From 3d077a54e8feb6db295b02f3fbb35afbd53b6f42 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 25 Apr 2026 17:23:05 +0800 Subject: [PATCH] fix(history): pause recovery for render-limited chat slices --- index.js | 127 +++++++++++++++++++++++++++++++++ tests/message-render-limit.mjs | 52 ++++++++++++++ tests/p0-regressions.mjs | 58 +++++++++++++++ 3 files changed, 237 insertions(+) diff --git a/index.js b/index.js index 778ec51..8e80f10 100644 --- a/index.js +++ b/index.js @@ -5060,6 +5060,102 @@ function applyMessageRenderLimit(settings = null, options = {}) { }; } +function getActiveMessageRenderLimitForHistoryGuard(settings = null) { + const normalized = getMessageRenderLimitSettings(settings); + const configuredLimit = + normalized.enabled && normalized.render_last_n > 0 + ? normalized.render_last_n + : 0; + let hostLimit = 0; + try { + const powerUserSettings = getHostPowerUserSettings(); + hostLimit = Math.max( + 0, + Math.trunc(Number(powerUserSettings?.chat_truncation ?? 0) || 0), + ); + } catch { + hostLimit = 0; + } + + if (configuredLimit > 0 && hostLimit > 0) { + return Math.min(configuredLimit, hostLimit); + } + return Math.max(configuredLimit, hostLimit); +} + +function getHighestTrackedProcessedHistoryFloor(historyState = {}) { + const lastProcessed = Number.isFinite( + Number(historyState?.lastProcessedAssistantFloor), + ) + ? Math.floor(Number(historyState.lastProcessedAssistantFloor)) + : -1; + const hashes = + historyState?.processedMessageHashes && + typeof historyState.processedMessageHashes === "object" && + !Array.isArray(historyState.processedMessageHashes) + ? historyState.processedMessageHashes + : {}; + const maxHashFloor = Object.keys(hashes).reduce((maxFloor, key) => { + const floor = Number.parseInt(key, 10); + return Number.isFinite(floor) ? Math.max(maxFloor, floor) : maxFloor; + }, -1); + + return Math.max(lastProcessed, maxHashFloor); +} + +function getRenderLimitedHistoryRecoveryGuard( + chat, + { settings = null, historyState = currentGraph?.historyState } = {}, +) { + const renderLimit = getActiveMessageRenderLimitForHistoryGuard(settings); + if (!Array.isArray(chat) || renderLimit <= 0) { + return { blocked: false }; + } + + const chatLength = chat.length; + const highestProcessedFloor = + getHighestTrackedProcessedHistoryFloor(historyState); + const renderWindowTolerance = renderLimit + 1; + if ( + chatLength > renderWindowTolerance || + highestProcessedFloor < chatLength + ) { + return { blocked: false }; + } + + return { + blocked: true, + chatLength, + highestProcessedFloor, + renderLimit, + reason: "render-limited-chat-slice", + message: + `当前聊天区最多只渲染最近 ${renderLimit} 条消息,当前可见 ${chatLength} 条;` + + `图谱已处理到楼层 ${highestProcessedFloor}。为避免把截断视图误判为历史删除并清空运行时图谱,已暂停历史恢复。` + + "请临时关闭“限制聊天区渲染楼层”或调大渲染数量并刷新后再提取。", + }; +} + +function notifyRenderLimitedHistoryRecoveryBlocked(guard, trigger = "") { + if (!guard?.blocked) return; + console.warn?.("[ST-BME] 历史恢复因聊天渲染限制暂停:", { + trigger, + chatLength: guard.chatLength, + highestProcessedFloor: guard.highestProcessedFloor, + renderLimit: guard.renderLimit, + }); + updateStageNotice( + "history", + "历史恢复已暂停", + guard.message, + "warning", + { + busy: false, + persist: true, + }, + ); +} + function getHideRuntimeAdapters() { return { $, @@ -16900,6 +16996,17 @@ function inspectHistoryMutation( ensureCurrentGraphRuntimeState(); const context = getContext(); const chat = context?.chat; + const renderLimitedGuard = getRenderLimitedHistoryRecoveryGuard(chat); + if (renderLimitedGuard.blocked) { + notifyRenderLimitedHistoryRecoveryBlocked(renderLimitedGuard, trigger); + return { + dirty: false, + earliestAffectedFloor: null, + reason: renderLimitedGuard.reason, + source: "render-limit-guard", + skipped: true, + }; + } if ( Array.isArray(chat) && currentGraph.historyState?.processedMessageHashesNeedRefresh === true @@ -17479,6 +17586,26 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { const context = getContext(); const chat = context?.chat; if (!Array.isArray(chat)) return true; + const renderLimitedGuard = getRenderLimitedHistoryRecoveryGuard(chat); + if (renderLimitedGuard.blocked) { + currentGraph.historyState.lastRecoveryResult = buildRecoveryResult( + "paused", + { + fromFloor: currentGraph.historyState?.historyDirtyFrom ?? null, + path: "render-limit-guard", + detectionSource: + currentGraph.historyState?.lastMutationSource || "render-limit-guard", + reason: renderLimitedGuard.message, + resultCode: "history.recovery.paused.render-limit", + chatLength: renderLimitedGuard.chatLength, + renderLimit: renderLimitedGuard.renderLimit, + highestProcessedFloor: renderLimitedGuard.highestProcessedFloor, + }, + ); + notifyRenderLimitedHistoryRecoveryBlocked(renderLimitedGuard, trigger); + refreshPanelLiveState(); + return false; + } const detection = inspectHistoryMutation(trigger); const dirtyFrom = currentGraph?.historyState?.historyDirtyFrom; diff --git a/tests/message-render-limit.mjs b/tests/message-render-limit.mjs index cde5448..dc17dec 100644 --- a/tests/message-render-limit.mjs +++ b/tests/message-render-limit.mjs @@ -33,6 +33,7 @@ let powerUser = { chat_truncation: 0 }; let reloadCount = 0; let inputValue = ""; let counterValue = ""; +let currentGraph = null; const triggeredEvents = []; function getContext() { @@ -83,10 +84,16 @@ function getState() { }; } +function setCurrentGraph(graph) { + currentGraph = graph; +} + export { applyMessageRenderLimit, + getRenderLimitedHistoryRecoveryGuard, getMessageRenderLimitSettings, getState, + setCurrentGraph, }; `, "utf8", @@ -133,6 +140,51 @@ try { reloadCount: 1, triggeredEvents: ["change"], }); + const guarded = module.getRenderLimitedHistoryRecoveryGuard( + new Array(10).fill({ mes: "visible" }), + { + settings: { + enabled: true, + hideOldMessagesRenderLimitEnabled: true, + hideOldMessagesRenderLimit: 10, + }, + historyState: { + lastProcessedAssistantFloor: 30, + processedMessageHashes: { 0: "a", 30: "b" }, + }, + }, + ); + assert.equal(guarded.blocked, true); + assert.equal(guarded.renderLimit, 10); + assert.equal(guarded.highestProcessedFloor, 30); + + const notGuardedWhenFullerThanRenderWindow = + module.getRenderLimitedHistoryRecoveryGuard(new Array(20).fill({}), { + settings: { + enabled: true, + hideOldMessagesRenderLimitEnabled: true, + hideOldMessagesRenderLimit: 10, + }, + historyState: { + lastProcessedAssistantFloor: 30, + processedMessageHashes: { 30: "b" }, + }, + }); + assert.equal(notGuardedWhenFullerThanRenderWindow.blocked, false); + + const notGuardedWhenHistoryFitsVisibleChat = + module.getRenderLimitedHistoryRecoveryGuard(new Array(10).fill({}), { + settings: { + enabled: true, + hideOldMessagesRenderLimitEnabled: true, + hideOldMessagesRenderLimit: 10, + }, + historyState: { + lastProcessedAssistantFloor: 5, + processedMessageHashes: { 5: "b" }, + }, + }); + assert.equal(notGuardedWhenHistoryFitsVisibleChat.blocked, false); const skipped = module.applyMessageRenderLimit({ enabled: true, diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index ab04abd..8ef39c7 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -358,6 +358,7 @@ function createHistoryRecoveryHarness() { prepareVectorStateCalls: [], saveGraphToChatCalls: 0, refreshPanelCalls: 0, + renderLimitBlockedCalls: [], notices: [], toastCalls: { success: [], @@ -471,6 +472,12 @@ function createHistoryRecoveryHarness() { getSettings() { return {}; }, + getRenderLimitedHistoryRecoveryGuard() { + return context.renderLimitedGuard || { blocked: false }; + }, + notifyRenderLimitedHistoryRecoveryBlocked(guard, trigger) { + context.renderLimitBlockedCalls.push({ guard, trigger }); + }, isBackendVectorConfig(config) { return config?.mode === "backend"; }, @@ -6324,6 +6331,56 @@ async function testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast assert.equal(harness.toastCalls.error.length, 0); } +async function testHistoryRecoveryPausesWhenRenderLimitedChatSlice() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = new Array(10).fill(null).map((_, index) => ({ + is_user: index % 2 === 0, + mes: `visible-${index}`, + })); + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 30, + processedMessageHashes: { 30: "hash-30" }, + historyDirtyFrom: 10, + lastMutationSource: "hash-recheck", + lastMutationReason: "已处理楼层超出当前聊天长度,检测到历史截断", + extractionCount: 8, + }, + vectorIndexState: { + collectionId: "col-1", + dirty: false, + dirtyReason: "", + pendingRepairFromFloor: null, + replayRequiredNodeIds: [], + lastWarning: "", + lastIntegrityIssue: null, + }, + batchJournal: [], + lastProcessedSeq: 30, + }; + harness.renderLimitedGuard = { + blocked: true, + chatLength: 10, + renderLimit: 10, + highestProcessedFloor: 30, + reason: "render-limited-chat-slice", + message: "render limited", + }; + + const result = await harness.result.recoverFromHistoryMutation("manual-extract"); + + assert.equal(result, false); + assert.equal(harness.prepareVectorStateCalls.length, 0); + assert.equal(harness.saveGraphToChatCalls, 0); + assert.equal(harness.refreshPanelCalls, 1); + assert.equal(harness.renderLimitBlockedCalls.length, 1); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult?.resultCode, + "history.recovery.paused.render-limit", + ); + assert.equal(harness.currentGraph.historyState.lastProcessedAssistantFloor, 30); +} + async function testHistoryRecoveryFullRebuildStillWarnsUser() { const harness = await createHistoryRecoveryHarness(); harness.chat = [ @@ -7525,6 +7582,7 @@ await testRecallSubGraphAndDataLayerEntryPoints(); await testRerollUsesBatchBoundaryRollbackAndPersistsState(); await testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast(); await testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast(); +await testHistoryRecoveryPausesWhenRenderLimitedChatSlice(); await testHistoryRecoveryFullRebuildStillWarnsUser(); await testHistoryRecoveryAbortClearsVectorRepairState(); await testHistoryRecoveryFallbackFullRebuildCarriesResultCode();