fix(history): pause recovery for render-limited chat slices

This commit is contained in:
Youzini-afk
2026-04-25 17:23:05 +08:00
parent 4952620c5c
commit 3d077a54e8
3 changed files with 237 additions and 0 deletions

127
index.js
View File

@@ -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() { function getHideRuntimeAdapters() {
return { return {
$, $,
@@ -16900,6 +16996,17 @@ function inspectHistoryMutation(
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
const context = getContext(); const context = getContext();
const chat = context?.chat; 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 ( if (
Array.isArray(chat) && Array.isArray(chat) &&
currentGraph.historyState?.processedMessageHashesNeedRefresh === true currentGraph.historyState?.processedMessageHashesNeedRefresh === true
@@ -17479,6 +17586,26 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
const context = getContext(); const context = getContext();
const chat = context?.chat; const chat = context?.chat;
if (!Array.isArray(chat)) return true; 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 detection = inspectHistoryMutation(trigger);
const dirtyFrom = currentGraph?.historyState?.historyDirtyFrom; const dirtyFrom = currentGraph?.historyState?.historyDirtyFrom;

View File

@@ -33,6 +33,7 @@ let powerUser = { chat_truncation: 0 };
let reloadCount = 0; let reloadCount = 0;
let inputValue = ""; let inputValue = "";
let counterValue = ""; let counterValue = "";
let currentGraph = null;
const triggeredEvents = []; const triggeredEvents = [];
function getContext() { function getContext() {
@@ -83,10 +84,16 @@ function getState() {
}; };
} }
function setCurrentGraph(graph) {
currentGraph = graph;
}
export { export {
applyMessageRenderLimit, applyMessageRenderLimit,
getRenderLimitedHistoryRecoveryGuard,
getMessageRenderLimitSettings, getMessageRenderLimitSettings,
getState, getState,
setCurrentGraph,
}; };
`, `,
"utf8", "utf8",
@@ -133,6 +140,51 @@ try {
reloadCount: 1, reloadCount: 1,
triggeredEvents: ["change"], 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({ const skipped = module.applyMessageRenderLimit({
enabled: true, enabled: true,

View File

@@ -358,6 +358,7 @@ function createHistoryRecoveryHarness() {
prepareVectorStateCalls: [], prepareVectorStateCalls: [],
saveGraphToChatCalls: 0, saveGraphToChatCalls: 0,
refreshPanelCalls: 0, refreshPanelCalls: 0,
renderLimitBlockedCalls: [],
notices: [], notices: [],
toastCalls: { toastCalls: {
success: [], success: [],
@@ -471,6 +472,12 @@ function createHistoryRecoveryHarness() {
getSettings() { getSettings() {
return {}; return {};
}, },
getRenderLimitedHistoryRecoveryGuard() {
return context.renderLimitedGuard || { blocked: false };
},
notifyRenderLimitedHistoryRecoveryBlocked(guard, trigger) {
context.renderLimitBlockedCalls.push({ guard, trigger });
},
isBackendVectorConfig(config) { isBackendVectorConfig(config) {
return config?.mode === "backend"; return config?.mode === "backend";
}, },
@@ -6324,6 +6331,56 @@ async function testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast
assert.equal(harness.toastCalls.error.length, 0); 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() { async function testHistoryRecoveryFullRebuildStillWarnsUser() {
const harness = await createHistoryRecoveryHarness(); const harness = await createHistoryRecoveryHarness();
harness.chat = [ harness.chat = [
@@ -7525,6 +7582,7 @@ await testRecallSubGraphAndDataLayerEntryPoints();
await testRerollUsesBatchBoundaryRollbackAndPersistsState(); await testRerollUsesBatchBoundaryRollbackAndPersistsState();
await testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast(); await testNotifyHistoryDirtyUsesStageNoticeWithoutGenericWarningToast();
await testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast(); await testHistoryRecoveryStandardSuffixReplayDoesNotEmitCompletionToast();
await testHistoryRecoveryPausesWhenRenderLimitedChatSlice();
await testHistoryRecoveryFullRebuildStillWarnsUser(); await testHistoryRecoveryFullRebuildStillWarnsUser();
await testHistoryRecoveryAbortClearsVectorRepairState(); await testHistoryRecoveryAbortClearsVectorRepairState();
await testHistoryRecoveryFallbackFullRebuildCarriesResultCode(); await testHistoryRecoveryFallbackFullRebuildCarriesResultCode();