From 5a77506ab189ad55bfd7f37f050eb23b87ca63ad Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Fri, 10 Apr 2026 17:00:37 +0800 Subject: [PATCH] Add Luker chat state persistence compatibility --- graph/graph-persistence.js | 181 +++++++++++ index.js | 631 +++++++++++++++++++++++++++++++++--- tests/graph-persistence.mjs | 148 +++++++++ 3 files changed, 922 insertions(+), 38 deletions(-) diff --git a/graph/graph-persistence.js b/graph/graph-persistence.js index d4628ab..70b8a4a 100644 --- a/graph/graph-persistence.js +++ b/graph/graph-persistence.js @@ -11,6 +11,9 @@ import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js"; export const MODULE_NAME = "st_bme"; export const GRAPH_METADATA_KEY = "st_bme_graph"; export const GRAPH_COMMIT_MARKER_KEY = "st_bme_commit_marker"; +export const GRAPH_CHAT_STATE_NAMESPACE = `${MODULE_NAME}_graph_state`; +export const GRAPH_CHAT_STATE_VERSION = 1; +export const GRAPH_CHAT_STATE_MAX_OPERATIONS = 4000; export const GRAPH_PERSISTENCE_META_KEY = "__stBmePersistence"; export const GRAPH_LOAD_STATES = Object.freeze({ NO_CHAT: "no-chat", @@ -374,6 +377,184 @@ export function writeChatMetadataPatch(context, patch = {}) { return true; } +export function canUseGraphChatState(context = null) { + return ( + !!context && + typeof context.getChatState === "function" && + typeof context.updateChatState === "function" + ); +} + +export function normalizeGraphChatStateSnapshot(snapshot = null) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + return null; + } + + const version = Number(snapshot.version); + const revision = Number(snapshot.revision); + const serializedGraph = String(snapshot.serializedGraph || ""); + const storageTier = String(snapshot.storageTier || "chat-state"); + const chatId = normalizeIdentityValue(snapshot.chatId); + const integrity = normalizeIdentityValue(snapshot.integrity); + const commitMarker = normalizeGraphCommitMarker(snapshot.commitMarker); + + if (!serializedGraph) { + return null; + } + + return { + version: Number.isFinite(version) && version > 0 ? version : GRAPH_CHAT_STATE_VERSION, + revision: Number.isFinite(revision) && revision > 0 ? revision : 0, + serializedGraph, + persistedAt: String(snapshot.persistedAt || ""), + updatedAt: String(snapshot.updatedAt || snapshot.persistedAt || ""), + reason: String(snapshot.reason || ""), + storageTier, + chatId, + integrity, + commitMarker, + }; +} + +export function buildGraphChatStateSnapshot( + graph, + { + revision = 0, + storageTier = "chat-state", + accepted = true, + reason = "", + persistedAt = "", + updatedAt = "", + chatId = "", + integrity = "", + lastProcessedAssistantFloor = null, + extractionCount = null, + } = {}, +) { + if (!graph) { + return null; + } + + const commitMarker = buildGraphCommitMarker(graph, { + revision, + storageTier, + accepted, + reason, + persistedAt, + chatId, + integrity, + lastProcessedAssistantFloor, + extractionCount, + }); + + return normalizeGraphChatStateSnapshot({ + version: GRAPH_CHAT_STATE_VERSION, + revision, + serializedGraph: serializeGraph(graph), + persistedAt: String(persistedAt || new Date().toISOString()), + updatedAt: String(updatedAt || persistedAt || new Date().toISOString()), + reason: String(reason || ""), + storageTier: String(storageTier || "chat-state"), + chatId, + integrity, + commitMarker, + }); +} + +export async function readGraphChatStateSnapshot( + context = null, + { namespace = GRAPH_CHAT_STATE_NAMESPACE } = {}, +) { + if (!canUseGraphChatState(context)) { + return null; + } + + try { + const payload = await context.getChatState(namespace); + return normalizeGraphChatStateSnapshot(payload); + } catch (error) { + console.warn("[ST-BME] 读取聊天侧车图谱失败:", error); + return null; + } +} + +export async function writeGraphChatStateSnapshot( + context = null, + graph = null, + { + namespace = GRAPH_CHAT_STATE_NAMESPACE, + revision = 0, + storageTier = "chat-state", + accepted = true, + reason = "", + chatId = "", + integrity = "", + lastProcessedAssistantFloor = null, + extractionCount = null, + maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS, + } = {}, +) { + if (!canUseGraphChatState(context) || !graph) { + return { + ok: false, + updated: false, + snapshot: null, + reason: "chat-state-unavailable", + }; + } + + const snapshot = buildGraphChatStateSnapshot(graph, { + revision, + storageTier, + accepted, + reason, + chatId, + integrity, + lastProcessedAssistantFloor, + extractionCount, + }); + if (!snapshot) { + return { + ok: false, + updated: false, + snapshot: null, + reason: "chat-state-build-failed", + }; + } + + try { + const result = await context.updateChatState( + namespace, + () => snapshot, + { + maxOperations, + asyncDiff: false, + maxRetries: 1, + }, + ); + return { + ok: result?.ok === true, + updated: result?.updated !== false, + snapshot, + reason: + result?.ok === true + ? result?.updated === false + ? "chat-state-noop" + : "chat-state-saved" + : "chat-state-save-failed", + }; + } catch (error) { + console.warn("[ST-BME] 写入聊天侧车图谱失败:", error); + return { + ok: false, + updated: false, + snapshot, + reason: "chat-state-save-failed", + error, + }; + } +} + export function normalizeGraphCommitMarker(marker = null) { if (!marker || typeof marker !== "object" || Array.isArray(marker)) { return null; diff --git a/index.js b/index.js index 97a3df5..352588f 100644 --- a/index.js +++ b/index.js @@ -97,9 +97,11 @@ import { } from "./maintenance/hierarchical-summary.js"; import { buildGraphCommitMarker, + canUseGraphChatState, detectIndexedDbSnapshotCommitMarkerMismatch, findGraphShadowSnapshotByIntegrity, getAcceptedCommitMarkerRevision, + GRAPH_CHAT_STATE_NAMESPACE, GRAPH_LOAD_PENDING_CHAT_ID, GRAPH_LOAD_STATES, GRAPH_COMMIT_MARKER_KEY, @@ -115,9 +117,11 @@ import { removeGraphShadowSnapshot, rememberGraphIdentityAlias, readGraphCommitMarker, + readGraphChatStateSnapshot, resolveGraphIdentityAliasByHostChatId, stampGraphPersistenceMeta, writeChatMetadataPatch, + writeGraphChatStateSnapshot, writeGraphShadowSnapshot, } from "./graph/graph-persistence.js"; import { @@ -688,6 +692,8 @@ const bmeIndexedDbLoadInFlightByChatId = new Map(); const bmeIndexedDbWriteInFlightByChatId = new Map(); const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map(); const bmeIndexedDbLatestQueuedRevisionByChatId = new Map(); +const bmeChatStateSnapshotCacheByChatId = new Map(); +const bmeChatStateLoadInFlightByChatId = new Map(); const PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS = [500, 1500, 5000]; const PENDING_GRAPH_PERSIST_MAX_RETRY_ATTEMPTS = 5; const BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET = new Set([ @@ -4329,6 +4335,382 @@ function readCachedIndexedDbSnapshot(chatId) { return cacheEntry.snapshot; } +function cacheChatStateSnapshot(chatId, snapshot = null) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !snapshot || typeof snapshot !== "object") return; + bmeChatStateSnapshotCacheByChatId.set(normalizedChatId, { + chatId: normalizedChatId, + revision: Number(snapshot?.revision || 0), + snapshot, + updatedAt: Date.now(), + }); +} + +function readCachedChatStateSnapshot(chatId) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + const cacheEntry = bmeChatStateSnapshotCacheByChatId.get(normalizedChatId); + if (!cacheEntry?.snapshot) return null; + return cacheEntry.snapshot; +} + +function canUseHostGraphChatStatePersistence(context = getContext()) { + return canUseGraphChatState(context); +} + +function selectPreferredCommitMarker(...candidates) { + let bestMarker = null; + let bestRevision = 0; + + for (const candidate of candidates) { + const revision = getAcceptedCommitMarkerRevision(candidate); + if (revision > bestRevision) { + bestRevision = revision; + bestMarker = candidate; + } + } + + return bestMarker || null; +} + +async function persistGraphToHostChatState( + context = getContext(), + { + graph = currentGraph, + revision = graphPersistenceState.revision, + reason = "graph-chat-state", + storageTier = "chat-state", + accepted = true, + lastProcessedAssistantFloor = null, + extractionCount: nextExtractionCount = null, + mode = "primary", + } = {}, +) { + if (!context || !graph || !canUseHostGraphChatStatePersistence(context)) { + return { + saved: false, + accepted: false, + reason: "chat-state-unavailable", + revision, + storageTier, + }; + } + + const chatId = getCurrentChatId(context); + if (!chatId) { + return { + saved: false, + accepted: false, + reason: "missing-chat-id", + revision, + storageTier, + }; + } + + const resolvedIdentity = resolveCurrentChatIdentity(context); + const nextIntegrity = + getChatMetadataIntegrity(context) || + normalizeChatIdCandidate(resolvedIdentity?.integrity) || + graphPersistenceState.metadataIntegrity; + const persistedGraph = cloneGraphForPersistence(graph, chatId); + stampGraphPersistenceMeta(persistedGraph, { + revision, + reason: `chat-state:${String(reason || "graph-chat-state")}`, + chatId, + integrity: nextIntegrity, + }); + + const writeResult = await writeGraphChatStateSnapshot( + context, + persistedGraph, + { + namespace: GRAPH_CHAT_STATE_NAMESPACE, + revision, + storageTier, + accepted, + reason, + chatId, + integrity: nextIntegrity, + lastProcessedAssistantFloor, + extractionCount: nextExtractionCount, + }, + ); + + if (!writeResult?.ok || !writeResult?.snapshot) { + updateGraphPersistenceState({ + dualWriteLastResult: { + action: "save", + target: "chat-state", + success: false, + chatId, + revision: Number(revision || 0), + reason: String(reason || "graph-chat-state"), + mode: String(mode || "primary"), + error: writeResult?.error?.message || writeResult?.reason || "chat-state-save-failed", + at: Date.now(), + }, + }); + return { + saved: false, + accepted: false, + reason: writeResult?.reason || "chat-state-save-failed", + revision, + storageTier, + error: writeResult?.error || null, + }; + } + + cacheChatStateSnapshot(chatId, writeResult.snapshot); + rememberResolvedGraphIdentityAlias(context, chatId); + updateGraphPersistenceState({ + metadataIntegrity: String(nextIntegrity || graphPersistenceState.metadataIntegrity || ""), + lastPersistReason: String(reason || ""), + lastPersistMode: + mode === "mirror" ? "chat-state-mirror" : "chat-state", + lastAcceptedRevision: + accepted === true + ? Math.max( + Number(graphPersistenceState.lastAcceptedRevision || 0), + Number(writeResult.snapshot.revision || revision || 0), + ) + : Number(graphPersistenceState.lastAcceptedRevision || 0), + dualWriteLastResult: { + action: "save", + target: "chat-state", + success: true, + chatId, + revision: Number(writeResult.snapshot.revision || revision || 0), + reason: String(reason || "graph-chat-state"), + mode: String(mode || "primary"), + at: Date.now(), + }, + }); + if (mode !== "mirror") { + clearPendingGraphPersistRetry(); + } + + return { + saved: true, + accepted, + chatId, + revision: Number(writeResult.snapshot.revision || revision || 0), + reason: String(reason || "graph-chat-state"), + saveMode: mode === "mirror" ? "chat-state-mirror" : "chat-state", + storageTier, + snapshot: writeResult.snapshot, + }; +} + +async function loadGraphFromChatState( + chatId, + { + source = "chat-state-probe", + attemptIndex = 0, + allowOverride = false, + } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + const context = getContext(); + if (!normalizedChatId) { + return { + success: false, + loaded: false, + reason: "chat-state-missing-chat-id", + chatId: "", + attemptIndex, + }; + } + if (!canUseHostGraphChatStatePersistence(context)) { + return { + success: false, + loaded: false, + reason: "chat-state-unavailable", + chatId: normalizedChatId, + attemptIndex, + }; + } + + const payload = + (await readGraphChatStateSnapshot(context, { + namespace: GRAPH_CHAT_STATE_NAMESPACE, + })) || readCachedChatStateSnapshot(normalizedChatId); + if (!payload?.serializedGraph) { + return { + success: false, + loaded: false, + reason: "chat-state-empty", + chatId: normalizedChatId, + attemptIndex, + }; + } + cacheChatStateSnapshot(normalizedChatId, payload); + + let chatStateGraph = null; + try { + chatStateGraph = cloneGraphForPersistence( + normalizeGraphRuntimeState( + deserializeGraph(payload.serializedGraph), + normalizedChatId, + ), + normalizedChatId, + ); + } catch (error) { + console.warn("[ST-BME] 聊天侧车图谱反序列化失败:", error); + return { + success: false, + loaded: false, + reason: "chat-state-deserialize-failed", + chatId: normalizedChatId, + attemptIndex, + error, + }; + } + + if (isGraphEffectivelyEmpty(chatStateGraph)) { + return { + success: false, + loaded: false, + reason: "chat-state-empty", + chatId: normalizedChatId, + attemptIndex, + }; + } + + const revision = Math.max( + 1, + Number(payload.revision || getGraphPersistedRevision(chatStateGraph) || 1), + ); + const integrity = + normalizeChatIdCandidate(payload.integrity) || + getChatMetadataIntegrity(context) || + graphPersistenceState.metadataIntegrity; + stampGraphPersistenceMeta(chatStateGraph, { + revision, + reason: `chat-state:${String(source || "chat-state-probe")}`, + chatId: normalizedChatId, + integrity, + }); + + const snapshot = buildSnapshotFromGraph(chatStateGraph, { + chatId: normalizedChatId, + revision, + meta: { + storagePrimary: "chat-state", + lastMutationReason: String(payload.reason || source || "chat-state"), + integrity, + }, + }); + const shadowSnapshot = resolveCompatibleGraphShadowSnapshot( + resolveCurrentChatIdentity(context), + ); + const shadowDecision = shouldPreferShadowSnapshotOverOfficial( + chatStateGraph, + shadowSnapshot, + ); + if (shadowSnapshot && shadowDecision?.prefer) { + return applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, { + source: `${source}:shadow-over-chat-state`, + attemptIndex, + }); + } + + const effectiveCommitMarker = selectPreferredCommitMarker( + payload.commitMarker, + getChatCommitMarker(context), + ); + const commitMarkerMismatch = detectIndexedDbSnapshotCommitMarkerMismatch( + snapshot, + effectiveCommitMarker, + ); + if (commitMarkerMismatch.mismatched) { + if ( + shadowSnapshot && + Number(shadowSnapshot.revision || 0) >= + Number(commitMarkerMismatch.markerRevision || 0) + ) { + return applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, { + source: `${source}:shadow-beats-chat-state-marker`, + attemptIndex, + }); + } + return applyPersistMismatchBlockedState( + normalizedChatId, + { + ...commitMarkerMismatch, + marker: commitMarkerMismatch.marker || effectiveCommitMarker, + }, + { + source: `${source}:chat-state-marker`, + attemptIndex, + }, + ); + } + + const shouldAllowOverride = + allowOverride || + BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has(graphPersistenceState.loadState) || + graphPersistenceState.storagePrimary === "chat-state" || + revision >= normalizeIndexedDbRevision(graphPersistenceState.revision); + if (!shouldAllowOverride) { + return { + success: false, + loaded: false, + reason: "chat-state-stale", + chatId: normalizedChatId, + attemptIndex, + revision, + }; + } + + if (getCurrentChatId() !== normalizedChatId) { + return { + success: false, + loaded: false, + reason: "chat-state-chat-switched", + chatId: normalizedChatId, + attemptIndex, + revision, + }; + } + + return applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { + source, + attemptIndex, + storagePrimary: "chat-state", + storageMode: "chat-state", + statusLabel: "聊天侧车", + reasonPrefix: "chat-state", + }); +} + +function scheduleGraphChatStateProbe(chatId, options = {}) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if ( + !normalizedChatId || + !canUseHostGraphChatStatePersistence(getContext()) || + bmeChatStateLoadInFlightByChatId.has(normalizedChatId) + ) { + return; + } + + scheduleBmeIndexedDbTask(() => { + const loadPromise = loadGraphFromChatState(normalizedChatId, options) + .catch((error) => { + console.warn("[ST-BME] 聊天侧车后台加载失败:", error); + }) + .finally(() => { + if ( + bmeChatStateLoadInFlightByChatId.get(normalizedChatId) === loadPromise + ) { + bmeChatStateLoadInFlightByChatId.delete(normalizedChatId); + } + }); + + bmeChatStateLoadInFlightByChatId.set(normalizedChatId, loadPromise); + return loadPromise; + }); +} + function readLegacyGraphFromChatMetadata(chatId, context = getContext()) { const normalizedChatId = normalizeChatIdCandidate(chatId); if (!normalizedChatId) return null; @@ -4813,7 +5195,14 @@ function applyIndexedDbEmptyToRuntime( function applyIndexedDbSnapshotToRuntime( chatId, snapshot, - { source = "indexeddb", attemptIndex = 0 } = {}, + { + source = "indexeddb", + attemptIndex = 0, + storagePrimary = "indexeddb", + storageMode = storagePrimary, + statusLabel = "IndexedDB", + reasonPrefix = "indexeddb", + } = {}, ) { const normalizedChatId = normalizeChatIdCandidate(chatId); syncCommitMarkerToPersistenceState(getContext()); @@ -4821,7 +5210,7 @@ function applyIndexedDbSnapshotToRuntime( return { success: false, loaded: false, - reason: "indexeddb-empty", + reason: `${reasonPrefix}-empty`, chatId: normalizedChatId, attemptIndex, }; @@ -4836,30 +5225,34 @@ function applyIndexedDbSnapshotToRuntime( snapshot, ); if (staleDecision.stale) { - updateGraphPersistenceState({ - storagePrimary: - graphPersistenceState.storagePrimary || "indexeddb", - storageMode: graphPersistenceState.storageMode || "indexeddb", - indexedDbRevision: Math.max( - graphPersistenceState.indexedDbRevision || 0, - revision, - ), + const persistencePatch = { + storagePrimary: graphPersistenceState.storagePrimary || storagePrimary, + storageMode: graphPersistenceState.storageMode || storageMode, metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, indexedDbLastError: "", dualWriteLastResult: { action: "load", - source: String(source || "indexeddb"), + source: String(source || reasonPrefix), success: false, rejected: true, - reason: "indexeddb-stale-runtime", + reason: `${reasonPrefix}-stale-runtime`, revision, staleDetail: cloneRuntimeDebugValue(staleDecision, null), at: Date.now(), }, + }; + if (storagePrimary === "indexeddb") { + persistencePatch.indexedDbRevision = Math.max( + graphPersistenceState.indexedDbRevision || 0, + revision, + ); + } + updateGraphPersistenceState({ + ...persistencePatch, }); - debugDebug("[ST-BME] 已拒绝用较旧 IndexedDB 快照覆盖当前运行时图谱", { + debugDebug(`[ST-BME] 已拒绝用较旧 ${statusLabel} 快照覆盖当前运行时图谱`, { chatId: normalizedChatId, source, revision, @@ -4868,7 +5261,7 @@ function applyIndexedDbSnapshotToRuntime( return { success: false, loaded: false, - reason: "indexeddb-stale-runtime", + reason: `${reasonPrefix}-stale-runtime`, chatId: normalizedChatId, attemptIndex, revision, @@ -4883,25 +5276,30 @@ function applyIndexedDbSnapshotToRuntime( } catch (error) { const failureReason = error?.code === "BME_SNAPSHOT_INTEGRITY_ERROR" - ? "indexeddb-snapshot-integrity-rejected" - : "indexeddb-snapshot-load-failed"; - updateGraphPersistenceState({ - storagePrimary: "indexeddb", - storageMode: "indexeddb", + ? `${reasonPrefix}-snapshot-integrity-rejected` + : `${reasonPrefix}-snapshot-load-failed`; + const persistencePatch = { + storagePrimary, + storageMode, dbReady: true, - indexedDbRevision: revision, indexedDbLastError: error?.message || String(error), dualWriteLastResult: { action: "load", - source: String(source || "indexeddb"), + source: String(source || reasonPrefix), success: false, rejected: true, reason: failureReason, revision, at: Date.now(), }, + }; + if (storagePrimary === "indexeddb") { + persistencePatch.indexedDbRevision = revision; + } + updateGraphPersistenceState({ + ...persistencePatch, }); - console.warn("[ST-BME] IndexedDB 图谱快照已拒绝加载", { + console.warn(`[ST-BME] ${statusLabel} 图谱快照已拒绝加载`, { chatId: normalizedChatId, source, revision, @@ -4925,7 +5323,7 @@ function applyIndexedDbSnapshotToRuntime( ); stampGraphPersistenceMeta(currentGraph, { revision, - reason: `indexeddb:${String(source || "indexeddb")}`, + reason: `${reasonPrefix}:${String(source || reasonPrefix)}`, chatId: normalizedChatId, integrity: normalizeChatIdCandidate(snapshot?.meta?.integrity) || @@ -4940,29 +5338,29 @@ function applyIndexedDbSnapshotToRuntime( const restoredRecallUi = restoreRecallUiStateFromPersistence( getContext()?.chat, ); - runtimeStatus = createUiStatus("待命", "已从 IndexedDB 加载聊天图谱", "idle"); + runtimeStatus = createUiStatus("待命", `已从${statusLabel}加载聊天图谱`, "idle"); lastExtractionStatus = createUiStatus( "待命", - "已从 IndexedDB 加载聊天图谱,等待下一次提取", + `已从${statusLabel}加载聊天图谱,等待下一次提取`, "idle", ); lastVectorStatus = createUiStatus( "待命", currentGraph.vectorIndexState?.lastWarning || - "已从 IndexedDB 加载聊天图谱,等待下一次向量任务", + `已从${statusLabel}加载聊天图谱,等待下一次向量任务`, "idle", ); lastRecallStatus = createUiStatus( "待命", restoredRecallUi.restored ? "已从持久化召回记录恢复显示,等待下一次召回" - : "已从 IndexedDB 加载聊天图谱,等待下一次召回", + : `已从${statusLabel}加载聊天图谱,等待下一次召回`, "idle", ); applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { chatId: normalizedChatId, - reason: `indexeddb:${source}`, + reason: `${reasonPrefix}:${source}`, attemptIndex, revision, lastPersistedRevision: Math.max( @@ -4977,16 +5375,15 @@ function applyIndexedDbSnapshotToRuntime( shadowSnapshotReason: "", writesBlocked: false, }); - updateGraphPersistenceState({ - storagePrimary: "indexeddb", - storageMode: "indexeddb", + const persistencePatch = { + storagePrimary, + storageMode, dbReady: true, persistMismatchReason: "", - indexedDbRevision: revision, metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, - indexedDbLastError: "", + indexedDbLastError: storagePrimary === "indexeddb" ? "" : graphPersistenceState.indexedDbLastError, lastAcceptedRevision: Math.max( Number(graphPersistenceState.lastAcceptedRevision || 0), revision, @@ -4994,19 +5391,23 @@ function applyIndexedDbSnapshotToRuntime( lastSyncError: "", dualWriteLastResult: { action: "load", - source: String(source || "indexeddb"), + source: String(source || reasonPrefix), success: true, - reason: "indexeddb-loaded", + reason: `${reasonPrefix}-loaded`, revision, at: Date.now(), }, - }); + }; + if (storagePrimary === "indexeddb") { + persistencePatch.indexedDbRevision = revision; + } + updateGraphPersistenceState(persistencePatch); rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); removeGraphShadowSnapshot(normalizedChatId); refreshPanelLiveState(); schedulePersistedRecallMessageUiRefresh(30); - debugDebug("[ST-BME] 已从 IndexedDB 加载图谱", { + debugDebug(`[ST-BME] 已从${statusLabel}加载图谱`, { chatId: normalizedChatId, source, revision, @@ -5017,7 +5418,7 @@ function applyIndexedDbSnapshotToRuntime( success: true, loaded: true, loadState: GRAPH_LOAD_STATES.LOADED, - reason: `indexeddb:${source}`, + reason: `${reasonPrefix}:${source}`, chatId: normalizedChatId, attemptIndex, shadowSnapshotUsed: false, @@ -6490,6 +6891,18 @@ async function retryPendingGraphPersist({ reason, }); if (indexedDbResult?.saved) { + const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context) + ? await persistGraphToHostChatState(context, { + graph: pendingPersistGraph, + revision: targetRevision, + reason: `${reason}:chat-state-mirror`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "mirror", + }) + : null; clearPendingGraphPersistRetry(); persistGraphCommitMarker(context, { reason, @@ -6531,6 +6944,66 @@ async function retryPendingGraphPersist({ return persistResult; } + if (canUseHostGraphChatStatePersistence(context)) { + const chatStateResult = await persistGraphToHostChatState(context, { + graph: pendingPersistGraph, + revision: targetRevision, + reason: `${reason}:chat-state-fallback`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "primary", + }); + if (chatStateResult?.saved) { + clearPendingGraphPersistRetry(); + persistGraphCommitMarker(context, { + reason: `${reason}:chat-state-fallback`, + revision: targetRevision, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + immediate: true, + }); + updateGraphPersistenceState({ + pendingPersist: false, + persistMismatchReason: "", + lastAcceptedRevision: Math.max( + Number(graphPersistenceState.lastAcceptedRevision || 0), + targetRevision, + ), + lastPersistReason: `${reason}:chat-state-fallback`, + lastPersistMode: "chat-state", + queuedPersistRevision: 0, + queuedPersistChatId: "", + queuedPersistMode: "", + queuedPersistRotateIntegrity: false, + queuedPersistReason: "", + storagePrimary: "chat-state", + storageMode: "chat-state", + }); + const persistResult = buildGraphPersistResult({ + saved: true, + accepted: true, + reason: `${reason}:chat-state-fallback`, + revision: targetRevision, + saveMode: "chat-state", + storageTier: "chat-state", + }); + applyAcceptedPendingPersistState(persistResult, { + lastProcessedAssistantFloor, + persistedGraph: pendingPersistGraph, + }); + queueGraphPersistToIndexedDb(activeChatId, pendingPersistGraph, { + revision: targetRevision, + reason: `${reason}:chat-state-fallback:promote-indexeddb`, + }); + void maybeResumePendingAutoExtraction("pending-persist-resolved:chat-state"); + return persistResult; + } + } + if (canPersistGraphToMetadataFallback(context, pendingPersistGraph)) { const metadataReason = `${reason}:metadata-full-fallback`; const metadataResult = persistGraphToChatMetadata(context, { @@ -6635,6 +7108,18 @@ async function persistExtractionBatchResult({ reason, }); if (indexedDbResult?.saved) { + const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context) + ? await persistGraphToHostChatState(context, { + graph: persistGraph, + revision, + reason: `${reason}:chat-state-mirror`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "mirror", + }) + : null; persistGraphCommitMarker(context, { reason, revision, @@ -6670,6 +7155,60 @@ async function persistExtractionBatchResult({ }); } + if (canUseHostGraphChatStatePersistence(context)) { + const chatStateResult = await persistGraphToHostChatState(context, { + graph: persistGraph, + revision, + reason: `${reason}:chat-state-fallback`, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + mode: "primary", + }); + if (chatStateResult?.saved) { + persistGraphCommitMarker(context, { + reason: `${reason}:chat-state-fallback`, + revision, + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor, + extractionCount, + immediate: true, + }); + updateGraphPersistenceState({ + pendingPersist: false, + persistMismatchReason: "", + lastAcceptedRevision: Math.max( + Number(graphPersistenceState.lastAcceptedRevision || 0), + revision, + ), + lastPersistReason: `${reason}:chat-state-fallback`, + lastPersistMode: "chat-state", + queuedPersistRevision: 0, + queuedPersistChatId: "", + queuedPersistMode: "", + queuedPersistRotateIntegrity: false, + queuedPersistReason: "", + storagePrimary: "chat-state", + storageMode: "chat-state", + }); + clearPendingGraphPersistRetry(); + queueGraphPersistToIndexedDb(chatId, persistGraph, { + revision, + reason: `${reason}:chat-state-fallback:promote-indexeddb`, + }); + return buildGraphPersistResult({ + saved: true, + accepted: true, + reason: `${reason}:chat-state-fallback`, + revision, + saveMode: "chat-state", + storageTier: "chat-state", + }); + } + } + const shadowReason = `${reason}:shadow-fallback`; const shadowCaptured = maybeCaptureGraphShadowSnapshot(shadowReason, { graph: persistGraph, @@ -6897,6 +7436,14 @@ function syncGraphLoadFromLiveContext(options = {}) { }; } + if (canUseHostGraphChatStatePersistence(context)) { + scheduleGraphChatStateProbe(chatId, { + source: `${source}:chat-state-probe`, + attemptIndex: 0, + allowOverride: true, + }); + } + const cachedSnapshot = readCachedIndexedDbSnapshot(chatId); if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) { const result = applyIndexedDbSnapshotToRuntime(chatId, cachedSnapshot, { @@ -7913,6 +8460,14 @@ function loadGraphFromChat(options = {}) { }; } + if (canUseHostGraphChatStatePersistence(context)) { + scheduleGraphChatStateProbe(chatId, { + source: `${source}:chat-state-probe`, + attemptIndex, + allowOverride: true, + }); + } + const cachedSnapshot = readCachedIndexedDbSnapshot(chatId); if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) { const cachedResult = applyIndexedDbSnapshotToRuntime( diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index e00fc86..a38878f 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -12,10 +12,13 @@ import { import { onMessageReceivedController } from "../host/event-binding.js"; import { buildGraphCommitMarker, + buildGraphChatStateSnapshot, + canUseGraphChatState, detectIndexedDbSnapshotCommitMarkerMismatch, cloneGraphForPersistence, cloneRuntimeDebugValue, findGraphShadowSnapshotByIntegrity, + GRAPH_CHAT_STATE_NAMESPACE, getAcceptedCommitMarkerRevision, getGraphPersistedRevision, getGraphIdentityAliasCandidates, @@ -33,6 +36,7 @@ import { MODULE_NAME, normalizeGraphCommitMarker, readGraphCommitMarker, + readGraphChatStateSnapshot, readGraphShadowSnapshot, rememberGraphIdentityAlias, removeGraphShadowSnapshot, @@ -40,6 +44,7 @@ import { shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, + writeGraphChatStateSnapshot, writeGraphShadowSnapshot, } from "../graph/graph-persistence.js"; import { @@ -391,9 +396,12 @@ async function createGraphPersistenceHarness({ readPersistedRecallFromUserMessage, cloneGraphForPersistence, buildGraphCommitMarker, + buildGraphChatStateSnapshot, + canUseGraphChatState, cloneRuntimeDebugValue, detectIndexedDbSnapshotCommitMarkerMismatch, onMessageReceivedController, + GRAPH_CHAT_STATE_NAMESPACE, getAcceptedCommitMarkerRevision, getGraphPersistenceMeta, getGraphPersistedRevision, @@ -412,6 +420,7 @@ async function createGraphPersistenceHarness({ findGraphShadowSnapshotByIntegrity, normalizeGraphCommitMarker, readGraphCommitMarker, + readGraphChatStateSnapshot, readGraphShadowSnapshot, rememberGraphIdentityAlias, removeGraphShadowSnapshot, @@ -419,6 +428,7 @@ async function createGraphPersistenceHarness({ shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, + writeGraphChatStateSnapshot, writeGraphShadowSnapshot, // Shadow snapshot functions need VM-local sessionStorage overrides // because imported versions use the outer globalThis (no sessionStorage) @@ -711,6 +721,7 @@ async function createGraphPersistenceHarness({ characterId, groupId, chat, + __chatStateStore: new Map(), updateChatMetadata(patch) { const base = this.chatMetadata && @@ -729,6 +740,36 @@ async function createGraphPersistenceHarness({ async saveMetadata() { runtimeContext.__contextImmediateSaveCalls += 1; }, + async getChatState(namespace) { + const key = String(namespace || "").trim().toLowerCase(); + const value = this.__chatStateStore.get(key); + return value == null ? null : structuredClone(value); + }, + async updateChatState(namespace, updater) { + const key = String(namespace || "").trim().toLowerCase(); + if (!key || typeof updater !== "function") { + return { ok: false, state: null, updated: false }; + } + const current = this.__chatStateStore.has(key) + ? structuredClone(this.__chatStateStore.get(key)) + : {}; + const next = await updater(structuredClone(current), { + attempt: 0, + target: null, + namespace: key, + }); + if (next == null) { + return { ok: true, state: current, updated: false }; + } + const currentJson = JSON.stringify(current); + const nextJson = JSON.stringify(next); + this.__chatStateStore.set(key, structuredClone(next)); + return { + ok: true, + state: structuredClone(next), + updated: currentJson !== nextJson, + }; + }, }, __contextSaveCalls: 0, __contextImmediateSaveCalls: 0, @@ -2675,4 +2716,111 @@ result = { ); } +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-state-save", + globalChatId: "chat-state-save", + chatMetadata: { + integrity: "meta-chat-state-save", + }, + indexedDbSnapshot: { + meta: { + chatId: "chat-state-save", + revision: 0, + }, + nodes: [], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: -1, + extractionCount: 0, + }, + }, + }); + + const graph = stampPersistedGraph( + createMeaningfulGraph("chat-state-save", "sidecar"), + { + revision: 7, + integrity: "meta-chat-state-save", + chatId: "chat-state-save", + reason: "chat-state-seed", + }, + ); + + const result = await harness.runtimeContext.persistGraphToHostChatState( + harness.runtimeContext.__chatContext, + { + graph, + revision: 7, + reason: "chat-state-direct-save", + storageTier: "chat-state", + accepted: true, + lastProcessedAssistantFloor: 6, + extractionCount: 3, + mode: "primary", + }, + ); + + assert.equal(result.saved, true); + const stored = await harness.runtimeContext.__chatContext.getChatState( + GRAPH_CHAT_STATE_NAMESPACE, + ); + assert.equal(stored?.revision, 7); + assert.equal(stored?.commitMarker?.storageTier, "chat-state"); + assert.equal( + harness.api.getGraphPersistenceState().dualWriteLastResult?.target, + "chat-state", + ); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-state-read", + globalChatId: "chat-state-read", + chatMetadata: { + integrity: "meta-chat-state-read", + }, + }); + + const sidecarGraph = stampPersistedGraph( + createMeaningfulGraph("chat-state-read", "sidecar-read"), + { + revision: 9, + integrity: "meta-chat-state-read", + chatId: "chat-state-read", + reason: "chat-state-read-seed", + }, + ); + harness.runtimeContext.__chatContext.__chatStateStore.set( + GRAPH_CHAT_STATE_NAMESPACE, + buildGraphChatStateSnapshot(sidecarGraph, { + revision: 9, + storageTier: "chat-state", + accepted: true, + reason: "chat-state-read-seed", + chatId: "chat-state-read", + integrity: "meta-chat-state-read", + lastProcessedAssistantFloor: 6, + extractionCount: 3, + }), + ); + + const result = await harness.runtimeContext.readGraphChatStateSnapshot( + harness.runtimeContext.__chatContext, + { + namespace: GRAPH_CHAT_STATE_NAMESPACE, + }, + ); + + assert.equal( + harness.runtimeContext.canUseGraphChatState( + harness.runtimeContext.__chatContext, + ), + true, + ); + assert.equal(result?.revision, 9); + assert.equal(result?.commitMarker?.storageTier, "chat-state"); +} + console.log("graph-persistence tests passed");