From 1f2cddb2a3167c4383a430c0f0df28547e2f554b Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 15 Apr 2026 15:02:28 +0800 Subject: [PATCH] fix: settle mobile local persistence loading state --- index.js | 197 ++++++++++++++++++++++++++++++++++-- tests/graph-persistence.mjs | 99 ++++++++++++++++++ ui/panel.js | 33 +++--- ui/ui-status.js | 1 + 4 files changed, 312 insertions(+), 18 deletions(-) diff --git a/index.js b/index.js index 43380db..9491c21 100644 --- a/index.js +++ b/index.js @@ -1201,6 +1201,7 @@ let bmeLocalStoreCapabilityWarningShown = false; const bmeIndexedDbSnapshotCacheByChatId = new Map(); const bmeIndexedDbLoadInFlightByChatId = new Map(); const bmeIndexedDbWriteInFlightByChatId = new Map(); +const bmeIndexedDbRuntimeRepairInFlightByChatId = new Set(); const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map(); const bmeIndexedDbLocalStoreMigrationInFlightByChatId = new Map(); const bmeIndexedDbLatestQueuedRevisionByChatId = new Map(); @@ -1345,6 +1346,9 @@ function getGraphPersistenceLiveState() { graphPersistenceState.cacheStorageTier || persistenceEnvironment.cacheStorageTier, ); + const runtimeGraphReadable = hasMeaningfulRuntimeGraphForChat( + graphPersistenceState.chatId || getCurrentChatId(), + ); const snapshot = { loadState: graphPersistenceState.loadState, chatId: graphPersistenceState.chatId, @@ -1416,6 +1420,7 @@ function getGraphPersistenceLiveState() { graphPersistenceState.opfsCompactionState, null, ), + runtimeGraphReadable, remoteSyncFormatVersion: Number(graphPersistenceState.remoteSyncFormatVersion || 0) || 1, dbReady: graphPersistenceState.dbReady ?? @@ -1549,6 +1554,52 @@ function hasReadableRuntimeGraphForRecall(chatId = getCurrentChatId()) { return currentGraph.nodes.length > 0 || currentGraph.edges.length > 0; } +function hasMeaningfulRuntimeGraphForChat( + chatId = getCurrentChatId(), + identity = resolveCurrentChatIdentity(getContext()), +) { + if ( + !currentGraph || + typeof currentGraph !== "object" || + !Array.isArray(currentGraph.nodes) || + !Array.isArray(currentGraph.edges) || + !currentGraph.historyState || + typeof currentGraph.historyState !== "object" || + Array.isArray(currentGraph.historyState) + ) { + return false; + } + + const normalizedTargetChatId = normalizeChatIdCandidate(chatId); + const runtimeChatId = normalizeChatIdCandidate( + currentGraph.historyState.chatId, + ); + + if (normalizedTargetChatId && runtimeChatId) { + const sameChat = + areChatIdsEquivalentForResolvedIdentity( + runtimeChatId, + normalizedTargetChatId, + identity, + ) || + areChatIdsEquivalentForResolvedIdentity( + normalizedTargetChatId, + runtimeChatId, + identity, + ); + if (!sameChat) { + return false; + } + } else if ( + normalizedTargetChatId && + !doesChatIdMatchResolvedGraphIdentity(normalizedTargetChatId, identity) + ) { + return false; + } + + return !isGraphEffectivelyEmpty(currentGraph); +} + function isGraphReadableForRecall( loadState = graphPersistenceState.loadState, chatId = getCurrentChatId(), @@ -1571,6 +1622,15 @@ function createGraphLoadUiStatus() { case GRAPH_LOAD_STATES.NO_CHAT: return createUiStatus("待命", "当前尚未进入聊天", "idle"); case GRAPH_LOAD_STATES.LOADING: + if (hasMeaningfulRuntimeGraphForChat(chatId)) { + return createUiStatus( + "图谱已暂载", + chatId + ? `已读到聊天 ${chatId} 的临时图谱,正在确认本地存储` + : "已读到临时图谱,正在确认本地存储", + "warning", + ); + } return createUiStatus( "图谱加载中", chatId @@ -1631,7 +1691,9 @@ function getGraphMutationBlockReason(operationLabel = "当前操作") { switch (graphPersistenceState.loadState) { case GRAPH_LOAD_STATES.LOADING: - return `${operationLabel}已暂停:正在加载 IndexedDB 图谱。`; + return hasMeaningfulRuntimeGraphForChat() + ? `${operationLabel}已暂停:当前图谱已暂载,正在确认本地存储。` + : `${operationLabel}已暂停:正在加载 IndexedDB 图谱。`; case GRAPH_LOAD_STATES.SHADOW_RESTORED: return `${operationLabel}已暂停:当前图谱仍处于旧恢复状态,请等待 IndexedDB 初始化完成。`; case GRAPH_LOAD_STATES.BLOCKED: @@ -7844,6 +7906,102 @@ function applyIndexedDbEmptyToRuntime( }; } +function queueRuntimeGraphLocalStoreRepair( + chatId, + { + source = "runtime-local-store-repair", + scheduleCloudUpload = false, + } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + const identity = resolveCurrentChatIdentity(getContext()); + if ( + !normalizedChatId || + bmeIndexedDbRuntimeRepairInFlightByChatId.has(normalizedChatId) || + !hasMeaningfulRuntimeGraphForChat(normalizedChatId, identity) + ) { + return { + queued: false, + chatId: normalizedChatId || "", + reason: !normalizedChatId + ? "missing-chat-id" + : bmeIndexedDbRuntimeRepairInFlightByChatId.has(normalizedChatId) + ? "already-running" + : "runtime-graph-unavailable", + }; + } + + const graphSnapshot = cloneGraphForPersistence(currentGraph, normalizedChatId); + const requestedRevision = Math.max( + 1, + Number(getGraphPersistedRevision(graphSnapshot) || 0), + Number(graphPersistenceState.revision || 0), + Number(graphPersistenceState.lastAcceptedRevision || 0), + Number(graphPersistenceState.lastPersistedRevision || 0), + ); + const repairReason = `${String(source || "runtime-local-store-repair")}:repair-local-store`; + bmeIndexedDbRuntimeRepairInFlightByChatId.add(normalizedChatId); + updateGraphPersistenceState({ + indexedDbLastError: "", + lastPersistReason: repairReason, + lastPersistMode: "runtime-local-store-repair-queued", + }); + + scheduleBmeIndexedDbTask(async () => { + try { + const result = await saveGraphToIndexedDb(normalizedChatId, graphSnapshot, { + revision: requestedRevision, + reason: repairReason, + scheduleCloudUpload, + }); + if ( + result?.accepted !== true && + graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING && + hasMeaningfulRuntimeGraphForChat(normalizedChatId, identity) + ) { + applyGraphLoadState(GRAPH_LOAD_STATES.BLOCKED, { + chatId: normalizedChatId, + reason: result?.reason || "runtime-local-store-repair-failed", + revision: Math.max( + Number(graphPersistenceState.revision || 0), + Number(result?.revision || requestedRevision), + ), + lastPersistedRevision: Math.max( + Number(graphPersistenceState.lastPersistedRevision || 0), + Number(result?.revision || 0), + ), + pendingPersist: false, + dbReady: false, + writesBlocked: true, + }); + } + } catch (error) { + if ( + graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING && + hasMeaningfulRuntimeGraphForChat(normalizedChatId, identity) + ) { + applyGraphLoadState(GRAPH_LOAD_STATES.BLOCKED, { + chatId: normalizedChatId, + reason: error?.message || "runtime-local-store-repair-failed", + pendingPersist: false, + dbReady: false, + writesBlocked: true, + }); + } + } finally { + bmeIndexedDbRuntimeRepairInFlightByChatId.delete(normalizedChatId); + refreshPanelLiveState(); + } + }); + + return { + queued: true, + chatId: normalizedChatId, + reason: repairReason, + revision: requestedRevision, + }; +} + async function maybeResolveOrphanAcceptedCommitMarker( chatId, { @@ -8455,6 +8613,22 @@ async function loadGraphFromIndexedDb( return orphanMarkerResolution.result; } } + const runtimeRepair = queueRuntimeGraphLocalStoreRepair(normalizedChatId, { + source: `${source}:empty-local-store`, + scheduleCloudUpload: false, + }); + if (runtimeRepair.queued) { + return { + success: true, + loaded: false, + repairQueued: true, + loadState: GRAPH_LOAD_STATES.LOADING, + reason: `${snapshotStore.reasonPrefix}-repair-queued`, + chatId: normalizedChatId, + attemptIndex, + revision: Number(runtimeRepair.revision || 0), + }; + } if ( applyEmptyState && !commitMarkerDiagnostic?.reason && @@ -10399,7 +10573,7 @@ function reconcileIndexedDbProbeFailureState( result = {}, { attemptIndex = 0 } = {}, ) { - if (result?.loaded || result?.emptyConfirmed) { + if (result?.loaded || result?.emptyConfirmed || result?.repairQueued) { clearPendingGraphLoadRetry(); return result; } @@ -12534,15 +12708,26 @@ async function saveGraphToIndexedDb( }); clearPendingGraphPersistRetry(); if ( - graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED && - areChatIdsEquivalentForResolvedIdentity( + (graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED || + (graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING && + hasMeaningfulRuntimeGraphForChat(normalizedChatId, currentIdentity))) && + (areChatIdsEquivalentForResolvedIdentity( normalizedChatId, graphPersistenceState.chatId || getCurrentChatId(), - ) + currentIdentity, + ) || + areChatIdsEquivalentForResolvedIdentity( + graphPersistenceState.chatId || getCurrentChatId(), + normalizedChatId, + currentIdentity, + )) ) { applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { chatId: normalizedChatId, - reason: `shadow-promoted:${String(reason || "graph-save")}`, + reason: + graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED + ? `shadow-promoted:${String(reason || "graph-save")}` + : `local-store-confirmed:${String(reason || "graph-save")}`, revision: normalizeIndexedDbRevision( commitResult?.revision, requestedRevision, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 3253c80..488a1ac 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -1373,6 +1373,105 @@ result = { ); } +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-loading-local-confirm", + globalChatId: "chat-loading-local-confirm", + chatMetadata: { + integrity: "meta-chat-loading-local-confirm", + }, + }); + const graph = createMeaningfulGraph( + "chat-loading-local-confirm", + "loading-local-confirm", + ); + harness.api.setCurrentGraph(graph); + harness.api.setGraphPersistenceState({ + loadState: "loading", + chatId: "chat-loading-local-confirm", + reason: "metadata-compat-provisional", + dbReady: false, + writesBlocked: true, + revision: 5, + lastPersistedRevision: 0, + storagePrimary: "indexeddb", + storageMode: "indexeddb", + }); + + const result = await harness.api.saveGraphToIndexedDb( + "chat-loading-local-confirm", + graph, + { + revision: 6, + reason: "test-loading-local-confirm", + }, + ); + + assert.equal(result.accepted, true); + assert.equal(harness.api.getGraphPersistenceState().loadState, "loaded"); + assert.equal(harness.api.getGraphPersistenceState().dbReady, true); + assert.equal(harness.api.getGraphPersistenceState().writesBlocked, false); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-metadata-runtime-repair", + globalChatId: "chat-metadata-runtime-repair", + chatMetadata: { + integrity: "meta-chat-metadata-runtime-repair", + }, + }); + const metadataGraph = createMeaningfulGraph( + "chat-metadata-runtime-repair", + "metadata-runtime-repair", + ); + harness.api.setChatContext({ + chatId: "chat-metadata-runtime-repair", + chatMetadata: { + integrity: "meta-chat-metadata-runtime-repair", + [GRAPH_METADATA_KEY]: metadataGraph, + }, + characterId: "char-runtime-repair", + groupId: null, + chat: [{ is_user: true, mes: "repair me" }], + updateChatMetadata(patch) { + const base = + this.chatMetadata && + typeof this.chatMetadata === "object" && + !Array.isArray(this.chatMetadata) + ? this.chatMetadata + : {}; + this.chatMetadata = { + ...base, + ...(patch || {}), + }; + }, + saveMetadataDebounced() {}, + }); + + const result = harness.api.loadGraphFromChat({ + attemptIndex: 0, + source: "metadata-runtime-repair", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(result.loadState, "loading"); + assert.equal(harness.api.getCurrentGraph().nodes.length > 0, true); + assert.equal(harness.api.getGraphPersistenceState().loadState, "loaded"); + assert.equal(harness.api.getGraphPersistenceState().dbReady, true); + const repairedChatId = + harness.api.getGraphPersistenceState().chatId || + harness.api.getCurrentGraph().historyState.chatId || + "chat-metadata-runtime-repair"; + assert.equal( + harness.api.getIndexedDbSnapshotForChat(repairedChatId)?.nodes?.length > 0, + true, + "metadata 暂载图谱应自动回填到本地存储", + ); +} + { const harness = await createGraphPersistenceHarness({ chatId: "", diff --git a/ui/panel.js b/ui/panel.js index 55fd015..98e3cea 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -2539,7 +2539,7 @@ function _renderCogStatusStrip(graph, loadInfo, canRender, targetEl) { if (!el) return; if (!canRender) { - el.innerHTML = `