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 = `
${_escHtml(_getGraphLoadLabel(loadInfo.loadState))}
`; + el.innerHTML = `
${_escHtml(_getGraphLoadLabel(loadInfo))}
`; return; } @@ -2941,7 +2941,7 @@ function _refreshSummaryWorkspace(targetEl) { if (!graph || !_canRenderGraphData(loadInfo)) { workspace.innerHTML = ` -
${_escHtml(_getGraphLoadLabel(loadInfo?.loadState))}
+
${_escHtml(_getGraphLoadLabel(loadInfo))}
`; return; } @@ -3056,7 +3056,7 @@ function _refreshDashboard() { _setText("bme-stat-archived", "—"); _setText("bme-stat-frag", "—"); _setText("bme-status-chat-id", loadInfo.chatId || "—"); - _setText("bme-status-history", _getGraphLoadLabel(loadInfo.loadState)); + _setText("bme-status-history", _getGraphLoadLabel(loadInfo)); _setText("bme-status-vector", "等待聊天图谱元数据加载"); _setText("bme-status-recovery", "等待聊天图谱元数据加载"); _setText("bme-status-last-extract", "等待聊天图谱元数据加载"); @@ -3066,11 +3066,11 @@ function _refreshDashboard() { _refreshPersistenceRepairUi(loadInfo, null); _renderStatefulListPlaceholder( document.getElementById("bme-recent-extract"), - _getGraphLoadLabel(loadInfo.loadState), + _getGraphLoadLabel(loadInfo), ); _renderStatefulListPlaceholder( document.getElementById("bme-recent-recall"), - _getGraphLoadLabel(loadInfo.loadState), + _getGraphLoadLabel(loadInfo), ); _refreshCognitionDashboard(graph, loadInfo); _refreshAiMonitorDashboard(); @@ -3569,17 +3569,17 @@ function _refreshCognitionDashboard( if (!canRenderGraph) { _setText("bme-cognition-active-owner", "—"); - _setText("bme-cognition-active-region", _getGraphLoadLabel(loadInfo.loadState)); + _setText("bme-cognition-active-region", _getGraphLoadLabel(loadInfo)); _setText("bme-cognition-adjacent-regions", "—"); _setText("bme-cognition-owner-count", "—"); _renderStatefulListPlaceholder( document.getElementById("bme-cognition-owner-list"), - _getGraphLoadLabel(loadInfo.loadState), + _getGraphLoadLabel(loadInfo), ); const detailEl = document.getElementById("bme-cognition-detail"); if (detailEl) { detailEl.innerHTML = ` -
${_escHtml(_getGraphLoadLabel(loadInfo.loadState))}
+
${_escHtml(_getGraphLoadLabel(loadInfo))}
`; } _setInputValueIfIdle("bme-cognition-manual-region", ""); @@ -3738,7 +3738,7 @@ function _refreshMemoryBrowser() { if (filterSelect) filterSelect.disabled = !canRenderGraph; if (!canRenderGraph && loadInfo.loadState !== "empty-confirmed") { - _renderStatefulListPlaceholder(listEl, _getGraphLoadLabel(loadInfo.loadState)); + _renderStatefulListPlaceholder(listEl, _getGraphLoadLabel(loadInfo)); return; } @@ -11702,10 +11702,19 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = return `干净,已确认处理到楼层 ${lastConfirmedFloor}`; } -function _getGraphLoadLabel(loadState = "") { +function _getGraphLoadLabel(loadInfoOrState = "") { + const loadInfo = + loadInfoOrState && typeof loadInfoOrState === "object" + ? loadInfoOrState + : null; + const loadState = String( + loadInfo ? loadInfo.loadState || "" : loadInfoOrState || "", + ); switch (loadState) { case "loading": - return "正在加载当前聊天图谱"; + return loadInfo?.runtimeGraphReadable === true + ? "图谱已暂载,正在确认本地存储" + : "正在加载当前聊天图谱"; case "shadow-restored": return "已从本次会话临时恢复,正在等待正式聊天元数据"; case "empty-confirmed": @@ -11808,7 +11817,7 @@ function _refreshGraphAvailabilityState() { const mobileOverlay = document.getElementById("bme-mobile-graph-overlay"); const mobileOverlayText = document.getElementById("bme-mobile-graph-overlay-text"); const blocked = _isGraphWriteBlocked(loadInfo); - const loadLabel = _getGraphLoadLabel(loadInfo.loadState); + const loadLabel = _getGraphLoadLabel(loadInfo); const pausedLabel = "图谱渲染已暂停,可点击工具栏按钮恢复。"; const renderingPaused = !_isGraphRenderingEnabled(); diff --git a/ui/ui-status.js b/ui/ui-status.js index 76064ed..e036656 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -87,6 +87,7 @@ export function createGraphPersistenceState() { opfsWalDepth: 0, opfsPendingBytes: 0, opfsCompactionState: null, + runtimeGraphReadable: false, remoteSyncFormatVersion: 1, dbReady: false, indexedDbRevision: 0,