fix: 重构生成前注入与历史回滚链路

This commit is contained in:
Youzini-afk
2026-03-25 02:45:43 +08:00
parent 58c43d638b
commit 7bfe37e964
6 changed files with 706 additions and 132 deletions

View File

@@ -11,7 +11,7 @@ import {
/** /**
* 图状态版本号 * 图状态版本号
*/ */
const GRAPH_VERSION = 4; const GRAPH_VERSION = 5;
/** /**
* 生成 UUID v4 * 生成 UUID v4
@@ -510,6 +510,20 @@ export function deserializeGraph(json) {
: createDefaultBatchJournal(); : createDefaultBatchJournal();
} }
if (data.version < 5) {
data.historyState = {
...createDefaultHistoryState(),
...(data.historyState || {}),
extractionCount: Number.isFinite(data?.historyState?.extractionCount)
? data.historyState.extractionCount
: 0,
lastMutationSource: String(data?.historyState?.lastMutationSource || ""),
};
data.batchJournal = Array.isArray(data.batchJournal)
? data.batchJournal
: createDefaultBatchJournal();
}
data.version = GRAPH_VERSION; data.version = GRAPH_VERSION;
} }
@@ -550,6 +564,10 @@ export function deserializeGraph(json) {
) )
? data.historyState.lastProcessedAssistantFloor ? data.historyState.lastProcessedAssistantFloor
: data.lastProcessedSeq, : data.lastProcessedSeq,
extractionCount: Number.isFinite(data?.historyState?.extractionCount)
? data.historyState.extractionCount
: 0,
lastMutationSource: String(data?.historyState?.lastMutationSource || ""),
}; };
data.vectorIndexState = { data.vectorIndexState = {
...createDefaultVectorIndexState(data?.historyState?.chatId || ""), ...createDefaultVectorIndexState(data?.historyState?.chatId || ""),

555
index.js
View File

@@ -44,11 +44,13 @@ import {
findJournalRecoveryPoint, findJournalRecoveryPoint,
markHistoryDirty, markHistoryDirty,
normalizeGraphRuntimeState, normalizeGraphRuntimeState,
rollbackBatch,
snapshotProcessedMessageHashes, snapshotProcessedMessageHashes,
} from "./runtime-state.js"; } from "./runtime-state.js";
import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js"; import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js";
import { import {
BACKEND_VECTOR_SOURCES, BACKEND_VECTOR_SOURCES,
deleteBackendVectorHashesForRecovery,
getVectorConfigFromSettings, getVectorConfigFromSettings,
getVectorIndexStats, getVectorIndexStats,
isBackendVectorConfig, isBackendVectorConfig,
@@ -192,6 +194,9 @@ let sendIntentHookRetryTimer = null;
let pendingHistoryRecoveryTimer = null; let pendingHistoryRecoveryTimer = null;
let pendingHistoryRecoveryTrigger = ""; let pendingHistoryRecoveryTrigger = "";
let pendingHistoryMutationCheckTimers = []; let pendingHistoryMutationCheckTimers = [];
let skipBeforeCombineRecallUntil = 0;
let lastPreGenerationRecallKey = "";
let lastPreGenerationRecallAt = 0;
const stageNoticeHandles = { const stageNoticeHandles = {
extraction: null, extraction: null,
vector: null, vector: null,
@@ -559,6 +564,17 @@ function registerBeforeCombinePrompts(listener) {
return null; return null;
} }
function registerGenerationAfterCommands(listener) {
const makeFirst = globalThis.eventMakeFirst;
if (typeof makeFirst === "function") {
return makeFirst(event_types.GENERATION_AFTER_COMMANDS, listener);
}
console.warn("[ST-BME] eventMakeFirst 不可用GENERATION_AFTER_COMMANDS 回退到普通事件注册");
eventSource.on(event_types.GENERATION_AFTER_COMMANDS, listener);
return null;
}
function installSendIntentHooks() { function installSendIntentHooks() {
for (const cleanup of sendIntentHookCleanup.splice(0, sendIntentHookCleanup.length)) { for (const cleanup of sendIntentHookCleanup.splice(0, sendIntentHookCleanup.length)) {
try { try {
@@ -839,6 +855,7 @@ async function recordGraphMutation({
artifactTags = [], artifactTags = [],
syncRange = null, syncRange = null,
signal = undefined, signal = undefined,
extractionCountBefore = extractionCount,
} = {}) { } = {}) {
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
const vectorSync = await syncVectorState({ const vectorSync = await syncVectorState({
@@ -865,6 +882,7 @@ async function recordGraphMutation({
artifactTags, artifactTags,
), ),
vectorHashesInserted: vectorSync?.insertedHashes || [], vectorHashesInserted: vectorSync?.insertedHashes || [],
extractionCountBefore,
}), }),
); );
saveGraphToChat(); saveGraphToChat();
@@ -1195,7 +1213,9 @@ function loadGraphFromChat() {
currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId);
} }
extractionCount = 0; extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
? currentGraph.historyState.extractionCount
: 0;
lastExtractedItems = []; lastExtractedItems = [];
updateLastRecalledItems(currentGraph.lastRecallResult || []); updateLastRecalledItems(currentGraph.lastRecallResult || []);
lastInjectionContent = ""; lastInjectionContent = "";
@@ -1218,6 +1238,7 @@ function saveGraphToChat() {
} }
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
currentGraph.historyState.extractionCount = extractionCount;
context.chatMetadata[GRAPH_METADATA_KEY] = currentGraph; context.chatMetadata[GRAPH_METADATA_KEY] = currentGraph;
saveMetadataDebounced(); saveMetadataDebounced();
return true; return true;
@@ -1463,6 +1484,56 @@ function resolveRecallInput(chat, recentContextMessageLimit, override = null) {
}; };
} }
function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) {
if (params?.automatic_trigger || params?.quiet_prompt) {
return null;
}
const generationType = String(type || "").trim() || "normal";
if (!["normal", "continue", "regenerate", "swipe"].includes(generationType)) {
return null;
}
if (generationType === "normal") {
const lastNonSystemMessage = getLastNonSystemChatMessage(chat);
const tailUserText = lastNonSystemMessage?.is_user
? normalizeRecallInputText(lastNonSystemMessage?.mes || "")
: "";
const textareaText = normalizeRecallInputText(
pendingRecallSendIntent.text || getSendTextareaValue(),
);
const userMessage = tailUserText || textareaText;
if (!userMessage) return null;
return {
overrideUserMessage: userMessage,
overrideSource: tailUserText ? "chat-tail-user" : "send-intent",
overrideSourceLabel: tailUserText ? "当前用户楼层" : "发送意图",
includeSyntheticUserMessage: !tailUserText,
};
}
const latestUserText = normalizeRecallInputText(
getLatestUserChatMessage(chat)?.mes || lastRecallSentUserMessage.text,
);
if (!latestUserText) return null;
return {
overrideUserMessage: latestUserText,
overrideSource: "chat-last-user",
overrideSourceLabel: "历史最后用户楼层",
includeSyntheticUserMessage: false,
};
}
function buildPreGenerationRecallKey(type, options = {}) {
return [
getCurrentChatId(),
String(type || "normal").trim() || "normal",
hashRecallInput(options.overrideUserMessage || ""),
].join(":");
}
function getCurrentChatSeq(context = getContext()) { function getCurrentChatSeq(context = getContext()) {
const chat = context?.chat; const chat = context?.chat;
if (Array.isArray(chat) && chat.length > 0) { if (Array.isArray(chat) && chat.length > 0) {
@@ -1476,6 +1547,8 @@ async function handleExtractionSuccess(result, endIdx, settings, signal = undefi
const warnings = []; const warnings = [];
throwIfAborted(signal, "提取已终止"); throwIfAborted(signal, "提取已终止");
extractionCount++; extractionCount++;
ensureCurrentGraphRuntimeState();
currentGraph.historyState.extractionCount = extractionCount;
updateLastExtractedItems(result.newNodeIds || []); updateLastExtractedItems(result.newNodeIds || []);
if (settings.enableEvolution && result.newNodeIds?.length > 0) { if (settings.enableEvolution && result.newNodeIds?.length > 0) {
@@ -1606,6 +1679,96 @@ function buildExtractionMessages(chat, startIdx, endIdx, settings) {
return messages; return messages;
} }
function getChatIndexForPlayableSeq(chat, playableSeq) {
if (!Array.isArray(chat) || !Number.isFinite(playableSeq)) return null;
let currentSeq = -1;
for (let index = 0; index < chat.length; index++) {
const message = chat[index];
if (message?.is_system) continue;
currentSeq++;
if (currentSeq >= playableSeq) {
return index;
}
}
return chat.length;
}
function getChatIndexForAssistantSeq(chat, assistantSeq) {
if (!Array.isArray(chat) || !Number.isFinite(assistantSeq)) return null;
let currentSeq = -1;
for (let index = 0; index < chat.length; index++) {
if (!isAssistantChatMessage(chat[index])) continue;
currentSeq++;
if (currentSeq >= assistantSeq) {
return index;
}
}
return chat.length;
}
function resolveDirtyFloorFromMutationMeta(trigger, primaryArg, meta, chat) {
if (!meta || typeof meta !== "object") return null;
const candidates = [];
if (Number.isFinite(meta.messageId)) {
candidates.push({
floor: meta.messageId,
source: `${trigger}-meta`,
});
}
if (Number.isFinite(meta.deletedPlayableSeqFrom)) {
const floor = getChatIndexForPlayableSeq(chat, meta.deletedPlayableSeqFrom);
if (Number.isFinite(floor)) {
candidates.push({
floor,
source: `${trigger}-meta`,
});
}
}
if (Number.isFinite(meta.deletedAssistantSeqFrom)) {
const floor = getChatIndexForAssistantSeq(chat, meta.deletedAssistantSeqFrom);
if (Number.isFinite(floor)) {
candidates.push({
floor,
source: `${trigger}-meta`,
});
}
}
if (Number.isFinite(meta.playableSeq)) {
const floor = getChatIndexForPlayableSeq(chat, meta.playableSeq);
if (Number.isFinite(floor)) {
candidates.push({
floor,
source: `${trigger}-meta`,
});
}
}
if (Number.isFinite(meta.assistantSeq)) {
const floor = getChatIndexForAssistantSeq(chat, meta.assistantSeq);
if (Number.isFinite(floor)) {
candidates.push({
floor,
source: `${trigger}-meta`,
});
}
}
if (trigger !== "message-deleted" && Number.isFinite(primaryArg)) {
candidates.push({
floor: primaryArg,
source: `${trigger}-meta`,
});
}
if (candidates.length === 0) return null;
return candidates.reduce((earliest, current) =>
current.floor < earliest.floor ? current : earliest,
);
}
function getLastProcessedAssistantFloor() { function getLastProcessedAssistantFloor() {
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
return Number.isFinite(currentGraph?.historyState?.lastProcessedAssistantFloor) return Number.isFinite(currentGraph?.historyState?.lastProcessedAssistantFloor)
@@ -1675,7 +1838,7 @@ function scheduleImmediateHistoryRecovery(
}, delayMs); }, delayMs);
} }
function scheduleHistoryMutationRecheck(trigger = "history-change") { function scheduleHistoryMutationRecheck(trigger = "history-change", primaryArg = null, meta = null) {
if (!getSettings().enabled) return; if (!getSettings().enabled) return;
clearPendingHistoryMutationChecks(); clearPendingHistoryMutationChecks();
@@ -1701,7 +1864,7 @@ function scheduleHistoryMutationRecheck(trigger = "history-change") {
); );
if (!getSettings().enabled) return; if (!getSettings().enabled) return;
const detection = inspectHistoryMutation(`settled:${trigger}`); const detection = inspectHistoryMutation(`settled:${trigger}`, primaryArg, meta);
if ( if (
detection.dirty || detection.dirty ||
Number.isFinite(currentGraph?.historyState?.historyDirtyFrom) Number.isFinite(currentGraph?.historyState?.historyDirtyFrom)
@@ -1718,12 +1881,39 @@ function scheduleHistoryMutationRecheck(trigger = "history-change") {
} }
} }
function inspectHistoryMutation(trigger = "history-change") { function inspectHistoryMutation(trigger = "history-change", primaryArg = null, meta = null) {
if (!currentGraph) return { dirty: false, earliestAffectedFloor: null, reason: "" }; if (!currentGraph) return { dirty: false, earliestAffectedFloor: null, reason: "" };
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
const context = getContext(); const context = getContext();
const chat = context?.chat; const chat = context?.chat;
const metaDetection = resolveDirtyFloorFromMutationMeta(
trigger,
primaryArg,
meta,
chat,
);
if (
metaDetection &&
Number.isFinite(metaDetection.floor) &&
metaDetection.floor <= getLastProcessedAssistantFloor()
) {
clearInjectionState();
markHistoryDirty(
currentGraph,
metaDetection.floor,
`${trigger} 元数据检测到楼层变动`,
metaDetection.source,
);
saveGraphToChat();
notifyHistoryDirty(metaDetection.floor, `${trigger} 元数据检测到楼层变动`);
return {
dirty: true,
earliestAffectedFloor: metaDetection.floor,
reason: `${trigger} 元数据检测到楼层变动`,
source: metaDetection.source,
};
}
const detection = detectHistoryMutation(chat, currentGraph.historyState); const detection = detectHistoryMutation(chat, currentGraph.historyState);
if (detection.dirty) { if (detection.dirty) {
@@ -1732,10 +1922,14 @@ function inspectHistoryMutation(trigger = "history-change") {
currentGraph, currentGraph,
detection.earliestAffectedFloor, detection.earliestAffectedFloor,
detection.reason || trigger, detection.reason || trigger,
"hash-recheck",
); );
saveGraphToChat(); saveGraphToChat();
notifyHistoryDirty(detection.earliestAffectedFloor, detection.reason); notifyHistoryDirty(detection.earliestAffectedFloor, detection.reason);
return detection; return {
...detection,
source: "hash-recheck",
};
} }
if (trigger === "message-edited" || trigger === "message-swiped") { if (trigger === "message-edited" || trigger === "message-swiped") {
@@ -1763,23 +1957,31 @@ async function purgeCurrentVectorCollection(signal = undefined) {
} }
} }
async function prepareVectorStateForReplay(fullReset = false, signal = undefined) { async function prepareVectorStateForReplay(
fullReset = false,
signal = undefined,
{ skipBackendPurge = false } = {},
) {
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
const config = getEmbeddingConfig(); const config = getEmbeddingConfig();
if (isBackendVectorConfig(config)) { if (isBackendVectorConfig(config)) {
try { if (!skipBackendPurge) {
await purgeCurrentVectorCollection(signal); try {
} catch (error) { await purgeCurrentVectorCollection(signal);
if (isAbortError(error)) { } catch (error) {
throw error; if (isAbortError(error)) {
throw error;
}
console.warn("[ST-BME] 清理后端向量索引失败,继续本地恢复:", error);
} }
console.warn("[ST-BME] 清理后端向量索引失败,继续本地恢复:", error); currentGraph.vectorIndexState.hashToNodeId = {};
currentGraph.vectorIndexState.nodeToHash = {};
} }
currentGraph.vectorIndexState.hashToNodeId = {};
currentGraph.vectorIndexState.nodeToHash = {};
currentGraph.vectorIndexState.dirty = true; currentGraph.vectorIndexState.dirty = true;
currentGraph.vectorIndexState.lastWarning = "历史恢复后需要重建后端向量索引"; currentGraph.vectorIndexState.lastWarning = skipBackendPurge
? "历史恢复后需要修复受影响后缀的后端向量索引"
: "历史恢复后需要重建后端向量索引";
return; return;
} }
@@ -1802,6 +2004,7 @@ async function executeExtractionBatch({
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
throwIfAborted(signal, "提取已终止"); throwIfAborted(signal, "提取已终止");
const lastProcessed = getLastProcessedAssistantFloor(); const lastProcessed = getLastProcessedAssistantFloor();
const extractionCountBefore = extractionCount;
const beforeSnapshot = cloneGraphSnapshot(currentGraph); const beforeSnapshot = cloneGraphSnapshot(currentGraph);
const messages = buildExtractionMessages(chat, startIdx, endIdx, settings); const messages = buildExtractionMessages(chat, startIdx, endIdx, settings);
@@ -1852,6 +2055,7 @@ async function executeExtractionBatch({
processedRange: [startIdx, endIdx], processedRange: [startIdx, endIdx],
postProcessArtifacts, postProcessArtifacts,
vectorHashesInserted: effects?.vectorHashesInserted || [], vectorHashesInserted: effects?.vectorHashesInserted || [],
extractionCountBefore,
}), }),
); );
saveGraphToChat(); saveGraphToChat();
@@ -1901,6 +2105,29 @@ async function replayExtractionFromHistory(chat, settings, signal = undefined) {
return replayedBatches; return replayedBatches;
} }
function collectAffectedInsertedHashes(affectedJournals = []) {
const hashes = new Set();
for (const journal of affectedJournals) {
const insertedHashes =
journal?.vectorDelta?.insertedHashes ||
journal?.vectorHashesInserted ||
[];
for (const hash of insertedHashes) {
if (hash) hashes.add(hash);
}
}
return [...hashes];
}
function rollbackAffectedJournals(graph, affectedJournals = []) {
for (let index = affectedJournals.length - 1; index >= 0; index--) {
rollbackBatch(graph, affectedJournals[index]);
}
graph.batchJournal = Array.isArray(graph.batchJournal)
? graph.batchJournal.slice(0, Math.max(0, graph.batchJournal.length - affectedJournals.length))
: [];
}
async function recoverHistoryIfNeeded(trigger = "history-recovery") { async function recoverHistoryIfNeeded(trigger = "history-recovery") {
if (!currentGraph || isRecoveringHistory) { if (!currentGraph || isRecoveringHistory) {
return !isRecoveringHistory; return !isRecoveringHistory;
@@ -1927,6 +2154,8 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
: detection.earliestAffectedFloor; : detection.earliestAffectedFloor;
let replayedBatches = 0; let replayedBatches = 0;
let usedFullRebuild = false; let usedFullRebuild = false;
let recoveryPath = "full-rebuild";
let affectedBatchCount = 0;
const historyController = beginStageAbortController("history"); const historyController = beginStageAbortController("history");
const historySignal = historyController.signal; const historySignal = historyController.signal;
@@ -1946,33 +2175,71 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
try { try {
throwIfAborted(historySignal, "历史恢复已终止"); throwIfAborted(historySignal, "历史恢复已终止");
const recoveryPoint = findJournalRecoveryPoint(currentGraph, initialDirtyFrom); const recoveryPoint = findJournalRecoveryPoint(currentGraph, initialDirtyFrom);
if (recoveryPoint) { if (recoveryPoint?.path === "reverse-journal") {
recoveryPath = "reverse-journal";
affectedBatchCount = recoveryPoint.affectedBatchCount || 0;
const config = getEmbeddingConfig();
const insertedHashes = collectAffectedInsertedHashes(
recoveryPoint.affectedJournals,
);
rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals);
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
extractionCount = currentGraph.historyState.extractionCount || 0;
if (isBackendVectorConfig(config) && insertedHashes.length > 0) {
await deleteBackendVectorHashesForRecovery(
currentGraph.vectorIndexState.collectionId,
config,
insertedHashes,
historySignal,
);
}
await prepareVectorStateForReplay(false, historySignal, {
skipBackendPurge: isBackendVectorConfig(config),
});
} else if (recoveryPoint?.path === "legacy-snapshot") {
recoveryPath = "legacy-snapshot";
affectedBatchCount = recoveryPoint.affectedBatchCount || 0;
currentGraph = normalizeGraphRuntimeState( currentGraph = normalizeGraphRuntimeState(
recoveryPoint.snapshotBefore, recoveryPoint.snapshotBefore,
chatId, chatId,
); );
extractionCount = currentGraph.historyState.extractionCount || 0;
await prepareVectorStateForReplay(false, historySignal);
} else { } else {
recoveryPath = "full-rebuild";
currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId);
usedFullRebuild = true; usedFullRebuild = true;
extractionCount = 0;
await prepareVectorStateForReplay(true, historySignal);
} }
await prepareVectorStateForReplay(usedFullRebuild, historySignal);
replayedBatches = await replayExtractionFromHistory(chat, settings, historySignal); replayedBatches = await replayExtractionFromHistory(chat, settings, historySignal);
clearHistoryDirty( clearHistoryDirty(
currentGraph, currentGraph,
buildRecoveryResult(usedFullRebuild ? "full-rebuild" : "replayed", { buildRecoveryResult(
usedFullRebuild ? "full-rebuild" : "replayed",
{
fromFloor: initialDirtyFrom, fromFloor: initialDirtyFrom,
batches: replayedBatches, batches: replayedBatches,
path: recoveryPath,
detectionSource:
detection.source ||
currentGraph?.historyState?.lastMutationSource ||
"hash-recheck",
affectedBatchCount,
replayedBatchCount: replayedBatches,
reason: detection.reason || currentGraph?.historyState?.lastMutationReason || trigger, reason: detection.reason || currentGraph?.historyState?.lastMutationReason || trigger,
}), },
),
); );
saveGraphToChat(); saveGraphToChat();
refreshPanelLiveState(); refreshPanelLiveState();
updateStageNotice( updateStageNotice(
"history", "history",
usedFullRebuild ? "历史恢复完成(全量重建)" : "历史恢复完成", usedFullRebuild ? "历史恢复完成(全量重建)" : "历史恢复完成",
`起点楼层 ${initialDirtyFrom} · 回放 ${replayedBatches}`, `path ${recoveryPath} · 起点楼层 ${initialDirtyFrom} · 受影响 ${affectedBatchCount} 批 · 回放 ${replayedBatches}`,
usedFullRebuild ? "warning" : "success", usedFullRebuild ? "warning" : "success",
{ {
busy: false, busy: false,
@@ -2005,6 +2272,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
try { try {
currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId);
extractionCount = 0;
await prepareVectorStateForReplay(true, historySignal); await prepareVectorStateForReplay(true, historySignal);
replayedBatches = await replayExtractionFromHistory(chat, settings, historySignal); replayedBatches = await replayExtractionFromHistory(chat, settings, historySignal);
clearHistoryDirty( clearHistoryDirty(
@@ -2012,6 +2280,13 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
buildRecoveryResult("full-rebuild", { buildRecoveryResult("full-rebuild", {
fromFloor: 0, fromFloor: 0,
batches: replayedBatches, batches: replayedBatches,
path: "full-rebuild",
detectionSource:
detection.source ||
currentGraph?.historyState?.lastMutationSource ||
"hash-recheck",
affectedBatchCount,
replayedBatchCount: replayedBatches,
reason: `恢复失败后兜底全量重建: ${error?.message || error}`, reason: `恢复失败后兜底全量重建: ${error?.message || error}`,
}), }),
); );
@@ -2020,7 +2295,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
updateStageNotice( updateStageNotice(
"history", "history",
"历史恢复已退化为全量重建", "历史恢复已退化为全量重建",
`起点楼层 ${initialDirtyFrom} · 回放 ${replayedBatches}`, `path full-rebuild · 起点楼层 ${initialDirtyFrom} · 回放 ${replayedBatches}`,
"warning", "warning",
{ {
busy: false, busy: false,
@@ -2032,6 +2307,13 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
} catch (fallbackError) { } catch (fallbackError) {
currentGraph.historyState.lastRecoveryResult = buildRecoveryResult("failed", { currentGraph.historyState.lastRecoveryResult = buildRecoveryResult("failed", {
fromFloor: initialDirtyFrom, fromFloor: initialDirtyFrom,
path: recoveryPath,
detectionSource:
detection.source ||
currentGraph?.historyState?.lastMutationSource ||
"hash-recheck",
affectedBatchCount,
replayedBatchCount: replayedBatches,
reason: String(fallbackError), reason: String(fallbackError),
}); });
saveGraphToChat(); saveGraphToChat();
@@ -2145,23 +2427,115 @@ async function runExtraction() {
} }
} }
function getRecallHookLabel(hookName = "") {
switch (hookName) {
case "GENERATION_AFTER_COMMANDS":
return "hook GENERATION_AFTER_COMMANDS";
case "GENERATE_BEFORE_COMBINE_PROMPTS":
return "hook GENERATE_BEFORE_COMBINE_PROMPTS";
default:
return "";
}
}
function applyRecallInjection(context, settings, recallInput, recentMessages, result) {
const injectionText = formatInjection(result, getSchema()).trim();
lastInjectionContent = injectionText;
const retrievalMeta = result?.meta?.retrieval || {};
const llmMeta = retrievalMeta.llm || {
status: settings.recallEnableLLM ? "unknown" : "disabled",
reason: settings.recallEnableLLM ? "未提供 LLM 状态" : "LLM 精排已关闭",
candidatePool: 0,
};
if (injectionText) {
const tokens = estimateTokens(injectionText);
console.log(
`[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`,
);
}
context.setExtensionPrompt(
MODULE_NAME,
injectionText,
extension_prompt_types.IN_CHAT,
clampInt(settings.injectDepth, 9999, 0, 9999),
);
currentGraph.lastRecallResult = result.selectedNodeIds;
updateLastRecalledItems(result.selectedNodeIds || []);
saveGraphToChat();
const llmLabel =
llmMeta.status === "llm"
? "LLM 精排完成"
: llmMeta.status === "fallback"
? "LLM 回退评分"
: llmMeta.status === "disabled"
? "仅评分排序"
: "召回完成";
const hookLabel = getRecallHookLabel(recallInput.hookName);
setLastRecallStatus(
llmLabel,
[
hookLabel,
recallInput.sourceLabel,
`ctx ${recentMessages.length}`,
`vector ${retrievalMeta.vectorHits ?? 0}`,
`diffusion ${retrievalMeta.diffusionHits ?? 0}`,
`llm pool ${llmMeta.candidatePool ?? 0}`,
`recall ${result.stats.recallCount}`,
].filter(Boolean).join(" · "),
llmMeta.status === "fallback" ? "warning" : "success",
{
syncRuntime: true,
toastKind: "",
},
);
if (llmMeta.status === "fallback") {
const now = Date.now();
if (now - lastRecallFallbackNoticeAt > 15000) {
lastRecallFallbackNoticeAt = now;
toastr.warning(
llmMeta.reason || "LLM 精排未返回有效结果,已回退到评分排序",
"ST-BME 召回提示",
{ timeOut: 4500 },
);
}
}
return { injectionText, retrievalMeta, llmMeta };
}
/** /**
* 召回管线:检索并注入记忆 * 召回管线:检索并注入记忆
*/ */
async function runRecall(options = {}) { async function runRecall(options = {}) {
if (isRecalling || !currentGraph) return; if (isRecalling || !currentGraph) return false;
const settings = getSettings(); const settings = getSettings();
if (!settings.enabled || !settings.recallEnabled) return; if (!settings.enabled || !settings.recallEnabled) return false;
if (!(await recoverHistoryIfNeeded("pre-recall"))) return; if (!(await recoverHistoryIfNeeded("pre-recall"))) return false;
const context = getContext(); const context = getContext();
const chat = context.chat; const chat = context.chat;
if (!chat || chat.length === 0) return; if (!chat || chat.length === 0) return false;
isRecalling = true; isRecalling = true;
const recallController = beginStageAbortController("recall"); const recallController = beginStageAbortController("recall");
const recallSignal = recallController.signal; const recallSignal = recallController.signal;
if (options.signal) {
if (options.signal.aborted) {
recallController.abort(options.signal.reason || createAbortError("宿主已终止生成"));
} else {
options.signal.addEventListener(
"abort",
() => recallController.abort(options.signal.reason || createAbortError("宿主已终止生成")),
{ once: true },
);
}
}
try { try {
await ensureVectorReadyIfNeeded("pre-recall", recallSignal); await ensureVectorReadyIfNeeded("pre-recall", recallSignal);
@@ -2175,17 +2549,25 @@ async function runRecall(options = {}) {
const userMessage = recallInput.userMessage; const userMessage = recallInput.userMessage;
const recentMessages = recallInput.recentMessages; const recentMessages = recallInput.recentMessages;
if (!userMessage) return; if (!userMessage) return false;
recallInput.hookName = options.hookName || "";
console.log("[ST-BME] 开始召回", { console.log("[ST-BME] 开始召回", {
source: recallInput.source, source: recallInput.source,
sourceLabel: recallInput.sourceLabel, sourceLabel: recallInput.sourceLabel,
hookName: recallInput.hookName,
userMessageLength: userMessage.length, userMessageLength: userMessage.length,
recentMessages: recentMessages.length, recentMessages: recentMessages.length,
}); });
setLastRecallStatus( setLastRecallStatus(
"召回中", "召回中",
`来源 ${recallInput.sourceLabel} · 上下文 ${recentMessages.length} 条 · 当前用户消息长度 ${userMessage.length}`, [
getRecallHookLabel(recallInput.hookName),
`来源 ${recallInput.sourceLabel}`,
`上下文 ${recentMessages.length}`,
`当前用户消息长度 ${userMessage.length}`,
].filter(Boolean).join(" · "),
"running", "running",
{ syncRuntime: true }, { syncRuntime: true },
); );
@@ -2223,71 +2605,14 @@ async function runRecall(options = {}) {
}, },
}); });
// 格式化注入文本 applyRecallInjection(context, settings, recallInput, recentMessages, result);
const injectionText = formatInjection(result, getSchema()).trim(); return true;
lastInjectionContent = injectionText;
const retrievalMeta = result?.meta?.retrieval || {};
const llmMeta = retrievalMeta.llm || {
status: settings.recallEnableLLM ? "unknown" : "disabled",
reason: settings.recallEnableLLM ? "未提供 LLM 状态" : "LLM 精排已关闭",
candidatePool: 0,
};
if (injectionText) {
const tokens = estimateTokens(injectionText);
console.log(
`[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`,
);
}
// 无结果时也要清空旧注入,避免脏 prompt 残留
context.setExtensionPrompt(
MODULE_NAME,
injectionText,
extension_prompt_types.IN_CHAT, // 当前注入走 IN_CHAT@Depth
clampInt(settings.injectDepth, 9999, 0, 9999),
);
// 保存召回结果和访问强化
currentGraph.lastRecallResult = result.selectedNodeIds;
updateLastRecalledItems(result.selectedNodeIds || []);
saveGraphToChat();
const llmLabel =
llmMeta.status === "llm"
? "LLM 精排完成"
: llmMeta.status === "fallback"
? "LLM 回退评分"
: llmMeta.status === "disabled"
? "仅评分排序"
: "召回完成";
setLastRecallStatus(
llmLabel,
`${recallInput.sourceLabel} · ctx ${recentMessages.length} · vector ${retrievalMeta.vectorHits ?? 0} · diffusion ${retrievalMeta.diffusionHits ?? 0} · llm pool ${llmMeta.candidatePool ?? 0} · recall ${result.stats.recallCount}`,
llmMeta.status === "fallback" ? "warning" : "success",
{
syncRuntime: true,
toastKind: "",
},
);
if (llmMeta.status === "fallback") {
const now = Date.now();
if (now - lastRecallFallbackNoticeAt > 15000) {
lastRecallFallbackNoticeAt = now;
toastr.warning(
llmMeta.reason || "LLM 精排未返回有效结果,已回退到评分排序",
"ST-BME 召回提示",
{ timeOut: 4500 },
);
}
}
} catch (e) { } catch (e) {
if (isAbortError(e)) { if (isAbortError(e)) {
setLastRecallStatus("召回已终止", e?.message || "已手动终止当前召回", "warning", { setLastRecallStatus("召回已终止", e?.message || "已手动终止当前召回", "warning", {
syncRuntime: true, syncRuntime: true,
}); });
return; return false;
} }
console.error("[ST-BME] 召回失败:", e); console.error("[ST-BME] 召回失败:", e);
const message = e?.message || String(e); const message = e?.message || String(e);
@@ -2296,6 +2621,7 @@ async function runRecall(options = {}) {
toastKind: "", toastKind: "",
}); });
toastr.error(`召回失败: ${message}`); toastr.error(`召回失败: ${message}`);
return false;
} finally { } finally {
finishStageAbortController("recall", recallController); finishStageAbortController("recall", recallController);
isRecalling = false; isRecalling = false;
@@ -2310,6 +2636,9 @@ function onChatChanged() {
clearTimeout(pendingHistoryRecoveryTimer); clearTimeout(pendingHistoryRecoveryTimer);
pendingHistoryRecoveryTimer = null; pendingHistoryRecoveryTimer = null;
pendingHistoryRecoveryTrigger = ""; pendingHistoryRecoveryTrigger = "";
skipBeforeCombineRecallUntil = 0;
lastPreGenerationRecallKey = "";
lastPreGenerationRecallAt = 0;
abortAllRunningStages(); abortAllRunningStages();
dismissAllStageNotices(); dismissAllStageNotices();
loadGraphFromChat(); loadGraphFromChat();
@@ -2328,23 +2657,56 @@ function onMessageSent(messageId) {
recordRecallSentUserMessage(messageId, message.mes || ""); recordRecallSentUserMessage(messageId, message.mes || "");
} }
function onMessageDeleted() { function onMessageDeleted(chatLengthOrMessageId, meta = null) {
clearInjectionState(); clearInjectionState();
scheduleHistoryMutationRecheck("message-deleted"); scheduleHistoryMutationRecheck("message-deleted", chatLengthOrMessageId, meta);
} }
function onMessageEdited() { function onMessageEdited(messageId, meta = null) {
clearInjectionState(); clearInjectionState();
scheduleHistoryMutationRecheck("message-edited"); scheduleHistoryMutationRecheck("message-edited", messageId, meta);
} }
function onMessageSwiped() { function onMessageSwiped(messageId, meta = null) {
clearInjectionState(); clearInjectionState();
scheduleHistoryMutationRecheck("message-swiped"); scheduleHistoryMutationRecheck("message-swiped", messageId, meta);
}
async function onGenerationAfterCommands(type, params = {}, dryRun = false) {
if (dryRun) return;
const context = getContext();
const chat = context?.chat;
const recallOptions = buildGenerationAfterCommandsRecallInput(type, params, chat);
if (!recallOptions?.overrideUserMessage) return;
const recallKey = buildPreGenerationRecallKey(type, recallOptions);
const recentlyHandled =
lastPreGenerationRecallKey === recallKey &&
Date.now() - lastPreGenerationRecallAt < 1500;
if (recentlyHandled) {
return;
}
const didRecall = await runRecall({
...recallOptions,
hookName: "GENERATION_AFTER_COMMANDS",
signal: params?.signal,
});
if (didRecall) {
lastPreGenerationRecallKey = recallKey;
lastPreGenerationRecallAt = Date.now();
skipBeforeCombineRecallUntil = Date.now() + 1500;
}
} }
async function onBeforeCombinePrompts() { async function onBeforeCombinePrompts() {
await runRecall(); if (skipBeforeCombineRecallUntil > Date.now()) {
skipBeforeCombineRecallUntil = 0;
return;
}
await runRecall({ hookName: "GENERATE_BEFORE_COMBINE_PROMPTS" });
} }
function onMessageReceived() { function onMessageReceived() {
@@ -2426,6 +2788,10 @@ async function onRebuild() {
buildRecoveryResult("full-rebuild", { buildRecoveryResult("full-rebuild", {
fromFloor: 0, fromFloor: 0,
batches: replayedBatches, batches: replayedBatches,
path: "full-rebuild",
detectionSource: "manual-rebuild",
affectedBatchCount: currentGraph.batchJournal?.length || 0,
replayedBatchCount: replayedBatches,
reason: "用户手动触发全量重建", reason: "用户手动触发全量重建",
}), }),
); );
@@ -2824,6 +3190,7 @@ async function onReembedDirect() {
if (event_types.MESSAGE_SENT) { if (event_types.MESSAGE_SENT) {
eventSource.on(event_types.MESSAGE_SENT, onMessageSent); eventSource.on(event_types.MESSAGE_SENT, onMessageSent);
} }
registerGenerationAfterCommands(onGenerationAfterCommands);
registerBeforeCombinePrompts(onBeforeCombinePrompts); registerBeforeCombinePrompts(onBeforeCombinePrompts);
eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived); eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived);
eventSource.on(event_types.MESSAGE_DELETED, onMessageDeleted); eventSource.on(event_types.MESSAGE_DELETED, onMessageDeleted);

View File

@@ -372,7 +372,15 @@ function _refreshDashboard() {
_setText( _setText(
"bme-status-recovery", "bme-status-recovery",
recovery recovery
? `${recovery.status} · from ${recovery.fromFloor ?? "—"} · ${recovery.reason || "—"}` ? [
recovery.status || "—",
recovery.path ? `path ${recovery.path}` : "",
recovery.detectionSource ? `src ${recovery.detectionSource}` : "",
recovery.fromFloor != null ? `from ${recovery.fromFloor}` : "",
recovery.affectedBatchCount != null ? `affected ${recovery.affectedBatchCount}` : "",
recovery.replayedBatchCount != null ? `replayed ${recovery.replayedBatchCount}` : "",
recovery.reason || "",
].filter(Boolean).join(" · ")
: "暂无恢复记录", : "暂无恢复记录",
); );
_setText( _setText(

View File

@@ -1,6 +1,7 @@
// ST-BME: 运行时状态与历史恢复辅助 // ST-BME: 运行时状态与历史恢复辅助
const BATCH_JOURNAL_LIMIT = 24; const BATCH_JOURNAL_LIMIT = 96;
export const BATCH_JOURNAL_VERSION = 2;
export function buildVectorCollectionId(chatId) { export function buildVectorCollectionId(chatId) {
return `st-bme::${chatId || "unknown-chat"}`; return `st-bme::${chatId || "unknown-chat"}`;
@@ -13,6 +14,8 @@ export function createDefaultHistoryState(chatId = "") {
processedMessageHashes: {}, processedMessageHashes: {},
historyDirtyFrom: null, historyDirtyFrom: null,
lastMutationReason: "", lastMutationReason: "",
lastMutationSource: "",
extractionCount: 0,
lastRecoveryResult: null, lastRecoveryResult: null,
}; };
} }
@@ -61,6 +64,12 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
? graph.lastProcessedSeq ? graph.lastProcessedSeq
: -1; : -1;
} }
if (!Number.isFinite(historyState.extractionCount)) {
historyState.extractionCount = 0;
}
if (typeof historyState.lastMutationSource !== "string") {
historyState.lastMutationSource = "";
}
if ( if (
!historyState.processedMessageHashes || !historyState.processedMessageHashes ||
@@ -207,7 +216,7 @@ export function detectHistoryMutation(chat, historyState) {
return { dirty: false, earliestAffectedFloor: null, reason: "" }; return { dirty: false, earliestAffectedFloor: null, reason: "" };
} }
export function markHistoryDirty(graph, floor, reason = "") { export function markHistoryDirty(graph, floor, reason = "", source = "") {
normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || "");
const currentDirtyFrom = graph.historyState.historyDirtyFrom; const currentDirtyFrom = graph.historyState.historyDirtyFrom;
@@ -219,11 +228,13 @@ export function markHistoryDirty(graph, floor, reason = "") {
? Math.min(currentDirtyFrom, floor) ? Math.min(currentDirtyFrom, floor)
: floor; : floor;
graph.historyState.lastMutationReason = String(reason || "").trim(); graph.historyState.lastMutationReason = String(reason || "").trim();
graph.historyState.lastMutationSource = String(source || "").trim();
graph.historyState.lastRecoveryResult = { graph.historyState.lastRecoveryResult = {
status: "pending", status: "pending",
at: Date.now(), at: Date.now(),
fromFloor: graph.historyState.historyDirtyFrom, fromFloor: graph.historyState.historyDirtyFrom,
reason: graph.historyState.lastMutationReason, reason: graph.historyState.lastMutationReason,
detectionSource: graph.historyState.lastMutationSource || "",
}; };
} }
@@ -231,6 +242,7 @@ export function clearHistoryDirty(graph, result = null) {
normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || "");
graph.historyState.historyDirtyFrom = null; graph.historyState.historyDirtyFrom = null;
graph.historyState.lastMutationReason = ""; graph.historyState.lastMutationReason = "";
graph.historyState.lastMutationSource = "";
if (result) { if (result) {
graph.historyState.lastRecoveryResult = result; graph.historyState.lastRecoveryResult = result;
} }
@@ -252,6 +264,32 @@ function hasMeaningfulEdgeChange(beforeEdge, afterEdge) {
return JSON.stringify(beforeEdge) !== JSON.stringify(afterEdge); return JSON.stringify(beforeEdge) !== JSON.stringify(afterEdge);
} }
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
function buildJournalStateBefore(snapshotBefore, meta = {}) {
return {
lastProcessedAssistantFloor:
snapshotBefore?.historyState?.lastProcessedAssistantFloor ??
snapshotBefore?.lastProcessedSeq ??
-1,
processedMessageHashes: clonePlain(
snapshotBefore?.historyState?.processedMessageHashes || {},
),
historyDirtyFrom: Number.isFinite(snapshotBefore?.historyState?.historyDirtyFrom)
? snapshotBefore.historyState.historyDirtyFrom
: null,
vectorIndexState: clonePlain(snapshotBefore?.vectorIndexState || {}),
lastRecallResult: Array.isArray(snapshotBefore?.lastRecallResult)
? [...snapshotBefore.lastRecallResult]
: null,
extractionCount: Number.isFinite(meta.extractionCountBefore)
? meta.extractionCountBefore
: snapshotBefore?.historyState?.extractionCount ?? 0,
};
}
export function createBatchJournalEntry(snapshotBefore, snapshotAfter, meta = {}) { export function createBatchJournalEntry(snapshotBefore, snapshotAfter, meta = {}) {
const beforeNodes = buildNodeMap(snapshotBefore?.nodes || []); const beforeNodes = buildNodeMap(snapshotBefore?.nodes || []);
const afterNodes = buildNodeMap(snapshotAfter?.nodes || []); const afterNodes = buildNodeMap(snapshotAfter?.nodes || []);
@@ -260,9 +298,8 @@ export function createBatchJournalEntry(snapshotBefore, snapshotAfter, meta = {}
const createdNodeIds = []; const createdNodeIds = [];
const createdEdgeIds = []; const createdEdgeIds = [];
const updatedNodeSnapshots = []; const previousNodeSnapshots = [];
const archivedNodeSnapshots = []; const previousEdgeSnapshots = [];
const invalidatedEdgeSnapshots = [];
for (const [nodeId, afterNode] of afterNodes.entries()) { for (const [nodeId, afterNode] of afterNodes.entries()) {
if (!beforeNodes.has(nodeId)) { if (!beforeNodes.has(nodeId)) {
@@ -272,11 +309,7 @@ export function createBatchJournalEntry(snapshotBefore, snapshotAfter, meta = {}
const beforeNode = beforeNodes.get(nodeId); const beforeNode = beforeNodes.get(nodeId);
if (!hasMeaningfulNodeChange(beforeNode, afterNode)) continue; if (!hasMeaningfulNodeChange(beforeNode, afterNode)) continue;
updatedNodeSnapshots.push(cloneGraphSnapshot(beforeNode)); previousNodeSnapshots.push(cloneGraphSnapshot(beforeNode));
if (beforeNode.archived !== afterNode.archived) {
archivedNodeSnapshots.push(cloneGraphSnapshot(beforeNode));
}
} }
for (const [edgeId, afterEdge] of afterEdges.entries()) { for (const [edgeId, afterEdge] of afterEdges.entries()) {
@@ -287,31 +320,34 @@ export function createBatchJournalEntry(snapshotBefore, snapshotAfter, meta = {}
const beforeEdge = beforeEdges.get(edgeId); const beforeEdge = beforeEdges.get(edgeId);
if (!hasMeaningfulEdgeChange(beforeEdge, afterEdge)) continue; if (!hasMeaningfulEdgeChange(beforeEdge, afterEdge)) continue;
if ( previousEdgeSnapshots.push(cloneGraphSnapshot(beforeEdge));
beforeEdge.invalidAt !== afterEdge.invalidAt ||
beforeEdge.expiredAt !== afterEdge.expiredAt
) {
invalidatedEdgeSnapshots.push(cloneGraphSnapshot(beforeEdge));
}
} }
return { const entry = {
id: `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
journalVersion: BATCH_JOURNAL_VERSION,
createdAt: Date.now(), createdAt: Date.now(),
processedRange: meta.processedRange || [-1, -1], processedRange: meta.processedRange || [-1, -1],
createdNodeIds, createdNodeIds,
createdEdgeIds, createdEdgeIds,
updatedNodeSnapshots, previousNodeSnapshots,
archivedNodeSnapshots, previousEdgeSnapshots,
invalidatedEdgeSnapshots, stateBefore: buildJournalStateBefore(snapshotBefore, meta),
vectorHashesInserted: Array.isArray(meta.vectorHashesInserted) vectorDelta: {
? [...new Set(meta.vectorHashesInserted)] insertedHashes: Array.isArray(meta.vectorHashesInserted)
: [], ? [...new Set(meta.vectorHashesInserted)]
: [],
},
postProcessArtifacts: Array.isArray(meta.postProcessArtifacts) postProcessArtifacts: Array.isArray(meta.postProcessArtifacts)
? meta.postProcessArtifacts ? meta.postProcessArtifacts
: [], : [],
snapshotBefore,
}; };
if (meta.includeLegacySnapshotBefore) {
entry.snapshotBefore = snapshotBefore;
}
return entry;
} }
export function appendBatchJournal(graph, entry) { export function appendBatchJournal(graph, entry) {
@@ -322,6 +358,99 @@ export function appendBatchJournal(graph, entry) {
} }
} }
function upsertById(list, item) {
const index = list.findIndex((entry) => entry.id === item.id);
if (index >= 0) {
list[index] = item;
} else {
list.push(item);
}
}
function sanitizeGraphReferences(graph) {
const nodeIds = new Set((graph?.nodes || []).map((node) => node.id));
graph.nodes = (graph.nodes || []).map((node) => ({
...node,
parentId: nodeIds.has(node.parentId) ? node.parentId : null,
childIds: Array.isArray(node.childIds)
? node.childIds.filter((id) => nodeIds.has(id))
: [],
prevId: nodeIds.has(node.prevId) ? node.prevId : null,
nextId: nodeIds.has(node.nextId) ? node.nextId : null,
}));
graph.edges = (graph.edges || []).filter(
(edge) => nodeIds.has(edge.fromId) && nodeIds.has(edge.toId),
);
}
function applyJournalStateBefore(graph, stateBefore = {}) {
const historyState = {
...createDefaultHistoryState(graph?.historyState?.chatId || ""),
...(graph.historyState || {}),
};
historyState.lastProcessedAssistantFloor = Number.isFinite(
stateBefore.lastProcessedAssistantFloor,
)
? stateBefore.lastProcessedAssistantFloor
: historyState.lastProcessedAssistantFloor;
historyState.processedMessageHashes = clonePlain(
stateBefore.processedMessageHashes || {},
);
historyState.historyDirtyFrom = Number.isFinite(stateBefore.historyDirtyFrom)
? stateBefore.historyDirtyFrom
: null;
historyState.extractionCount = Number.isFinite(stateBefore.extractionCount)
? stateBefore.extractionCount
: historyState.extractionCount;
graph.historyState = historyState;
graph.vectorIndexState = {
...createDefaultVectorIndexState(graph?.historyState?.chatId || ""),
...clonePlain(stateBefore.vectorIndexState || {}),
};
graph.lastRecallResult = Array.isArray(stateBefore.lastRecallResult)
? [...stateBefore.lastRecallResult]
: null;
graph.lastProcessedSeq = historyState.lastProcessedAssistantFloor;
}
export function rollbackBatch(graph, journal) {
if (!graph || !journal) return graph;
normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || "");
const createdNodeIds = new Set(journal.createdNodeIds || []);
const createdEdgeIds = new Set(journal.createdEdgeIds || []);
const previousNodeSnapshots =
journal.previousNodeSnapshots ||
journal.updatedNodeSnapshots ||
journal.archivedNodeSnapshots ||
[];
const previousEdgeSnapshots =
journal.previousEdgeSnapshots ||
journal.invalidatedEdgeSnapshots ||
[];
graph.edges = (graph.edges || []).filter(
(edge) =>
!createdEdgeIds.has(edge.id) &&
!createdNodeIds.has(edge.fromId) &&
!createdNodeIds.has(edge.toId),
);
graph.nodes = (graph.nodes || []).filter((node) => !createdNodeIds.has(node.id));
for (const nodeSnapshot of previousNodeSnapshots) {
upsertById(graph.nodes, cloneGraphSnapshot(nodeSnapshot));
}
for (const edgeSnapshot of previousEdgeSnapshots) {
upsertById(graph.edges, cloneGraphSnapshot(edgeSnapshot));
}
applyJournalStateBefore(graph, journal.stateBefore || {});
sanitizeGraphReferences(graph);
return graph;
}
export function findJournalRecoveryPoint(graph, dirtyFromFloor) { export function findJournalRecoveryPoint(graph, dirtyFromFloor) {
const journals = Array.isArray(graph?.batchJournal) ? graph.batchJournal : []; const journals = Array.isArray(graph?.batchJournal) ? graph.batchJournal : [];
const affectedIndex = journals.findIndex((journal) => { const affectedIndex = journals.findIndex((journal) => {
@@ -333,14 +462,31 @@ export function findJournalRecoveryPoint(graph, dirtyFromFloor) {
if (affectedIndex < 0) return null; if (affectedIndex < 0) return null;
const journal = journals[affectedIndex]; const affectedJournals = journals.slice(affectedIndex);
if (!journal?.snapshotBefore) return null; const canReverse = affectedJournals.every(
(journal) => Number(journal?.journalVersion || 0) >= BATCH_JOURNAL_VERSION,
);
if (canReverse) {
return {
path: "reverse-journal",
affectedIndex,
affectedJournals: affectedJournals.map((journal) => cloneGraphSnapshot(journal)),
affectedBatchCount: affectedJournals.length,
};
}
return { const journal = journals[affectedIndex];
affectedIndex, if (journal?.snapshotBefore) {
journal, return {
snapshotBefore: cloneGraphSnapshot(journal.snapshotBefore), path: "legacy-snapshot",
}; affectedIndex,
journal: cloneGraphSnapshot(journal),
snapshotBefore: cloneGraphSnapshot(journal.snapshotBefore),
affectedBatchCount: affectedJournals.length,
};
}
return null;
} }
export function buildRecoveryResult(status, extra = {}) { export function buildRecoveryResult(status, extra = {}) {

View File

@@ -5,6 +5,7 @@ import {
createBatchJournalEntry, createBatchJournalEntry,
detectHistoryMutation, detectHistoryMutation,
findJournalRecoveryPoint, findJournalRecoveryPoint,
rollbackBatch,
snapshotProcessedMessageHashes, snapshotProcessedMessageHashes,
} from "../runtime-state.js"; } from "../runtime-state.js";
import { createEmptyGraph } from "../graph.js"; import { createEmptyGraph } from "../graph.js";
@@ -43,8 +44,29 @@ assert.equal(truncatedDetection.earliestAffectedFloor, 2);
const graph = createEmptyGraph(); const graph = createEmptyGraph();
graph.historyState.chatId = "chat-history-test"; graph.historyState.chatId = "chat-history-test";
const beforeSnapshot = cloneGraphSnapshot(graph); const beforeSnapshot = cloneGraphSnapshot(graph);
graph.nodes.push({
id: "node-1",
type: "event",
fields: { title: "旧事件", summary: "旧摘要" },
seq: 1,
seqRange: [1, 1],
archived: false,
embedding: null,
importance: 5,
accessCount: 0,
lastAccessTime: Date.now(),
createdTime: Date.now(),
level: 0,
parentId: null,
childIds: [],
prevId: null,
nextId: null,
clusters: [],
});
graph.lastProcessedSeq = 3; graph.lastProcessedSeq = 3;
graph.historyState.lastProcessedAssistantFloor = 3; graph.historyState.lastProcessedAssistantFloor = 3;
graph.historyState.processedMessageHashes = hashes;
graph.historyState.extractionCount = 4;
const afterSnapshot = cloneGraphSnapshot(graph); const afterSnapshot = cloneGraphSnapshot(graph);
appendBatchJournal( appendBatchJournal(
graph, graph,
@@ -52,15 +74,18 @@ appendBatchJournal(
processedRange: [1, 3], processedRange: [1, 3],
postProcessArtifacts: ["compression"], postProcessArtifacts: ["compression"],
vectorHashesInserted: [1234], vectorHashesInserted: [1234],
extractionCountBefore: 0,
}), }),
); );
const recoveryPoint = findJournalRecoveryPoint(graph, 2); const recoveryPoint = findJournalRecoveryPoint(graph, 2);
assert.ok(recoveryPoint); assert.ok(recoveryPoint);
assert.equal(recoveryPoint.journal.processedRange[1], 3); assert.equal(recoveryPoint.path, "reverse-journal");
assert.equal( assert.equal(recoveryPoint.affectedJournals[0].processedRange[1], 3);
recoveryPoint.snapshotBefore.historyState.lastProcessedAssistantFloor,
-1, rollbackBatch(graph, recoveryPoint.affectedJournals[0]);
); assert.equal(graph.nodes.length, 0);
assert.equal(graph.historyState.lastProcessedAssistantFloor, -1);
assert.equal(graph.historyState.extractionCount, 0);
console.log("runtime-history tests passed"); console.log("runtime-history tests passed");

View File

@@ -386,6 +386,16 @@ async function deleteVectorHashes(collectionId, config, hashes, signal) {
} }
} }
export async function deleteBackendVectorHashesForRecovery(
collectionId,
config,
hashes,
signal = undefined,
) {
if (!collectionId || !isBackendVectorConfig(config)) return;
await deleteVectorHashes(collectionId, config, hashes, signal);
}
async function insertVectorEntries(collectionId, config, entries, signal) { async function insertVectorEntries(collectionId, config, entries, signal) {
if (!Array.isArray(entries) || entries.length === 0) return; if (!Array.isArray(entries) || entries.length === 0) return;
throwIfAborted(signal); throwIfAborted(signal);