diff --git a/index.js b/index.js index 4fa56c2..d9cd0ff 100644 --- a/index.js +++ b/index.js @@ -715,7 +715,6 @@ let lastRecalledItems = []; // 最近召回的节点(面板展示用) let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) let serverSettingsSaveTimer = null; let isRecoveringHistory = false; -let lastHistoryWarningAt = 0; let lastRecallFallbackNoticeAt = 0; let lastExtractionWarningAt = 0; const LOCAL_VECTOR_TIMEOUT_MS = 300000; @@ -11249,13 +11248,6 @@ function notifyHistoryDirty(dirtyFrom, reason) { busy: true, }, ); - const now = Date.now(); - if (now - lastHistoryWarningAt < 3000) return; - lastHistoryWarningAt = now; - toastr.warning( - `检测到楼层历史变化,将从楼层 ${dirtyFrom} 之后自动恢复图谱`, - reason || "ST-BME 历史回退保护", - ); } function clearPendingHistoryMutationChecks() { @@ -11979,12 +11971,9 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { persist: false, }, ); - - toastr.success( - usedFullRebuild - ? "历史变化已触发全量重建" - : "历史变化已完成受影响后缀恢复", - ); + if (usedFullRebuild) { + toastr.warning("历史变化已触发全量重建"); + } return true; } catch (error) { if (isAbortError(error)) { diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 33a6712..b68deca 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -349,6 +349,11 @@ function createHistoryRecoveryHarness() { saveGraphToChatCalls: 0, refreshPanelCalls: 0, notices: [], + toastCalls: { + success: [], + warning: [], + error: [], + }, embeddingConfig: { mode: "backend" }, isRestoreLockActive() { return false; @@ -511,9 +516,15 @@ function createHistoryRecoveryHarness() { context.refreshPanelCalls += 1; }, toastr: { - success() {}, - warning() {}, - error() {}, + success(...args) { + context.toastCalls.success.push(args); + }, + warning(...args) { + context.toastCalls.warning.push(args); + }, + error(...args) { + context.toastCalls.error.push(args); + }, }, }; vm.createContext(context); @@ -526,6 +537,38 @@ function createHistoryRecoveryHarness() { }); } +function createHistoryNotificationHarness() { + return fs.readFile(indexPath, "utf8").then((source) => { + const start = source.indexOf("function notifyHistoryDirty(dirtyFrom, reason) {"); + const end = source.indexOf("function clearPendingHistoryMutationChecks() {"); + if (start < 0 || end < 0 || end <= start) { + throw new Error("无法从 index.js 提取 history notify 定义"); + } + const snippet = source.slice(start, end).replace(/^export\s+/gm, ""); + const context = { + console, + result: null, + notices: [], + warningToasts: [], + updateStageNotice(...args) { + context.notices.push(args); + }, + toastr: { + warning(...args) { + context.warningToasts.push(args); + }, + }, + }; + vm.createContext(context); + vm.runInContext( + `${snippet}\nresult = { notifyHistoryDirty };`, + context, + { filename: indexPath }, + ); + return context; + }); +} + function createRerollHarness() { return fs.readFile(indexPath, "utf8").then((source) => { const rollbackStart = source.indexOf( @@ -5436,6 +5479,120 @@ async function testHistoryRecoveryAbortClearsVectorRepairState() { assert.equal(harness.currentGraph.vectorIndexState.dirtyReason, ""); } +async function testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast() { + const harness = await createHistoryNotificationHarness(); + + harness.result.notifyHistoryDirty( + 12, + "已处理楼层超出当前聊天长度,检测到历史截断", + ); + + assert.equal(harness.notices.length, 1); + assert.equal(harness.warningToasts.length, 0); + assert.equal(harness.notices[0][0], "history"); + assert.equal(harness.notices[0][1], "检测到楼层历史变化"); +} + +async function testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 1, + processedMessageHashes: { 1: "hash-1" }, + historyDirtyFrom: 1, + lastMutationSource: "message-deleted", + lastMutationReason: "tail-truncated", + extractionCount: 1, + }, + vectorIndexState: { + collectionId: "col-1", + dirty: true, + dirtyReason: "history-recovery-replay", + pendingRepairFromFloor: 1, + replayRequiredNodeIds: ["node-1"], + lastWarning: "repair pending", + lastIntegrityIssue: null, + }, + batchJournal: [], + lastProcessedSeq: 1, + }; + harness.findJournalRecoveryPointImpl = () => ({ + path: "reverse-journal", + affectedBatchCount: 1, + affectedJournals: [ + { + processedRange: [1, 1], + vectorDelta: { + insertedHashes: [], + removedHashes: [], + backendDeleteHashes: [], + touchedNodeIds: [], + replayRequiredNodeIds: [], + replacedMappings: [], + }, + }, + ], + }); + harness.replayExtractionFromHistoryImpl = async () => { + harness.currentGraph.historyState.lastProcessedAssistantFloor = 1; + harness.currentGraph.lastProcessedSeq = 1; + return 1; + }; + + const result = await harness.result.recoverFromHistoryMutation("message-deleted"); + + assert.equal(result, true); + assert.equal(harness.toastCalls.success.length, 0); + assert.equal(harness.toastCalls.warning.length, 0); + assert.equal(harness.toastCalls.error.length, 0); +} + +async function testHistoryRecoveryFullRebuildStillWarnsUser() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 1, + processedMessageHashes: { 1: "hash-1" }, + historyDirtyFrom: 1, + lastMutationSource: "message-edited", + lastMutationReason: "edited", + extractionCount: 1, + }, + vectorIndexState: { + collectionId: "col-1", + dirty: true, + dirtyReason: "history-recovery-replay", + pendingRepairFromFloor: 1, + replayRequiredNodeIds: ["node-1"], + lastWarning: "repair pending", + lastIntegrityIssue: null, + }, + batchJournal: [], + lastProcessedSeq: 1, + }; + harness.findJournalRecoveryPointImpl = () => null; + harness.replayExtractionFromHistoryImpl = async () => { + harness.currentGraph.historyState.lastProcessedAssistantFloor = 1; + harness.currentGraph.lastProcessedSeq = 1; + return 1; + }; + + const result = await harness.result.recoverFromHistoryMutation("message-edited"); + + assert.equal(result, true); + assert.equal(harness.toastCalls.success.length, 0); + assert.equal(harness.toastCalls.warning.length, 1); + assert.match(String(harness.toastCalls.warning[0]?.[0] || ""), /全量重建/); +} + async function testHistoryRecoveryFallbackFullRebuildCarriesResultCode() { const harness = await createHistoryRecoveryHarness(); harness.chat = [ @@ -6475,6 +6632,9 @@ await testRecallCardUserTextRefreshesWithoutCardRecreate(); await testRecallCardDisplayModeToggleRestoresOriginalUserText(); await testRecallSubGraphAndDataLayerEntryPoints(); await testRerollUsesBatchBoundaryRollbackAndPersistsState(); +await testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast(); +await testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast(); +await testHistoryRecoveryFullRebuildStillWarnsUser(); await testHistoryRecoveryAbortClearsVectorRepairState(); await testHistoryRecoveryFallbackFullRebuildCarriesResultCode(); await testHistoryRecoverySuccessRestoresProcessedHashesAfterReplay();