From 22bf3cf588461c28302600da842691750862db36 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 15 Apr 2026 15:43:06 +0800 Subject: [PATCH] fix: clear stale recovery sources during graph cleanup --- index.js | 1 + tests/p0-regressions.mjs | 130 ++++++++++++++++++++++++++++++++++++ ui/panel.js | 5 +- ui/ui-actions-controller.js | 33 ++++++++- 4 files changed, 165 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 9491c21..eaaa2a4 100644 --- a/index.js +++ b/index.js @@ -17042,6 +17042,7 @@ const _cleanupRuntime = () => ({ getRequestHeaders: typeof getRequestHeaders === "function" ? getRequestHeaders : undefined, }), getGraphPersistenceState: () => graphPersistenceState, + getSettings, toastr, }); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 9307a3e..c5a8168 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -67,6 +67,7 @@ import { shouldRunRecallForTransaction, } from "../ui/ui-status.js"; import { + onClearGraphController, onDeleteCurrentIdbController, onManualCompressController, onManualEvolveController, @@ -2082,6 +2083,16 @@ async function testDeleteCurrentIdbClearsCommitMarkerBeforeReload() { options.immediate === true, ]); }, + getGraphPersistenceState() { + return { + lastSyncedRevision: 9, + }; + }, + getSettings() { + return { + cloudStorageMode: "automatic", + }; + }, syncGraphLoadFromLiveContext(options = {}) { callLog.push([ "sync-graph-load", @@ -2119,11 +2130,128 @@ async function testDeleteCurrentIdbClearsCommitMarkerBeforeReload() { clearMarkerIndex < syncLoadIndex, "应先清理 commit marker,再触发图谱重探测", ); + assert.ok( + callLog.some( + (entry) => + entry[0] === "toast-success" && + /删除服务端同步数据/.test(String(entry[1] || "")), + ), + "若当前聊天存在远端同步记录,应提示用户本地缓存删除后仍可能被远端恢复", + ); } finally { globalThis.indexedDB = originalIndexedDb; } } +async function testClearGraphClearsRecoveryAnchorsAndPersistsEmptyMetadata() { + const callLog = []; + const runtime = { + confirm() { + return true; + }, + ensureGraphMutationReady() { + return true; + }, + getCurrentChatId() { + return "chat-clear-graph"; + }, + clearCurrentChatRecoveryAnchors(options = {}) { + callLog.push([ + "clear-recovery-anchors", + String(options.chatId || ""), + String(options.reason || ""), + options.clearMetadataFull === true, + options.clearCommitMarker === true, + options.clearPendingPersist === true, + ]); + }, + normalizeGraphRuntimeState(graph, chatId) { + return { + ...(graph || {}), + historyState: { + chatId, + }, + }; + }, + createEmptyGraph() { + return { + nodes: [], + edges: [], + historyState: {}, + }; + }, + setCurrentGraph(graph) { + callLog.push(["set-current-graph", Array.isArray(graph?.nodes) ? graph.nodes.length : -1]); + }, + clearInjectionState() { + callLog.push(["clear-injection"]); + }, + markVectorStateDirty(reason) { + callLog.push(["mark-vector-dirty", String(reason || "")]); + }, + setExtractionCount(count) { + callLog.push(["set-extraction-count", Number(count)]); + }, + setLastExtractedItems(items = []) { + callLog.push(["set-last-extracted-items", Array.isArray(items) ? items.length : -1]); + }, + saveGraphToChat(options = {}) { + callLog.push([ + "save-graph", + String(options.reason || ""), + options.persistMetadata === true, + options.captureShadow === false, + ]); + }, + refreshPanelLiveState() { + callLog.push(["refresh-panel"]); + }, + getGraphPersistenceState() { + return { + lastSyncedRevision: 0, + }; + }, + getSettings() { + return { + cloudStorageMode: "automatic", + }; + }, + toastr: { + success(message) { + callLog.push(["toast-success", String(message || "")]); + }, + warning(message) { + callLog.push(["toast-warning", String(message || "")]); + }, + error(message) { + callLog.push(["toast-error", String(message || "")]); + }, + }, + }; + + const result = await onClearGraphController(runtime); + assert.equal(result?.handledToast, true); + assert.ok( + callLog.some( + (entry) => + entry[0] === "clear-recovery-anchors" && + entry[1] === "chat-clear-graph" && + entry[2] === "manual-clear-graph", + ), + "清空图谱时应先清理当前聊天的恢复锚点", + ); + assert.ok( + callLog.some( + (entry) => + entry[0] === "save-graph" && + entry[1] === "manual-clear-graph" && + entry[2] === true && + entry[3] === true, + ), + "清空图谱时应显式把空图写入 metadata,避免旧恢复锚点复活", + ); +} + async function testCompressTypeAcceptsTopLevelFieldsResult() { const graph = createEmptyGraph(); const typeDef = { @@ -6919,6 +7047,8 @@ await testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask(); await testMessageReceivedDefersExtractionDuringHostGeneration(); await testMessageReceivedLagModeWaitsSilentlyForNextAssistant(); await testMessageReceivedLagModeQueuesPreviousAssistantOnly(); +await testClearGraphClearsRecoveryAnchorsAndPersistsEmptyMetadata(); +await testDeleteCurrentIdbClearsCommitMarkerBeforeReload(); await testLagModeSmartTriggerOnlyScoresEligibleWindow(); await testLagModeRespectsExtractEveryAgainstEligibleWindow(); await testGenerationEndedResumesPendingAutoExtractionAfterSettle(); diff --git a/ui/panel.js b/ui/panel.js index a319d03..901a633 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -11627,6 +11627,7 @@ function _isPersistenceRevisionAccepted(persistence = null, loadInfo = {}) { function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { const persistence = batchStatus?.persistence || null; + const localPersistError = String(loadInfo?.indexedDbLastError || "").trim(); if (_hasMeaningfulPersistenceRecord(persistence)) { const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const parts = [ @@ -11640,6 +11641,7 @@ function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { ? `rev ${Number(persistence.revision)}` : "", persistence.reason || "", + !accepted && localPersistError ? `本地错误 ${localPersistError}` : "", ].filter(Boolean); return parts.join(" · ") || "尚无持久化记录"; } @@ -11674,6 +11676,7 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = graph?.historyState?.lastProcessedAssistantFloor ?? -1; const persistence = batchStatus?.persistence || null; const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); + const localPersistError = String(loadInfo?.indexedDbLastError || "").trim(); const processedRange = Array.isArray(batchStatus?.processedRange) ? batchStatus.processedRange : []; @@ -11683,7 +11686,7 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = : null; if (_hasMeaningfulPersistenceRecord(persistence) && !accepted && pendingFloor != null) { - return `持久化待确认:本地已抽取到楼层 ${pendingFloor},已确认楼层 ${lastConfirmedFloor}`; + return `持久化待确认:本地已抽取到楼层 ${pendingFloor},已确认楼层 ${lastConfirmedFloor}${localPersistError ? ` · 本地错误 ${localPersistError}` : ""}`; } if (loadInfo?.persistMismatchReason) { diff --git a/ui/ui-actions-controller.js b/ui/ui-actions-controller.js index 3a309b0..15d908c 100644 --- a/ui/ui-actions-controller.js +++ b/ui/ui-actions-controller.js @@ -1056,6 +1056,18 @@ export async function onClearGraphController(runtime) { return { cancelled: true }; } if (!runtime.ensureGraphMutationReady("清空图谱")) return; + const chatId = runtime.getCurrentChatId?.(); + + if (chatId && typeof runtime.clearCurrentChatRecoveryAnchors === "function") { + runtime.clearCurrentChatRecoveryAnchors({ + chatId, + reason: "manual-clear-graph", + immediate: true, + clearMetadataFull: true, + clearCommitMarker: true, + clearPendingPersist: true, + }); + } const nextGraph = runtime.normalizeGraphRuntimeState( runtime.createEmptyGraph(), @@ -1066,9 +1078,21 @@ export async function onClearGraphController(runtime) { runtime.markVectorStateDirty?.("清空图谱后需要重建向量索引"); runtime.setExtractionCount(0); runtime.setLastExtractedItems([]); - runtime.saveGraphToChat({ reason: "manual-clear-graph" }); + runtime.saveGraphToChat({ + reason: "manual-clear-graph", + persistMetadata: true, + captureShadow: false, + }); runtime.refreshPanelLiveState(); - runtime.toastr.success("当前图谱已清空"); + const persistenceState = runtime.getGraphPersistenceState?.() || {}; + const remoteSyncMayRestore = + Number(persistenceState.lastSyncedRevision || 0) > 0 && + String(runtime.getSettings?.()?.cloudStorageMode || "automatic") !== "manual"; + runtime.toastr.success( + remoteSyncMayRestore + ? "当前图谱已清空;若刷新后旧节点重新出现,请再清空服务端同步数据" + : "当前图谱已清空", + ); return { handledToast: true }; } @@ -1256,8 +1280,11 @@ export async function onDeleteCurrentIdbController(runtime) { const deletedOpfs = currentOpfsResult?.deleted === true || restoreSafetyOpfsResult?.deleted === true; + const remoteSyncMayRestore = + Number(runtime.getGraphPersistenceState?.()?.lastSyncedRevision || 0) > 0 && + String(runtime.getSettings?.()?.cloudStorageMode || "automatic") !== "manual"; runtime.toastr.success( - `已清空当前聊天本地存储:IndexedDB ${deletedIndexedDbCount > 0 ? "已处理" : "无"},OPFS ${deletedOpfs ? "已处理" : "无"}`, + `已清空当前聊天本地存储:IndexedDB ${deletedIndexedDbCount > 0 ? "已处理" : "无"},OPFS ${deletedOpfs ? "已处理" : "无"}${remoteSyncMayRestore ? ";若刷新后旧图恢复,请再删除服务端同步数据" : ""}`, ); } catch (error) { runtime.toastr.error(`删除失败: ${error?.message || error}`);