From d8710f45f1e437f58ba2682764edb975717a27c9 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 31 Mar 2026 15:22:08 +0800 Subject: [PATCH] fix: restore before-combine recall gating --- index.js | 819 +++++++++++++++++++++++++-------------- recall-controller.js | 33 +- tests/p0-regressions.mjs | 518 ++++++++++++++++++++----- 3 files changed, 966 insertions(+), 404 deletions(-) diff --git a/index.js b/index.js index bf255df..f91fd9d 100644 --- a/index.js +++ b/index.js @@ -28,8 +28,33 @@ import { scheduleUpload, syncNow, } from "./bme-sync.js"; +import { + buildExtractionMessages, + clampRecoveryStartFloor, + getAssistantTurns, + isAssistantChatMessage, + pruneProcessedMessageHashesFromFloor, + resolveDirtyFloorFromMutationMeta, + rollbackAffectedJournals, +} from "./chat-history.js"; import { compressAll, sleepCycle } from "./compressor.js"; import { consolidateMemories } from "./consolidator.js"; +import { + installSendIntentHooksController, + onBeforeCombinePromptsController, + onChatChangedController, + onChatLoadedController, + onGenerationAfterCommandsController, + onMessageDeletedController, + onMessageEditedController, + onMessageReceivedController, + onMessageSentController, + onMessageSwipedController, + registerBeforeCombinePromptsController, + registerCoreEventHooksController, + registerGenerationAfterCommandsController, + scheduleSendIntentHookRetryController, +} from "./event-binding.js"; import { executeExtractionBatchController, onManualExtractController, @@ -42,12 +67,19 @@ import { generateSynopsis, } from "./extractor.js"; import { - applyRecallInjectionController, - buildRecallRecentMessagesController, - getRecallUserMessageSourceLabelController, - runRecallController, - resolveRecallInputController, -} from "./recall-controller.js"; + GRAPH_LOAD_PENDING_CHAT_ID, + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_STARTUP_RECONCILE_DELAYS_MS, + MODULE_NAME, + cloneGraphForPersistence, + cloneRuntimeDebugValue, + getGraphPersistedRevision, + removeGraphShadowSnapshot, + stampGraphPersistenceMeta, + writeChatMetadataPatch, + writeGraphShadowSnapshot, +} from "./graph-persistence.js"; import { createEmptyGraph, deserializeGraph, @@ -55,7 +87,6 @@ import { getGraphStats, getNode, importGraph, - serializeGraph, } from "./graph.js"; import { HOST_ADAPTER_STATE_SEMANTICS, @@ -70,47 +101,36 @@ import { fetchMemoryLLMModels, testLLMConnection } from "./llm.js"; import { getNodeDisplayName } from "./node-labels.js"; import { showManagedBmeNotice } from "./notice.js"; import { - installSendIntentHooksController, - onChatChangedController, - onChatLoadedController, - onBeforeCombinePromptsController, - onGenerationAfterCommandsController, - onMessageDeletedController, - onMessageEditedController, - onMessageReceivedController, - onMessageSentController, - onMessageSwipedController, - registerBeforeCombinePromptsController, - registerCoreEventHooksController, - registerGenerationAfterCommandsController, - scheduleSendIntentHookRetryController, -} from "./event-binding.js"; + createNoticePanelActionController, + initializePanelBridgeController, + refreshPanelLiveStateController, +} from "./panel-bridge.js"; import { createDefaultTaskProfiles, migrateLegacyTaskProfiles, } from "./prompt-profiles.js"; import { - onFetchEmbeddingModelsController, - onFetchMemoryLLMModelsController, - onExportGraphController, - onImportGraphController, - onManualCompressController, - onManualEvolveController, - onManualSleepController, - onManualSynopsisController, - onRebuildVectorIndexController, - onRebuildController, - onTestEmbeddingController, - onTestMemoryLLMController, - onReembedDirectController, - onViewLastInjectionController, - onViewGraphController, -} from "./ui-actions-controller.js"; + applyRecallInjectionController, + buildRecallRecentMessagesController, + getRecallUserMessageSourceLabelController, + resolveRecallInputController, + runRecallController, +} from "./recall-controller.js"; import { - createNoticePanelActionController, - initializePanelBridgeController, - refreshPanelLiveStateController, -} from "./panel-bridge.js"; + createRecallCardElement, + openRecallSidebar, + updateRecallCardData, +} from "./recall-message-ui.js"; +import { + buildPersistedRecallRecord, + bumpPersistedRecallGenerationCount, + markPersistedRecallManualEdit, + readPersistedRecallFromUserMessage, + removePersistedRecallFromUserMessage, + resolveFinalRecallInjectionSource, + resolveGenerationTargetUserMessageIndex, + writePersistedRecallToUserMessage, +} from "./recall-persistence.js"; import { resolveConfiguredTimeoutMs } from "./request-timeout.js"; import { retrieve } from "./retriever.js"; import { @@ -124,27 +144,28 @@ import { findJournalRecoveryPoint, markHistoryDirty, normalizeGraphRuntimeState, - rollbackBatch, snapshotProcessedMessageHashes, } from "./runtime-state.js"; import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js"; import { - deleteBackendVectorHashesForRecovery, - fetchAvailableEmbeddingModels, - getVectorConfigFromSettings, - getVectorIndexStats, - isBackendVectorConfig, - isDirectVectorConfig, - syncGraphVectorIndex, - testVectorConnection, - validateVectorConfig, -} from "./vector-index.js"; + onExportGraphController, + onFetchEmbeddingModelsController, + onFetchMemoryLLMModelsController, + onImportGraphController, + onManualCompressController, + onManualEvolveController, + onManualSleepController, + onManualSynopsisController, + onRebuildController, + onRebuildVectorIndexController, + onReembedDirectController, + onTestEmbeddingController, + onTestMemoryLLMController, + onViewGraphController, + onViewLastInjectionController, +} from "./ui-actions-controller.js"; import { - BATCH_STAGE_ORDER, - BATCH_STAGE_SEVERITY, - clampFloat, clampInt, - createBatchStageStatus, createBatchStatusSkeleton, createGraphPersistenceState, createRecallInputRecord, @@ -158,7 +179,6 @@ import { getStageNoticeTitle, hashRecallInput, isFreshRecallInputRecord, - isTerminalGenerationRecallHookState, normalizeRecallInputText, normalizeStageNoticeLevel, pushBatchStageArtifact, @@ -166,54 +186,16 @@ import { shouldRunRecallForTransaction, } from "./ui-status.js"; import { - cloneGraphForPersistence, - cloneRuntimeDebugValue, - getGraphPersistenceMeta, - getGraphPersistedRevision, - getGraphShadowSnapshotStorageKey, - GRAPH_LOAD_PENDING_CHAT_ID, - GRAPH_LOAD_STATES, - GRAPH_METADATA_KEY, - GRAPH_PERSISTENCE_META_KEY, - GRAPH_PERSISTENCE_SESSION_ID, - GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX, - GRAPH_STARTUP_RECONCILE_DELAYS_MS, - MODULE_NAME, - readGraphShadowSnapshot, - removeGraphShadowSnapshot, - shouldPreferShadowSnapshotOverOfficial, - stampGraphPersistenceMeta, - writeChatMetadataPatch, - writeGraphShadowSnapshot, -} from "./graph-persistence.js"; -import { - buildExtractionMessages, - clampRecoveryStartFloor, - getAssistantTurns, - getChatIndexForAssistantSeq, - getChatIndexForPlayableSeq, - getMinExtractableAssistantFloor, - isAssistantChatMessage, - pruneProcessedMessageHashesFromFloor, - resolveDirtyFloorFromMutationMeta, - rollbackAffectedJournals, -} from "./chat-history.js"; -import { - buildPersistedRecallRecord, - bumpPersistedRecallGenerationCount, - markPersistedRecallManualEdit, - readPersistedRecallFromUserMessage, - removePersistedRecallFromUserMessage, - resolveFinalRecallInjectionSource, - resolveGenerationTargetUserMessageIndex, - writePersistedRecallToUserMessage, -} from "./recall-persistence.js"; -import { - createRecallCardElement, - openRecallSidebar, - updateRecallCardData, -} from "./recall-message-ui.js"; - + deleteBackendVectorHashesForRecovery, + fetchAvailableEmbeddingModels, + getVectorConfigFromSettings, + getVectorIndexStats, + isBackendVectorConfig, + isDirectVectorConfig, + syncGraphVectorIndex, + testVectorConnection, + validateVectorConfig, +} from "./vector-index.js"; // 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; @@ -503,7 +485,12 @@ const bmeIndexedDbLoadInFlightByChatId = new Map(); const bmeIndexedDbWriteInFlightByChatId = new Map(); const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map(); const bmeIndexedDbLatestQueuedRevisionByChatId = new Map(); -const BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET = new Set([GRAPH_LOAD_STATES.LOADING, GRAPH_LOAD_STATES.BLOCKED, GRAPH_LOAD_STATES.NO_CHAT, GRAPH_LOAD_STATES.SHADOW_RESTORED]); +const BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET = new Set([ + GRAPH_LOAD_STATES.LOADING, + GRAPH_LOAD_STATES.BLOCKED, + GRAPH_LOAD_STATES.NO_CHAT, + GRAPH_LOAD_STATES.SHADOW_RESTORED, +]); function isGraphLoadStateDbReady(loadState = graphPersistenceState.loadState) { return ( @@ -513,12 +500,14 @@ function isGraphLoadStateDbReady(loadState = graphPersistenceState.loadState) { } function normalizeGraphSyncState(value = "idle") { - const normalized = String(value || "idle").trim().toLowerCase(); - if (["idle", "syncing", "warning", "error"].includes(normalized)) return normalized; + const normalized = String(value || "idle") + .trim() + .toLowerCase(); + if (["idle", "syncing", "warning", "error"].includes(normalized)) + return normalized; return "idle"; } - function getGraphPersistenceLiveState() { const snapshot = { loadState: graphPersistenceState.loadState, @@ -549,15 +538,20 @@ function getGraphPersistenceLiveState() { storagePrimary: graphPersistenceState.storagePrimary || "indexeddb", storageMode: graphPersistenceState.storageMode || "indexeddb", dbReady: - graphPersistenceState.dbReady ?? isGraphLoadStateDbReady(graphPersistenceState.loadState), + graphPersistenceState.dbReady ?? + isGraphLoadStateDbReady(graphPersistenceState.loadState), indexedDbRevision: graphPersistenceState.indexedDbRevision || 0, indexedDbLastError: graphPersistenceState.indexedDbLastError || "", syncState: normalizeGraphSyncState(graphPersistenceState.syncState), lastSyncUploadedAt: Number(graphPersistenceState.lastSyncUploadedAt) || 0, - lastSyncDownloadedAt: Number(graphPersistenceState.lastSyncDownloadedAt) || 0, + lastSyncDownloadedAt: + Number(graphPersistenceState.lastSyncDownloadedAt) || 0, lastSyncedRevision: Number(graphPersistenceState.lastSyncedRevision) || 0, lastSyncError: String(graphPersistenceState.lastSyncError || ""), - dualWriteLastResult: cloneRuntimeDebugValue(graphPersistenceState.dualWriteLastResult, null), + dualWriteLastResult: cloneRuntimeDebugValue( + graphPersistenceState.dualWriteLastResult, + null, + ), }; return cloneRuntimeDebugValue(snapshot, snapshot); @@ -612,6 +606,45 @@ function isGraphReadable(loadState = graphPersistenceState.loadState) { ); } +function hasReadableRuntimeGraphForRecall(chatId = getCurrentChatId()) { + if ( + !currentGraph || + typeof currentGraph !== "object" || + !Array.isArray(currentGraph.nodes) || + !Array.isArray(currentGraph.edges) || + !currentGraph.historyState || + typeof currentGraph.historyState !== "object" || + Array.isArray(currentGraph.historyState) + ) { + return false; + } + + const activeChatId = normalizeChatIdCandidate(chatId); + const runtimeChatId = normalizeChatIdCandidate( + currentGraph.historyState.chatId, + ); + if (!activeChatId || !runtimeChatId) { + return false; + } + + return runtimeChatId === activeChatId; +} + +function isGraphReadableForRecall( + loadState = graphPersistenceState.loadState, + chatId = getCurrentChatId(), +) { + if (isGraphReadable(loadState)) { + return true; + } + + if (loadState !== GRAPH_LOAD_STATES.LOADING) { + return false; + } + + return hasReadableRuntimeGraphForRecall(chatId); +} + function createGraphLoadUiStatus() { const state = graphPersistenceState.loadState; const chatId = graphPersistenceState.chatId || getCurrentChatId(); @@ -754,12 +787,12 @@ function throwIfAborted(signal, message = "操作已终止") { } } -function assertRecoveryChatStillActive(expectedChatId, label = '') { +function assertRecoveryChatStillActive(expectedChatId, label = "") { if (!expectedChatId) return; const currentId = getCurrentChatId(); if (currentId && currentId !== expectedChatId) { throw createAbortError( - `历史恢复已终止:聊天已从 ${expectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ''}` + `历史恢复已终止:聊天已从 ${expectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ""}`, ); } } @@ -1048,7 +1081,11 @@ function debugPersistedRecallUi(reason, details = null, throttleKey = reason) { ); } -function debugPersistedRecallPersistence(reason, details = null, throttleKey = reason) { +function debugPersistedRecallPersistence( + reason, + details = null, + throttleKey = reason, +) { const suffix = details ? ` ${JSON.stringify(details)}` : ""; debugWithThrottle( persistedRecallPersistDiagnosticTimestamps, @@ -1066,7 +1103,8 @@ function persistRecallInjectionRecord({ const chat = getContext()?.chat; if (!Array.isArray(chat)) return null; - const generationType = String(recallInput?.generationType || "normal").trim() || "normal"; + const generationType = + String(recallInput?.generationType || "normal").trim() || "normal"; let resolvedTargetIndex = Number.isFinite(recallInput?.targetUserMessageIndex) ? recallInput.targetUserMessageIndex : resolveGenerationTargetUserMessageIndex(chat, { generationType }); @@ -1111,7 +1149,9 @@ function persistRecallInjectionRecord({ if (!String(record?.injectionText || "").trim()) { debugPersistedRecallPersistence("无有效 injectionText,跳过持久化", { targetUserMessageIndex: resolvedTargetIndex, - selectedNodeCount: Array.isArray(result?.selectedNodeIds) ? result.selectedNodeIds.length : 0, + selectedNodeCount: Array.isArray(result?.selectedNodeIds) + ? result.selectedNodeIds.length + : 0, }); return null; } @@ -1123,11 +1163,17 @@ function persistRecallInjectionRecord({ } triggerChatMetadataSave(getContext(), { immediate: false }); - debugPersistedRecallPersistence("召回记录已写入 user 楼层", { - targetUserMessageIndex: resolvedTargetIndex, - injectionTextLength: String(record?.injectionText || "").length, - selectedNodeCount: Array.isArray(record?.selectedNodeIds) ? record.selectedNodeIds.length : 0, - }, `persist-success:${resolvedTargetIndex}`); + debugPersistedRecallPersistence( + "召回记录已写入 user 楼层", + { + targetUserMessageIndex: resolvedTargetIndex, + injectionTextLength: String(record?.injectionText || "").length, + selectedNodeCount: Array.isArray(record?.selectedNodeIds) + ? record.selectedNodeIds.length + : 0, + }, + `persist-success:${resolvedTargetIndex}`, + ); return { index: resolvedTargetIndex, record, @@ -1162,7 +1208,12 @@ function editMessageRecallRecord(messageIndex, nextInjectionText) { if (!writePersistedRecallToUserMessage(chat, messageIndex, nextRecord)) { return null; } - const edited = markPersistedRecallManualEdit(chat, messageIndex, true, nowIso); + const edited = markPersistedRecallManualEdit( + chat, + messageIndex, + true, + nowIso, + ); if (!edited) return null; triggerChatMetadataSave(getContext(), { immediate: false }); @@ -1204,7 +1255,10 @@ function applyFinalRecallInjectionForGeneration({ applyModuleInjectionPrompt("", getSettings()); } - if (resolved.source === "persisted" && Number.isFinite(targetUserMessageIndex)) { + if ( + resolved.source === "persisted" && + Number.isFinite(targetUserMessageIndex) + ) { bumpPersistedRecallGenerationCount(chat, targetUserMessageIndex); triggerChatMetadataSave(getContext(), { immediate: false }); } @@ -1217,7 +1271,11 @@ function applyFinalRecallInjectionForGeneration({ ); } else if (resolved.source === "persisted") { lastInjectionContent = resolved.injectionText || ""; - runtimeStatus = createUiStatus("召回回退", "已使用消息楼层持久化注入", "info"); + runtimeStatus = createUiStatus( + "召回回退", + "已使用消息楼层持久化注入", + "info", + ); } else { lastInjectionContent = ""; runtimeStatus = createUiStatus("待命", "当前无有效注入内容", "idle"); @@ -1245,7 +1303,9 @@ function clearPersistedRecallMessageUiObserver() { function isDomNodeAttached(node) { if (!node) return false; if (node.isConnected === true) return true; - return typeof document?.contains === "function" ? document.contains(node) : true; + return typeof document?.contains === "function" + ? document.contains(node) + : true; } function cleanupRecallCardElement(cardElement) { @@ -1260,7 +1320,9 @@ function cleanupRecallCardElement(cardElement) { function cleanupLegacyRecallBadges(messageElement) { if (!messageElement?.querySelectorAll) return; - const oldBadges = Array.from(messageElement.querySelectorAll(".st-bme-recall-badge") || []); + const oldBadges = Array.from( + messageElement.querySelectorAll(".st-bme-recall-badge") || [], + ); for (const oldBadge of oldBadges) oldBadge.remove(); } @@ -1269,9 +1331,14 @@ function cleanupRecallArtifacts(messageElement, keepMessageIndex = null) { cleanupLegacyRecallBadges(messageElement); - const existingCards = Array.from(messageElement.querySelectorAll(".bme-recall-card") || []); + const existingCards = Array.from( + messageElement.querySelectorAll(".bme-recall-card") || [], + ); for (const card of existingCards) { - if (keepMessageIndex !== null && card.dataset?.messageIndex === String(keepMessageIndex)) { + if ( + keepMessageIndex !== null && + card.dataset?.messageIndex === String(keepMessageIndex) + ) { continue; } cleanupRecallCardElement(card); @@ -1310,18 +1377,25 @@ function resolveRecallCardAnchor(messageElement) { const mesBlock = messageElement.querySelector?.(".mes_block"); if (isDomNodeAttached(mesBlock)) return mesBlock; - const mesTextParent = messageElement.querySelector?.(".mes_text")?.parentElement; + const mesTextParent = + messageElement.querySelector?.(".mes_text")?.parentElement; if (isDomNodeAttached(mesTextParent)) return mesTextParent; return isDomNodeAttached(messageElement) ? messageElement : null; } function buildPersistedRecallUiRetryDelays(initialDelayMs = 0) { - const normalizedInitial = Math.max(0, Number.parseInt(initialDelayMs, 10) || 0); - if (!normalizedInitial) return [...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS]; + const normalizedInitial = Math.max( + 0, + Number.parseInt(initialDelayMs, 10) || 0, + ); + if (!normalizedInitial) + return [...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS]; return [ normalizedInitial, - ...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS.filter((delay) => delay > normalizedInitial), + ...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS.filter( + (delay) => delay > normalizedInitial, + ), ]; } @@ -1369,15 +1443,23 @@ function refreshPersistedRecallMessageUi() { cleanupLegacyRecallBadges(messageElement); const messageIndex = resolveMessageIndexFromElement(messageElement); if (!Number.isFinite(messageIndex)) { - debugPersistedRecallUi("消息 DOM 缺少稳定索引属性,跳过挂载", { - className: messageElement.className || "", - }, "missing-stable-message-index"); + debugPersistedRecallUi( + "消息 DOM 缺少稳定索引属性,跳过挂载", + { + className: messageElement.className || "", + }, + "missing-stable-message-index", + ); continue; } if (messageElementMap.has(messageIndex)) { - debugPersistedRecallUi("检测到重复消息 DOM 索引,保留首个锚点", { - messageIndex, - }, `duplicate-message-index:${messageIndex}`); + debugPersistedRecallUi( + "检测到重复消息 DOM 索引,保留首个锚点", + { + messageIndex, + }, + `duplicate-message-index:${messageIndex}`, + ); cleanupRecallArtifacts(messageElement); continue; } @@ -1396,18 +1478,26 @@ function refreshPersistedRecallMessageUi() { for (let messageIndex = 0; messageIndex < chat.length; messageIndex++) { const message = chat[messageIndex]; const messageElement = messageElementMap.get(messageIndex) || null; - const existingCard = messageElement?.querySelector?.( - `.bme-recall-card[data-message-index="${messageIndex}"]`, - ) || null; + const existingCard = + messageElement?.querySelector?.( + `.bme-recall-card[data-message-index="${messageIndex}"]`, + ) || null; if (!message?.is_user) { if (existingCard) cleanupRecallCardElement(existingCard); - const unexpectedRecord = readPersistedRecallFromUserMessage(chat, messageIndex); + const unexpectedRecord = readPersistedRecallFromUserMessage( + chat, + messageIndex, + ); if (unexpectedRecord) { summary.skippedNonUserIndices.push(messageIndex); - debugPersistedRecallUi("非 user 楼层存在持久召回记录,已跳过挂载", { - messageIndex, - }, `skipped-non-user:${messageIndex}`); + debugPersistedRecallUi( + "非 user 楼层存在持久召回记录,已跳过挂载", + { + messageIndex, + }, + `skipped-non-user:${messageIndex}`, + ); } continue; } @@ -1421,9 +1511,13 @@ function refreshPersistedRecallMessageUi() { summary.persistedRecordCount += 1; if (!messageElement) { summary.waitingMessageIndices.push(messageIndex); - debugPersistedRecallUi("目标 user 楼层 DOM 未就绪,等待后续刷新", { - messageIndex, - }, `waiting-dom:${messageIndex}`); + debugPersistedRecallUi( + "目标 user 楼层 DOM 未就绪,等待后续刷新", + { + messageIndex, + }, + `waiting-dom:${messageIndex}`, + ); continue; } @@ -1431,16 +1525,21 @@ function refreshPersistedRecallMessageUi() { if (!anchor) { cleanupRecallCardElement(existingCard); summary.anchorFailureIndices.push(messageIndex); - debugPersistedRecallUi("目标 user 楼层锚点解析失败,跳过挂载", { - messageIndex, - }, `missing-anchor:${messageIndex}`); + debugPersistedRecallUi( + "目标 user 楼层锚点解析失败,跳过挂载", + { + messageIndex, + }, + `missing-anchor:${messageIndex}`, + ); continue; } cleanupRecallArtifacts(messageElement, messageIndex); - const currentCard = messageElement.querySelector?.( - `.bme-recall-card[data-message-index="${messageIndex}"]`, - ) || null; + const currentCard = + messageElement.querySelector?.( + `.bme-recall-card[data-message-index="${messageIndex}"]`, + ) || null; if (currentCard) { updateRecallCardData(currentCard, record, { @@ -1467,11 +1566,15 @@ function refreshPersistedRecallMessageUi() { if (summary.status === "missing_recall_record") { debugPersistedRecallUi("当前无有效持久召回记录可渲染"); } else if (summary.renderedCount > 0) { - debugPersistedRecallUi("Recall Card 挂载完成", { - renderedCount: summary.renderedCount, - persistedRecordCount: summary.persistedRecordCount, - waitingDom: summary.waitingMessageIndices.length, - }, `rendered:${summary.renderedCount}`); + debugPersistedRecallUi( + "Recall Card 挂载完成", + { + renderedCount: summary.renderedCount, + persistedRecordCount: summary.persistedRecordCount, + waitingDom: summary.waitingMessageIndices.length, + }, + `rendered:${summary.renderedCount}`, + ); } return summary; } @@ -1548,7 +1651,10 @@ function armPersistedRecallMessageUiObserver(sessionId, runAttempt) { clearPersistedRecallMessageUiObserver(); runAttempt(); }); - persistedRecallUiRefreshObserver.observe(chatRoot, { childList: true, subtree: true }); + persistedRecallUiRefreshObserver.observe(chatRoot, { + childList: true, + subtree: true, + }); return true; } @@ -1582,10 +1688,16 @@ function schedulePersistedRecallMessageUiRefresh(delayMs = 0) { armPersistedRecallMessageUiObserver(sessionId, runAttempt); attemptIndex += 1; - persistedRecallUiRefreshTimer = setTimeout(runAttempt, retryDelays[attemptIndex]); + persistedRecallUiRefreshTimer = setTimeout( + runAttempt, + retryDelays[attemptIndex], + ); }; - persistedRecallUiRefreshTimer = setTimeout(runAttempt, retryDelays[attemptIndex]); + persistedRecallUiRefreshTimer = setTimeout( + runAttempt, + retryDelays[attemptIndex], + ); } function cleanupPersistedRecallMessageUi() { @@ -1630,7 +1742,6 @@ async function rerunRecallForMessage(messageIndex) { return result; } - function getSendTextareaValue() { return String(document.getElementById("send_textarea")?.value ?? ""); } @@ -2018,13 +2129,17 @@ async function syncIndexedDbMetaToPersistenceState( const manager = ensureBmeChatManager(); if (!manager) return null; const db = await manager.getCurrentDb(normalizedChatId); - const [revision, lastSyncUploadedAt, lastSyncDownloadedAt, lastSyncedRevision] = - await Promise.all([ - db.getRevision(), - db.getMeta("lastSyncUploadedAt", 0), - db.getMeta("lastSyncDownloadedAt", 0), - db.getMeta("lastSyncedRevision", 0), - ]); + const [ + revision, + lastSyncUploadedAt, + lastSyncDownloadedAt, + lastSyncedRevision, + ] = await Promise.all([ + db.getRevision(), + db.getMeta("lastSyncUploadedAt", 0), + db.getMeta("lastSyncDownloadedAt", 0), + db.getMeta("lastSyncedRevision", 0), + ]); const patch = { storagePrimary: "indexeddb", @@ -2171,13 +2286,20 @@ function isIndexedDbSnapshotMeaningful(snapshot = null) { if (Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0) return true; if (Array.isArray(snapshot.edges) && snapshot.edges.length > 0) return true; - if (Array.isArray(snapshot.tombstones) && snapshot.tombstones.length > 0) return true; + if (Array.isArray(snapshot.tombstones) && snapshot.tombstones.length > 0) + return true; const state = snapshot.state || {}; - if (Number.isFinite(Number(state.lastProcessedFloor)) && Number(state.lastProcessedFloor) >= 0) { + if ( + Number.isFinite(Number(state.lastProcessedFloor)) && + Number(state.lastProcessedFloor) >= 0 + ) { return true; } - if (Number.isFinite(Number(state.extractionCount)) && Number(state.extractionCount) > 0) { + if ( + Number.isFinite(Number(state.extractionCount)) && + Number(state.extractionCount) > 0 + ) { return true; } @@ -2188,7 +2310,9 @@ function isIndexedDbSnapshotMeaningful(snapshot = null) { !Array.isArray(runtimeHistoryState) ) { if ( - Number.isFinite(Number(runtimeHistoryState.lastProcessedAssistantFloor)) && + Number.isFinite( + Number(runtimeHistoryState.lastProcessedAssistantFloor), + ) && Number(runtimeHistoryState.lastProcessedAssistantFloor) >= 0 ) { return true; @@ -2234,7 +2358,9 @@ function readLegacyGraphFromChatMetadata(chatId, context = getContext()) { try { const hydratedLegacyGraph = - typeof legacyGraph === "string" ? deserializeGraph(legacyGraph) : legacyGraph; + typeof legacyGraph === "string" + ? deserializeGraph(legacyGraph) + : legacyGraph; return cloneGraphForPersistence( normalizeGraphRuntimeState(hydratedLegacyGraph, normalizedChatId), normalizedChatId, @@ -2259,9 +2385,8 @@ async function maybeMigrateLegacyGraphToIndexedDb( }; } - const inFlightMigration = bmeIndexedDbLegacyMigrationInFlightByChatId.get( - normalizedChatId, - ); + const inFlightMigration = + bmeIndexedDbLegacyMigrationInFlightByChatId.get(normalizedChatId); if (inFlightMigration) { return await inFlightMigration; } @@ -2308,7 +2433,10 @@ async function maybeMigrateLegacyGraphToIndexedDb( }; } - const legacyGraph = readLegacyGraphFromChatMetadata(normalizedChatId, context); + const legacyGraph = readLegacyGraphFromChatMetadata( + normalizedChatId, + context, + ); if (!legacyGraph) { return { migrated: false, @@ -2350,7 +2478,9 @@ async function maybeMigrateLegacyGraphToIndexedDb( source, chatId: normalizedChatId, revision: - postMigrationSnapshot?.meta?.revision || migrationResult?.revision || 0, + postMigrationSnapshot?.meta?.revision || + migrationResult?.revision || + 0, imported: migrationResult.imported, }); @@ -2403,7 +2533,10 @@ async function maybeMigrateLegacyGraphToIndexedDb( } }); - bmeIndexedDbLegacyMigrationInFlightByChatId.set(normalizedChatId, migrationTask); + bmeIndexedDbLegacyMigrationInFlightByChatId.set( + normalizedChatId, + migrationTask, + ); return await migrationTask; } @@ -2422,7 +2555,10 @@ function applyIndexedDbEmptyToRuntime( }; } - currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), normalizedChatId); + currentGraph = normalizeGraphRuntimeState( + createEmptyGraph(), + normalizedChatId, + ); extractionCount = 0; lastExtractedItems = []; lastRecalledItems = []; @@ -2476,7 +2612,6 @@ function applyIndexedDbEmptyToRuntime( }; } - function applyIndexedDbSnapshotToRuntime( chatId, snapshot, @@ -2493,7 +2628,10 @@ function applyIndexedDbSnapshotToRuntime( }; } - const revision = Math.max(1, normalizeIndexedDbRevision(snapshot?.meta?.revision)); + const revision = Math.max( + 1, + normalizeIndexedDbRevision(snapshot?.meta?.revision), + ); const graphFromSnapshot = buildGraphFromSnapshot(snapshot, { chatId: normalizedChatId, }); @@ -2508,11 +2646,7 @@ function applyIndexedDbSnapshotToRuntime( lastExtractedItems = []; updateLastRecalledItems(currentGraph.lastRecallResult || []); lastInjectionContent = ""; - runtimeStatus = createUiStatus( - "待命", - "已从 IndexedDB 加载聊天图谱", - "idle", - ); + runtimeStatus = createUiStatus("待命", "已从 IndexedDB 加载聊天图谱", "idle"); lastExtractionStatus = createUiStatus( "待命", "已从 IndexedDB 加载聊天图谱,等待下一次提取", @@ -2552,7 +2686,9 @@ function applyIndexedDbSnapshotToRuntime( storageMode: "indexeddb", dbReady: true, indexedDbRevision: revision, - metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, + metadataIntegrity: + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, indexedDbLastError: "", lastSyncError: "", dualWriteLastResult: { @@ -2628,7 +2764,8 @@ async function loadGraphFromIndexedDb( if (migrationResult?.migrated) { const migratedRevision = normalizeIndexedDbRevision( - migrationResult?.snapshot?.meta?.revision || migrationResult?.migrationResult?.revision, + migrationResult?.snapshot?.meta?.revision || + migrationResult?.migrationResult?.revision, ); updateGraphPersistenceState({ storagePrimary: "indexeddb", @@ -2649,7 +2786,9 @@ async function loadGraphFromIndexedDb( }); } else if (migrationResult?.reason === "migration-failed") { updateGraphPersistenceState({ - indexedDbLastError: String(migrationResult?.error || "migration-failed"), + indexedDbLastError: String( + migrationResult?.error || "migration-failed", + ), dualWriteLastResult: { action: "migration", source: "chat_metadata", @@ -2664,10 +2803,7 @@ async function loadGraphFromIndexedDb( cacheIndexedDbSnapshot(normalizedChatId, snapshot); if (!isIndexedDbSnapshotMeaningful(snapshot)) { - if ( - applyEmptyState && - getCurrentChatId() === normalizedChatId - ) { + if (applyEmptyState && getCurrentChatId() === normalizedChatId) { return applyIndexedDbEmptyToRuntime(normalizedChatId, { source, attemptIndex, @@ -2682,12 +2818,17 @@ async function loadGraphFromIndexedDb( }; } - const snapshotRevision = normalizeIndexedDbRevision(snapshot?.meta?.revision); + const snapshotRevision = normalizeIndexedDbRevision( + snapshot?.meta?.revision, + ); const shouldAllowOverride = allowOverride || - BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has(graphPersistenceState.loadState) || + BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has( + graphPersistenceState.loadState, + ) || graphPersistenceState.storagePrimary === "indexeddb" || - snapshotRevision >= normalizeIndexedDbRevision(graphPersistenceState.revision); + snapshotRevision >= + normalizeIndexedDbRevision(graphPersistenceState.revision); if (!shouldAllowOverride) { return { @@ -2740,7 +2881,10 @@ async function loadGraphFromIndexedDb( function scheduleIndexedDbGraphProbe(chatId, options = {}) { const normalizedChatId = normalizeChatIdCandidate(chatId); - if (!normalizedChatId || bmeIndexedDbLoadInFlightByChatId.has(normalizedChatId)) { + if ( + !normalizedChatId || + bmeIndexedDbLoadInFlightByChatId.has(normalizedChatId) + ) { return; } @@ -2750,7 +2894,9 @@ function scheduleIndexedDbGraphProbe(chatId, options = {}) { console.warn("[ST-BME] IndexedDB 后台加载失败:", error); }) .finally(() => { - if (bmeIndexedDbLoadInFlightByChatId.get(normalizedChatId) === loadPromise) { + if ( + bmeIndexedDbLoadInFlightByChatId.get(normalizedChatId) === loadPromise + ) { bmeIndexedDbLoadInFlightByChatId.delete(normalizedChatId); } }); @@ -3186,7 +3332,10 @@ function shouldSyncGraphLoadFromLiveContext( if (liveChatId !== stateChatId) return true; - if (!liveChatId && graphPersistenceState.loadState !== GRAPH_LOAD_STATES.NO_CHAT) { + if ( + !liveChatId && + graphPersistenceState.loadState !== GRAPH_LOAD_STATES.NO_CHAT + ) { return true; } @@ -3526,7 +3675,9 @@ function restoreRuntimeUiState(snapshot = {}) { } function getLastProcessedAssistantFloor() { - const historyFloor = Number(currentGraph?.historyState?.lastProcessedAssistantFloor); + const historyFloor = Number( + currentGraph?.historyState?.lastProcessedAssistantFloor, + ); if (Number.isFinite(historyFloor)) { return historyFloor; } @@ -4029,10 +4180,14 @@ function loadGraphFromChat(options = {}) { const cachedSnapshot = readCachedIndexedDbSnapshot(chatId); if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) { - const cachedResult = applyIndexedDbSnapshotToRuntime(chatId, cachedSnapshot, { - source: `${source}:indexeddb-cache`, - attemptIndex, - }); + const cachedResult = applyIndexedDbSnapshotToRuntime( + chatId, + cachedSnapshot, + { + source: `${source}:indexeddb-cache`, + attemptIndex, + }, + ); if (cachedResult?.loaded) { clearPendingGraphLoadRetry(); return cachedResult; @@ -4048,11 +4203,16 @@ function loadGraphFromChat(options = {}) { normalizeGraphRuntimeState(deserializeGraph(savedData), chatId), chatId, ); - const officialRevision = Math.max(1, getGraphPersistedRevision(officialGraph)); + const officialRevision = Math.max( + 1, + getGraphPersistedRevision(officialGraph), + ); clearPendingGraphLoadRetry(); currentGraph = officialGraph; - extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) + extractionCount = Number.isFinite( + currentGraph?.historyState?.extractionCount, + ) ? currentGraph.historyState.extractionCount : 0; lastExtractedItems = []; @@ -4128,7 +4288,10 @@ function loadGraphFromChat(options = {}) { attemptIndex, }; } catch (error) { - console.warn("[ST-BME] 兼容 metadata 图谱读取失败,将回退 IndexedDB:", error); + console.warn( + "[ST-BME] 兼容 metadata 图谱读取失败,将回退 IndexedDB:", + error, + ); } } @@ -4190,7 +4353,8 @@ async function saveGraphToIndexedDb( } const db = await manager.getCurrentDb(normalizedChatId); const baseSnapshot = - readCachedIndexedDbSnapshot(normalizedChatId) || (await db.exportSnapshot()); + readCachedIndexedDbSnapshot(normalizedChatId) || + (await db.exportSnapshot()); const snapshot = buildSnapshotFromGraph(graph, { chatId: normalizedChatId, revision, @@ -4209,18 +4373,26 @@ async function saveGraphToIndexedDb( }); await db.markSyncDirty(reason); - snapshot.meta.revision = normalizeIndexedDbRevision(importResult?.revision, revision); + snapshot.meta.revision = normalizeIndexedDbRevision( + importResult?.revision, + revision, + ); cacheIndexedDbSnapshot(normalizedChatId, snapshot); - scheduleUpload(normalizedChatId, buildBmeSyncRuntimeOptions({ - trigger: `graph-mutation:${String(reason || "graph-save")}`, - })); + scheduleUpload( + normalizedChatId, + buildBmeSyncRuntimeOptions({ + trigger: `graph-mutation:${String(reason || "graph-save")}`, + }), + ); updateGraphPersistenceState({ storagePrimary: "indexeddb", storageMode: "indexeddb", dbReady: true, indexedDbRevision: snapshot.meta.revision, - metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, + metadataIntegrity: + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, indexedDbLastError: "", lastSyncError: "", dualWriteLastResult: { @@ -4284,14 +4456,18 @@ function queueGraphPersistToIndexedDb( ); const previousWritePromise = - bmeIndexedDbWriteInFlightByChatId.get(normalizedChatId) || Promise.resolve(); + bmeIndexedDbWriteInFlightByChatId.get(normalizedChatId) || + Promise.resolve(); const nextWritePromise = previousWritePromise .catch(() => null) .then(async () => { const currentLatestRevision = normalizeIndexedDbRevision( bmeIndexedDbLatestQueuedRevisionByChatId.get(normalizedChatId), ); - if (normalizedRevision > 0 && normalizedRevision < currentLatestRevision) { + if ( + normalizedRevision > 0 && + normalizedRevision < currentLatestRevision + ) { return { saved: false, skipped: true, @@ -4305,7 +4481,10 @@ function queueGraphPersistToIndexedDb( }); }) .finally(() => { - if (bmeIndexedDbWriteInFlightByChatId.get(normalizedChatId) === nextWritePromise) { + if ( + bmeIndexedDbWriteInFlightByChatId.get(normalizedChatId) === + nextWritePromise + ) { bmeIndexedDbWriteInFlightByChatId.delete(normalizedChatId); } }); @@ -4358,7 +4537,8 @@ function saveGraphToChat(options = {}) { }); } - const metadataFallbackEnabled = Boolean(persistMetadata) || !ensureBmeChatManager(); + const metadataFallbackEnabled = + Boolean(persistMetadata) || !ensureBmeChatManager(); if (!markMutation) { const hasMeaningfulGraphData = !isGraphEffectivelyEmpty(currentGraph); @@ -4385,7 +4565,8 @@ function saveGraphToChat(options = {}) { storagePrimary: "indexeddb", storageMode: "indexeddb", dbReady: - graphPersistenceState.dbReady ?? isGraphLoadStateDbReady(graphPersistenceState.loadState), + graphPersistenceState.dbReady ?? + isGraphLoadStateDbReady(graphPersistenceState.loadState), lastPersistReason: String(reason || "graph-save"), lastPersistMode: saveMode, pendingPersist: false, @@ -4581,10 +4762,15 @@ function getLastNonSystemChatMessage(chat) { } function buildRecallRecentMessages(chat, limit, syntheticUserMessage = "") { - return buildRecallRecentMessagesController(chat, limit, syntheticUserMessage, { - formatRecallContextLine, - normalizeRecallInputText, - }); + return buildRecallRecentMessagesController( + chat, + limit, + syntheticUserMessage, + { + formatRecallContextLine, + normalizeRecallInputText, + }, + ); } function getRecallUserMessageSourceLabel(source) { @@ -4592,16 +4778,21 @@ function getRecallUserMessageSourceLabel(source) { } function resolveRecallInput(chat, recentContextMessageLimit, override = null) { - return resolveRecallInputController(chat, recentContextMessageLimit, override, { - buildRecallRecentMessages, - getLastNonSystemChatMessage, - getLatestUserChatMessage, - getRecallUserMessageSourceLabel, - isFreshRecallInputRecord, - lastRecallSentUserMessage, - normalizeRecallInputText, - pendingRecallSendIntent, - }); + return resolveRecallInputController( + chat, + recentContextMessageLimit, + override, + { + buildRecallRecentMessages, + getLastNonSystemChatMessage, + getLatestUserChatMessage, + getRecallUserMessageSourceLabel, + isFreshRecallInputRecord, + lastRecallSentUserMessage, + normalizeRecallInputText, + pendingRecallSendIntent, + }, + ); } function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { @@ -4647,7 +4838,9 @@ function buildNormalGenerationRecallInput(chat) { const tailUserText = lastNonSystemMessage?.is_user ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") : ""; - const targetUserMessageIndex = resolveGenerationTargetUserMessageIndex(chat, { generationType: "normal" }); + const targetUserMessageIndex = resolveGenerationTargetUserMessageIndex(chat, { + generationType: "normal", + }); const textareaText = normalizeRecallInputText( pendingRecallSendIntent.text || getSendTextareaValue(), ); @@ -4680,7 +4873,9 @@ function buildHistoryGenerationRecallInput(chat) { overrideSource: Number.isFinite(targetUserMessageIndex) ? "chat-last-user" : "chat-last-user-missing", - overrideSourceLabel: Number.isFinite(targetUserMessageIndex) ? "历史最后用户楼层" : "历史用户楼层缺失", + overrideSourceLabel: Number.isFinite(targetUserMessageIndex) + ? "历史最后用户楼层" + : "历史用户楼层缺失", includeSyntheticUserMessage: false, }; } @@ -4690,9 +4885,13 @@ function buildPreGenerationRecallKey(type, options = {}) { ? options.targetUserMessageIndex : "none"; const seedText = - options.overrideUserMessage || options.userMessage || `@target:${targetUserMessageIndex}`; + options.overrideUserMessage || + options.userMessage || + `@target:${targetUserMessageIndex}`; - const normalizedChatId = normalizeChatIdCandidate(options.chatId || getCurrentChatId()); + const normalizedChatId = normalizeChatIdCandidate( + options.chatId || getCurrentChatId(), + ); return [ normalizedChatId, @@ -4731,7 +4930,10 @@ function isGenerationRecallTransactionWithinBridgeWindow( now = Date.now(), ) { if (!transaction) return false; - return now - Number(transaction.updatedAt || transaction.createdAt || 0) <= GENERATION_RECALL_HOOK_BRIDGE_MS; + return ( + now - Number(transaction.updatedAt || transaction.createdAt || 0) <= + GENERATION_RECALL_HOOK_BRIDGE_MS + ); } function normalizeGenerationRecallTransactionType(generationType = "normal") { @@ -4746,9 +4948,10 @@ function freezeGenerationRecallOptionsForTransaction( ) { if (!Array.isArray(chat)) return null; - const optionGenerationType = String( - recallOptions?.generationType || generationType || "normal", - ).trim() || "normal"; + const optionGenerationType = + String( + recallOptions?.generationType || generationType || "normal", + ).trim() || "normal"; const normalizedGenerationType = optionGenerationType; const overrideUserMessage = normalizeRecallInputText( @@ -4756,8 +4959,11 @@ function freezeGenerationRecallOptionsForTransaction( ); const source = - String(recallOptions?.overrideSource || recallOptions?.source || "").trim() || - (normalizeGenerationRecallTransactionType(normalizedGenerationType) === "normal" + String( + recallOptions?.overrideSource || recallOptions?.source || "", + ).trim() || + (normalizeGenerationRecallTransactionType(normalizedGenerationType) === + "normal" ? "chat-tail-user" : "chat-last-user"); const sourceLabel = @@ -4767,7 +4973,9 @@ function freezeGenerationRecallOptionsForTransaction( getRecallUserMessageSourceLabel(source), ).trim() || getRecallUserMessageSourceLabel(source); - let targetUserMessageIndex = Number.isFinite(recallOptions?.targetUserMessageIndex) + let targetUserMessageIndex = Number.isFinite( + recallOptions?.targetUserMessageIndex, + ) ? Math.floor(Number(recallOptions.targetUserMessageIndex)) : resolveGenerationTargetUserMessageIndex(chat, { generationType: normalizedGenerationType, @@ -4775,7 +4983,8 @@ function freezeGenerationRecallOptionsForTransaction( if (!Number.isFinite(targetUserMessageIndex)) { if ( - normalizeGenerationRecallTransactionType(normalizedGenerationType) === "normal" && + normalizeGenerationRecallTransactionType(normalizedGenerationType) === + "normal" && overrideUserMessage ) { return { @@ -4846,7 +5055,8 @@ function beginGenerationRecallTransaction({ ); const now = Date.now(); - const existingTransaction = generationRecallTransactions.get(transactionId) || null; + const existingTransaction = + generationRecallTransactions.get(transactionId) || null; if ( existingTransaction && isGenerationRecallTransactionWithinBridgeWindow(existingTransaction, now) && @@ -4880,9 +5090,15 @@ function findRecentGenerationRecallTransactionForChat( let latestTransaction = null; for (const transaction of generationRecallTransactions.values()) { - if (!transaction || String(transaction.chatId || "") !== normalizedChatId) continue; - if (!isGenerationRecallTransactionWithinBridgeWindow(transaction, now)) continue; - if (!latestTransaction || Number(transaction.updatedAt || 0) > Number(latestTransaction.updatedAt || 0)) { + if (!transaction || String(transaction.chatId || "") !== normalizedChatId) + continue; + if (!isGenerationRecallTransactionWithinBridgeWindow(transaction, now)) + continue; + if ( + !latestTransaction || + Number(transaction.updatedAt || 0) > + Number(latestTransaction.updatedAt || 0) + ) { latestTransaction = transaction; } } @@ -5076,7 +5292,10 @@ function createGenerationRecallContext({ }; } - if (!transaction.frozenRecallOptions || typeof transaction.frozenRecallOptions !== "object") { + if ( + !transaction.frozenRecallOptions || + typeof transaction.frozenRecallOptions !== "object" + ) { transaction.frozenRecallOptions = { ...frozenRecallOptions, }; @@ -5093,7 +5312,8 @@ function createGenerationRecallContext({ const boundRecallOptions = { ...(transaction.frozenRecallOptions || frozenRecallOptions), recallKey: transaction.recallKey, - generationType: transaction.frozenRecallOptions?.generationType || generationType, + generationType: + transaction.frozenRecallOptions?.generationType || generationType, }; const recallKey = String(transaction.recallKey || fallbackRecallKey || ""); @@ -5597,12 +5817,17 @@ async function executeExtractionBatch({ ); } -async function replayExtractionFromHistory(chat, settings, signal = undefined, expectedChatId = undefined) { +async function replayExtractionFromHistory( + chat, + settings, + signal = undefined, + expectedChatId = undefined, +) { let replayedBatches = 0; while (true) { throwIfAborted(signal, "历史恢复已终止"); - assertRecoveryChatStillActive(expectedChatId, 'replay-loop'); + assertRecoveryChatStillActive(expectedChatId, "replay-loop"); const pendingAssistantTurns = getAssistantTurns(chat).filter( (index) => index > getLastProcessedAssistantFloor(), ); @@ -5712,7 +5937,7 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { isBackendVectorConfig(config) && recoveryPlan.backendDeleteHashes.length > 0 ) { - assertRecoveryChatStillActive(chatId, 'reroll-pre-vector'); + assertRecoveryChatStillActive(chatId, "reroll-pre-vector"); await deleteBackendVectorHashesForRecovery( currentGraph.vectorIndexState.collectionId, config, @@ -5720,7 +5945,7 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { ); } - assertRecoveryChatStillActive(chatId, 'reroll-pre-prepare'); + assertRecoveryChatStillActive(chatId, "reroll-pre-prepare"); await prepareVectorStateForReplay(false, undefined, { skipBackendPurge: isBackendVectorConfig(config), }); @@ -5847,7 +6072,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { isBackendVectorConfig(config) && recoveryPlan.backendDeleteHashes.length > 0 ) { - assertRecoveryChatStillActive(chatId, 'pre-backend-delete'); + assertRecoveryChatStillActive(chatId, "pre-backend-delete"); await deleteBackendVectorHashesForRecovery( currentGraph.vectorIndexState.collectionId, config, @@ -5875,7 +6100,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { await prepareVectorStateForReplay(true, historySignal); } - assertRecoveryChatStillActive(chatId, 'pre-replay'); + assertRecoveryChatStillActive(chatId, "pre-replay"); replayedBatches = await replayExtractionFromHistory( chat, settings, @@ -5941,7 +6166,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); extractionCount = 0; await prepareVectorStateForReplay(true, historySignal); - assertRecoveryChatStillActive(chatId, 'pre-fallback-replay'); + assertRecoveryChatStillActive(chatId, "pre-fallback-replay"); replayedBatches = await replayExtractionFromHistory( chat, settings, @@ -6145,6 +6370,7 @@ async function runRecall(options = {}) { isAbortError, isGraphMetadataWriteAllowed, isGraphReadable, + isGraphReadableForRecall, nextRecallRunSequence: () => ++recallRunSequence, recoverHistoryIfNeeded, refreshPanelLiveState, @@ -6475,34 +6701,37 @@ async function onFetchEmbeddingModels(mode = null) { } async function onManualExtract(options = {}) { - return await onManualExtractController({ - beginStageAbortController, - clampInt, - console, - createEmptyGraph, - ensureGraphMutationReady, - executeExtractionBatch, - finishStageAbortController, - getAssistantTurns, - getContext, - getCurrentChatId, - getCurrentGraph: () => currentGraph, - getIsExtracting: () => isExtracting, - getLastProcessedAssistantFloor, - getSettings, - isAbortError, - normalizeGraphRuntimeState, - recoverHistoryIfNeeded, - refreshPanelLiveState, - setCurrentGraph: (graph) => { - currentGraph = graph; + return await onManualExtractController( + { + beginStageAbortController, + clampInt, + console, + createEmptyGraph, + ensureGraphMutationReady, + executeExtractionBatch, + finishStageAbortController, + getAssistantTurns, + getContext, + getCurrentChatId, + getCurrentGraph: () => currentGraph, + getIsExtracting: () => isExtracting, + getLastProcessedAssistantFloor, + getSettings, + isAbortError, + normalizeGraphRuntimeState, + recoverHistoryIfNeeded, + refreshPanelLiveState, + setCurrentGraph: (graph) => { + currentGraph = graph; + }, + setIsExtracting: (value) => { + isExtracting = value; + }, + setLastExtractionStatus, + toastr, }, - setIsExtracting: (value) => { - isExtracting = value; - }, - setLastExtractionStatus, - toastr, - }, options); + options, + ); } async function onReroll({ fromFloor } = {}) { @@ -6592,8 +6821,7 @@ async function onReembedDirect() { return await onReembedDirectController({ getEmbeddingConfig, isDirectVectorConfig, - onRebuildVectorIndex: async () => - await onRebuildVectorIndex(), + onRebuildVectorIndex: async () => await onRebuildVectorIndex(), toastr, }); } @@ -6610,7 +6838,6 @@ async function onReembedDirect() { installSendIntentHooks(); autoSyncOnVisibility(buildBmeSyncRuntimeOptions()); - // 注册事件钩子 registerCoreEventHooksController({ eventSource, @@ -6634,7 +6861,10 @@ async function onReembedDirect() { scheduleBmeIndexedDbTask(async () => { const syncResult = await syncBmeChatManagerWithCurrentChat("initial-load"); if (!syncResult?.chatId) { - syncGraphLoadFromLiveContext({ source: "initial-load:no-chat", force: true }); + syncGraphLoadFromLiveContext({ + source: "initial-load:no-chat", + force: true, + }); return; } await runBmeAutoSyncForChat("initial-load", syncResult.chatId); @@ -6675,7 +6905,8 @@ async function onReembedDirect() { document, getGraph: () => currentGraph, getGraphPersistenceState: () => getGraphPersistenceLiveState(), - getLastBatchStatus: () => currentGraph?.historyState?.lastBatchStatus || null, + getLastBatchStatus: () => + currentGraph?.historyState?.lastBatchStatus || null, getLastExtract: () => lastExtractedItems, getLastExtractionStatus: () => lastExtractionStatus, getLastInjection: () => lastInjectionContent, diff --git a/recall-controller.js b/recall-controller.js index b969840..85d984b 100644 --- a/recall-controller.js +++ b/recall-controller.js @@ -19,9 +19,8 @@ export function buildRecallRecentMessagesController( recentMessages.unshift(runtime.formatRecallContextLine(message)); } - const normalizedSynthetic = runtime.normalizeRecallInputText( - syntheticUserMessage, - ); + const normalizedSynthetic = + runtime.normalizeRecallInputText(syntheticUserMessage); if (!normalizedSynthetic) return recentMessages; const syntheticLine = `[user]: ${normalizedSynthetic}`; @@ -63,8 +62,12 @@ export function resolveRecallInputController( return { userMessage: overrideText, generationType: String(override?.generationType || "normal"), - targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex) ? override.targetUserMessageIndex : null, - source: String(override?.source || override?.overrideSource || "override"), + targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex) + ? override.targetUserMessageIndex + : null, + source: String( + override?.source || override?.overrideSource || "override", + ), sourceLabel: String( override?.sourceLabel || override?.overrideSourceLabel || "发送前拦截", ), @@ -155,7 +158,12 @@ export function applyRecallInjectionController( runtime.console.log( `[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`, ); - runtime.persistRecallInjectionRecord?.({ recallInput, result, injectionText, tokenEstimate: tokens }); + runtime.persistRecallInjectionRecord?.({ + recallInput, + result, + injectionText, + tokenEstimate: tokens, + }); } const injectionTransport = runtime.applyModuleInjectionPrompt( @@ -196,7 +204,9 @@ export function applyRecallInjectionController( recallInput.sourceLabel, `ctx ${recentMessages.length}`, `vector ${retrievalMeta.vectorHits ?? 0}`, - retrievalMeta.vectorMergedHits ? `merged ${retrievalMeta.vectorMergedHits}` : "", + retrievalMeta.vectorMergedHits + ? `merged ${retrievalMeta.vectorMergedHits}` + : "", `diffusion ${retrievalMeta.diffusionHits ?? 0}`, retrievalMeta.candidatePoolAfterDpp ? `dpp ${retrievalMeta.candidatePoolAfterDpp}` @@ -259,7 +269,11 @@ export async function runRecallController(runtime, options = {}) { reason: "召回功能未启用", }); } - if (!runtime.isGraphReadable()) { + const isReadableForRecall = + typeof runtime.isGraphReadableForRecall === "function" + ? runtime.isGraphReadableForRecall() + : runtime.isGraphReadable(); + if (!isReadableForRecall) { const reason = runtime.getGraphMutationBlockReason("召回"); runtime.setLastRecallStatus("等待图谱加载", reason, "warning", { syncRuntime: true, @@ -300,7 +314,8 @@ export async function runRecallController(runtime, options = {}) { "abort", () => recallController.abort( - options.signal.reason || runtime.createAbortError("宿主已终止生成"), + options.signal.reason || + runtime.createAbortError("宿主已终止生成"), ), { once: true }, ); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 69e99c3..19fbbfb 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -4,10 +4,31 @@ import { createRequire, registerHooks } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; +import { pruneProcessedMessageHashesFromFloor } from "../chat-history.js"; +import { + onBeforeCombinePromptsController, + onGenerationAfterCommandsController, +} from "../event-binding.js"; +import { onRerollController } from "../extraction-controller.js"; +import { + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, + MODULE_NAME, +} from "../graph-persistence.js"; +import { + buildPersistedRecallRecord, + bumpPersistedRecallGenerationCount, + markPersistedRecallManualEdit, + readPersistedRecallFromUserMessage, + removePersistedRecallFromUserMessage, + resolveFinalRecallInjectionSource, + resolveGenerationTargetUserMessageIndex, + writePersistedRecallToUserMessage, +} from "../recall-persistence.js"; import { BATCH_STAGE_ORDER, BATCH_STAGE_SEVERITY, - clampFloat, clampInt, createBatchStageStatus, createBatchStatusSkeleton, @@ -16,7 +37,6 @@ import { createRecallRunResult, createUiStatus, finalizeBatchStatus, - formatRecallContextLine, getGenerationRecallHookStateFromResult, getRecallHookLabel, getStageNoticeDuration, @@ -30,44 +50,6 @@ import { setBatchStageOutcome, shouldRunRecallForTransaction, } from "../ui-status.js"; -import { - cloneRuntimeDebugValue, - GRAPH_LOAD_STATES, - GRAPH_METADATA_KEY, - GRAPH_PERSISTENCE_META_KEY, - GRAPH_PERSISTENCE_SESSION_ID, - MODULE_NAME, - readGraphShadowSnapshot, - stampGraphPersistenceMeta, - writeChatMetadataPatch, - writeGraphShadowSnapshot, -} from "../graph-persistence.js"; -import { - buildExtractionMessages, - clampRecoveryStartFloor, - getAssistantTurns, - getChatIndexForAssistantSeq, - getChatIndexForPlayableSeq, - getMinExtractableAssistantFloor, - isAssistantChatMessage, - pruneProcessedMessageHashesFromFloor, - rollbackAffectedJournals, -} from "../chat-history.js"; -import { - onBeforeCombinePromptsController, - onGenerationAfterCommandsController, -} from "../event-binding.js"; -import { onRerollController } from "../extraction-controller.js"; -import { - buildPersistedRecallRecord, - readPersistedRecallFromUserMessage, - removePersistedRecallFromUserMessage, - resolveFinalRecallInjectionSource, - resolveGenerationTargetUserMessageIndex, - writePersistedRecallToUserMessage, - bumpPersistedRecallGenerationCount, - markPersistedRecallManualEdit, -} from "../recall-persistence.js"; const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0)); const extensionsShimSource = [ @@ -332,7 +314,9 @@ function createGenerationRecallHarness() { onBeforeCombinePromptsController, onGenerationAfterCommandsController, readPersistedRecallFromUserMessage: () => null, - resolveFinalRecallInjectionSource: ({ freshRecallResult = null } = {}) => ({ + resolveFinalRecallInjectionSource: ({ + freshRecallResult = null, + } = {}) => ({ source: freshRecallResult?.didRecall ? "fresh" : "none", injectionText: String(freshRecallResult?.injectionText || ""), record: null, @@ -342,11 +326,16 @@ function createGenerationRecallHarness() { getSettings: () => ({}), triggerChatMetadataSave: () => "debounced", refreshPanelLiveState: () => {}, - resolveGenerationTargetUserMessageIndex: (chat = [], { generationType } = {}) => { + resolveGenerationTargetUserMessageIndex: ( + chat = [], + { generationType } = {}, + ) => { const normalized = String(generationType || "normal"); if (!Array.isArray(chat) || chat.length === 0) return null; - if (normalized === "normal") return chat[chat.length - 1]?.is_user ? chat.length - 1 : null; - for (let index = chat.length - 1; index >= 0; index--) if (chat[index]?.is_user) return index; + if (normalized === "normal") + return chat[chat.length - 1]?.is_user ? chat.length - 1 : null; + for (let index = chat.length - 1; index >= 0; index--) + if (chat[index]?.is_user) return index; return null; }, }; @@ -373,8 +362,12 @@ function createGenerationRecallHarness() { function createRerollHarness() { return fs.readFile(indexPath, "utf8").then((source) => { - const rollbackStart = source.indexOf("async function rollbackGraphForReroll("); - const rollbackEnd = source.indexOf("async function recoverHistoryIfNeeded("); + const rollbackStart = source.indexOf( + "async function rollbackGraphForReroll(", + ); + const rollbackEnd = source.indexOf( + "async function recoverHistoryIfNeeded(", + ); const rerollStart = source.indexOf("async function onReroll("); const rerollEnd = source.indexOf("async function onManualSleep()"); if ( @@ -387,7 +380,10 @@ function createRerollHarness() { ) { throw new Error("无法从 index.js 提取 reroll 定义"); } - const snippet = [source.slice(rollbackStart, rollbackEnd), source.slice(rerollStart, rerollEnd)] + const snippet = [ + source.slice(rollbackStart, rollbackEnd), + source.slice(rerollStart, rerollEnd), + ] .join("\n") .replace(/^export\s+/gm, ""); const context = { @@ -559,7 +555,6 @@ function pushTestOverrides(patch = {}) { }; } - class FakeClassList { constructor(owner) { this.owner = owner; @@ -567,7 +562,11 @@ class FakeClassList { } setFromString(value = "") { - this.tokens = new Set(String(value || "").split(/\s+/).filter(Boolean)); + this.tokens = new Set( + String(value || "") + .split(/\s+/) + .filter(Boolean), + ); } add(...tokens) { @@ -686,7 +685,11 @@ class FakeElement { child.parentElement = this; child.ownerDocument = this.ownerDocument; this.children.push(child); - this.ownerDocument?._notifyMutation({ type: "childList", target: this, addedNodes: [child] }); + this.ownerDocument?._notifyMutation({ + type: "childList", + target: this, + addedNodes: [child], + }); return child; } @@ -695,7 +698,11 @@ class FakeElement { if (index >= 0) { this.children.splice(index, 1); child.parentElement = null; - this.ownerDocument?._notifyMutation({ type: "childList", target: this, removedNodes: [child] }); + this.ownerDocument?._notifyMutation({ + type: "childList", + target: this, + removedNodes: [child], + }); } return child; } @@ -785,9 +792,12 @@ class FakeDocument { } const attrMatches = [...selector.matchAll(/\[([^=\]]+)="([^\]]*)"\]/g)]; const attrless = selector.replace(/\[[^\]]+\]/g, ""); - const classMatches = [...attrless.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]); + const classMatches = [...attrless.matchAll(/\.([A-Za-z0-9_-]+)/g)].map( + (m) => m[1], + ); const tagMatch = attrless.match(/^[A-Za-z][A-Za-z0-9_-]*/); - if (tagMatch && node.tagName.toLowerCase() !== tagMatch[0].toLowerCase()) return false; + if (tagMatch && node.tagName.toLowerCase() !== tagMatch[0].toLowerCase()) + return false; for (const className of classMatches) { if (!node.classList.contains(className)) return false; } @@ -813,9 +823,15 @@ class FakeDocument { } _querySelectorAll(selector, scopeRoot) { - const segments = String(selector || "").trim().split(/\s+/).filter(Boolean); + const segments = String(selector || "") + .trim() + .split(/\s+/) + .filter(Boolean); const nodes = this._flatten(scopeRoot); - return nodes.filter((node) => node !== scopeRoot && this._matchesSelectorChain(node, segments)); + return nodes.filter( + (node) => + node !== scopeRoot && this._matchesSelectorChain(node, segments), + ); } _registerObserver(observer) { @@ -871,7 +887,11 @@ function createDomHarness(chat) { return { document, chatRoot, MutationObserver: observerClass, chat }; } -function createMessageElement(document, messageIndex, { stableId = true, withMesBlock = true, isUser = true } = {}) { +function createMessageElement( + document, + messageIndex, + { stableId = true, withMesBlock = true, isUser = true } = {}, +) { const mes = document.createElement("div"); mes.classList.add("mes"); if (stableId) mes.setAttribute("mesid", String(messageIndex)); @@ -896,7 +916,10 @@ function appendLegacyBadge(document, messageElement) { return badge; } -async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } } = {}) { +async function createRecallUiHarness({ + chat, + graph = { nodes: [], edges: [] }, +} = {}) { const harness = createDomHarness(chat); const previousDocument = globalThis.document; globalThis.document = harness.document; @@ -935,7 +958,11 @@ async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } } getContext: () => ({ chat }), getSettings: () => ({ panelTheme: "crimson" }), triggerChatMetadataSave: () => "debounced", - estimateTokens: (text = "") => String(text || "").trim().split(/\s+/).filter(Boolean).length || 1, + estimateTokens: (text = "") => + String(text || "") + .trim() + .split(/\s+/) + .filter(Boolean).length || 1, toastr: { success() {}, warning() {}, @@ -975,18 +1002,38 @@ async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } } async function testRecallCardMountsOnStandardUserMessageDom() { const chat = [ - { is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } }, + { + is_user: true, + mes: "user-0", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "recall-0", + selectedNodeIds: ["n1"], + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, ]; const harness = await createRecallUiHarness({ chat }); - const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true }); + const messageElement = createMessageElement(harness.document, 0, { + stableId: true, + withMesBlock: true, + isUser: true, + }); harness.chatRoot.appendChild(messageElement); try { const summary = harness.api.refreshPersistedRecallMessageUi(); assert.equal(summary.status, "rendered"); assert.equal(summary.renderedCount, 1); - assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1); - assert.equal(harness.chatRoot.querySelectorAll(".mes_block .bme-recall-card").length, 1); + assert.equal( + harness.chatRoot.querySelectorAll(".bme-recall-card").length, + 1, + ); + assert.equal( + harness.chatRoot.querySelectorAll(".mes_block .bme-recall-card").length, + 1, + ); } finally { harness.restoreGlobals(); } @@ -994,17 +1041,34 @@ async function testRecallCardMountsOnStandardUserMessageDom() { async function testRecallCardSkipsMountWithoutStableMessageIndex() { const chat = [ - { is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } }, + { + is_user: true, + mes: "user-0", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "recall-0", + selectedNodeIds: ["n1"], + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, ]; const harness = await createRecallUiHarness({ chat }); - const messageElement = createMessageElement(harness.document, 0, { stableId: false, withMesBlock: true, isUser: true }); + const messageElement = createMessageElement(harness.document, 0, { + stableId: false, + withMesBlock: true, + isUser: true, + }); harness.chatRoot.appendChild(messageElement); try { const summary = harness.api.refreshPersistedRecallMessageUi(); assert.equal(summary.status, "waiting_dom"); assert.deepEqual(Array.from(summary.waitingMessageIndices), [0]); - assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 0); + assert.equal( + harness.chatRoot.querySelectorAll(".bme-recall-card").length, + 0, + ); } finally { harness.restoreGlobals(); } @@ -1012,7 +1076,17 @@ async function testRecallCardSkipsMountWithoutStableMessageIndex() { async function testRecallCardDelayedDomInsertionEventuallyRenders() { const chat = [ - { is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } }, + { + is_user: true, + mes: "user-0", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "recall-0", + selectedNodeIds: ["n1"], + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, ]; const harness = await createRecallUiHarness({ chat }); try { @@ -1025,15 +1099,26 @@ async function testRecallCardDelayedDomInsertionEventuallyRenders() { harness.api.schedulePersistedRecallMessageUiRefresh(); await waitForTick(); - const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true }); + const messageElement = createMessageElement(harness.document, 0, { + stableId: true, + withMesBlock: true, + isUser: true, + }); harness.chatRoot.appendChild(messageElement); await waitForTick(); await waitForTick(); await new Promise((resolve) => setTimeout(resolve, 35)); await waitForTick(); - assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1); - assert.equal(updateCalls, 0, "observer 先触发后不应再被旧 timeout 重复刷新"); + assert.equal( + harness.chatRoot.querySelectorAll(".bme-recall-card").length, + 1, + ); + assert.equal( + updateCalls, + 0, + "observer 先触发后不应再被旧 timeout 重复刷新", + ); } finally { harness.restoreGlobals(); } @@ -1041,17 +1126,34 @@ async function testRecallCardDelayedDomInsertionEventuallyRenders() { async function testRecallCardDoesNotMountOnNonUserFloor() { const chat = [ - { is_user: false, mes: "assistant-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } }, + { + is_user: false, + mes: "assistant-0", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "recall-0", + selectedNodeIds: ["n1"], + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, ]; const harness = await createRecallUiHarness({ chat }); - const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: false }); + const messageElement = createMessageElement(harness.document, 0, { + stableId: true, + withMesBlock: true, + isUser: false, + }); harness.chatRoot.appendChild(messageElement); try { const summary = harness.api.refreshPersistedRecallMessageUi(); assert.equal(summary.status, "skipped_non_user"); assert.deepEqual(Array.from(summary.skippedNonUserIndices), [0]); - assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 0); + assert.equal( + harness.chatRoot.querySelectorAll(".bme-recall-card").length, + 0, + ); } finally { harness.restoreGlobals(); } @@ -1059,10 +1161,24 @@ async function testRecallCardDoesNotMountOnNonUserFloor() { async function testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates() { const chat = [ - { is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1", "n2"], nowIso: "2026-01-01T00:00:00.000Z" }) } }, + { + is_user: true, + mes: "user-0", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "recall-0", + selectedNodeIds: ["n1", "n2"], + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, ]; const harness = await createRecallUiHarness({ chat }); - const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true }); + const messageElement = createMessageElement(harness.document, 0, { + stableId: true, + withMesBlock: true, + isUser: true, + }); const staleCard = harness.document.createElement("div"); staleCard.classList.add("bme-recall-card"); staleCard.dataset.messageIndex = "999"; @@ -1077,8 +1193,14 @@ async function testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates() { harness.api.refreshPersistedRecallMessageUi(); harness.api.refreshPersistedRecallMessageUi(); - assert.equal(harness.chatRoot.querySelectorAll(".st-bme-recall-badge").length, 0); - assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1); + assert.equal( + harness.chatRoot.querySelectorAll(".st-bme-recall-badge").length, + 0, + ); + assert.equal( + harness.chatRoot.querySelectorAll(".bme-recall-card").length, + 1, + ); assert.equal(staleCard.dataset.destroyed, "1"); } finally { harness.restoreGlobals(); @@ -1136,14 +1258,18 @@ async function testRecallCardExpandedContentRerendersAfterRecordUpdate() { card = harness.chatRoot.querySelector(".bme-recall-card"); assert.equal(card.dataset.updatedAt, "2026-01-01T00:01:00.000Z"); - assert.equal(card.querySelector(".bme-recall-count-badge")?.textContent, "记忆 2"); + assert.equal( + card.querySelector(".bme-recall-count-badge")?.textContent, + "记忆 2", + ); assert.equal( card.querySelector(".bme-recall-token-hint")?.textContent, "~13 tokens", ); const metaElements = card.querySelectorAll(".bme-recall-meta"); const latestMeta = metaElements[metaElements.length - 1] || null; - const latestTag = card.querySelectorAll(".bme-recall-meta-tag").pop() || null; + const latestTag = + card.querySelectorAll(".bme-recall-meta-tag").pop() || null; assert.ok(latestMeta?.textContent.includes("来源: after")); assert.equal(latestTag?.textContent, "✍ 手动编辑"); assert.notEqual(card.dataset.expandedRenderSignature, signatureBefore); @@ -1154,23 +1280,43 @@ async function testRecallCardExpandedContentRerendersAfterRecordUpdate() { async function testRecallCardUserTextRefreshesWithoutCardRecreate() { const chat = [ - { is_user: true, mes: "before-user", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } }, + { + is_user: true, + mes: "before-user", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "recall-0", + selectedNodeIds: ["n1"], + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, ]; const harness = await createRecallUiHarness({ chat }); - const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true }); + const messageElement = createMessageElement(harness.document, 0, { + stableId: true, + withMesBlock: true, + isUser: true, + }); harness.chatRoot.appendChild(messageElement); try { harness.api.refreshPersistedRecallMessageUi(); const firstCard = harness.chatRoot.querySelector(".bme-recall-card"); - assert.equal(firstCard.querySelector(".bme-recall-user-text")?.textContent, "before-user"); + assert.equal( + firstCard.querySelector(".bme-recall-user-text")?.textContent, + "before-user", + ); chat[0].mes = "after-user"; harness.api.refreshPersistedRecallMessageUi(); const secondCard = harness.chatRoot.querySelector(".bme-recall-card"); assert.equal(secondCard, firstCard); - assert.equal(secondCard.querySelector(".bme-recall-user-text")?.textContent, "after-user"); + assert.equal( + secondCard.querySelector(".bme-recall-user-text")?.textContent, + "after-user", + ); } finally { harness.restoreGlobals(); } @@ -2055,8 +2201,15 @@ async function testGenerationRecallHistoryModesUseSameBindingAcrossHooks() { await harness.result.onGenerationAfterCommands(generationType, {}, false); await harness.result.onBeforeCombinePrompts(); - assert.equal(harness.runRecallCalls.length, 1, `${generationType} 应只执行一次召回`); - assert.equal(harness.runRecallCalls[0].hookName, "GENERATION_AFTER_COMMANDS"); + assert.equal( + harness.runRecallCalls.length, + 1, + `${generationType} 应只执行一次召回`, + ); + assert.equal( + harness.runRecallCalls[0].hookName, + "GENERATION_AFTER_COMMANDS", + ); assert.equal(harness.runRecallCalls[0].targetUserMessageIndex, 0); assert.equal(harness.runRecallCalls[0].overrideUserMessage, userMessage); } @@ -2138,7 +2291,10 @@ async function testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration( await harness.result.onGenerationAfterCommands("normal", {}, false); assert.equal(harness.runRecallCalls.length, 2); - assert.equal(harness.runRecallCalls[0].recallKey, harness.runRecallCalls[1].recallKey); + assert.equal( + harness.runRecallCalls[0].recallKey, + harness.runRecallCalls[1].recallKey, + ); } async function testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow() { @@ -2146,7 +2302,9 @@ async function testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow() { harness.chat = [{ is_user: true, mes: "同 key 重复生成" }]; await harness.result.onGenerationAfterCommands("normal", {}, false); - const transaction = [...harness.result.generationRecallTransactions.values()][0]; + const transaction = [ + ...harness.result.generationRecallTransactions.values(), + ][0]; transaction.updatedAt = Date.now() - 5000; harness.result.generationRecallTransactions.set(transaction.id, transaction); await harness.result.onGenerationAfterCommands("normal", {}, false); @@ -2199,11 +2357,10 @@ async function testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine() { await harness.result.onBeforeCombinePrompts(); assert.equal(harness.runRecallCalls.length, 1); - assert.equal( - harness.result.generationRecallTransactions.size, - 1, - ); - const transaction = [...harness.result.generationRecallTransactions.values()][0]; + assert.equal(harness.result.generationRecallTransactions.size, 1); + const transaction = [ + ...harness.result.generationRecallTransactions.values(), + ][0]; assert.equal(transaction.hookStates.GENERATION_AFTER_COMMANDS, "skipped"); } @@ -2218,6 +2375,108 @@ async function testGenerationRecallAppliesFinalInjectionOncePerTransaction() { assert.equal(harness.applyFinalCalls[0].generationType, "normal"); } +async function testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphReadable() { + const { runRecallController } = await import("../recall-controller.js"); + const statuses = []; + const graph = normalizeGraphRuntimeState(createEmptyGraph(), "chat-main"); + graph.nodes.push( + createNode("event", { + title: "旧事件", + summary: "来自 runtime graph", + }), + ); + + const runtime = { + getIsRecalling: () => false, + abortRecallStageWithReason() {}, + waitForActiveRecallToSettle: async () => ({ settled: true }), + getCurrentGraph: () => graph, + getSettings: () => ({ + enabled: true, + recallEnabled: true, + recallLlmContextMessages: 4, + }), + isGraphReadable: () => false, + isGraphReadableForRecall: () => true, + getGraphMutationBlockReason: () => "召回已暂停:正在加载 IndexedDB 图谱。", + setLastRecallStatus: (...args) => { + statuses.push(args); + }, + isGraphMetadataWriteAllowed: () => false, + recoverHistoryIfNeeded: async () => { + throw new Error("loading 期间不应触发历史恢复"); + }, + getContext: () => ({ + chat: [{ is_user: true, mes: "发送前输入" }], + }), + nextRecallRunSequence: () => 1, + setIsRecalling() {}, + beginStageAbortController: () => ({ + signal: { aborted: false, addEventListener() {} }, + abort() {}, + }), + createAbortError: (message) => new Error(message), + ensureVectorReadyIfNeeded: async () => {}, + clampInt, + resolveRecallInput: () => ({ + userMessage: "发送前输入", + recentMessages: ["[user]: 发送前输入"], + source: "send-intent", + sourceLabel: "发送意图", + generationType: "normal", + targetUserMessageIndex: null, + }), + console, + getRecallHookLabel: () => "发送前拦截", + retrieve: async ({ graph: passedGraph, userMessage }) => { + assert.equal(passedGraph, graph); + assert.equal(userMessage, "发送前输入"); + return { + stats: { recallCount: 1, coreCount: 1 }, + selectedNodeIds: [graph.nodes[0].id], + meta: { + retrieval: { + vectorHits: 1, + diffusionHits: 0, + llm: { status: "disabled", candidatePool: 0 }, + }, + }, + }; + }, + getEmbeddingConfig: () => null, + getSchema: () => schema, + buildRecallRetrieveOptions: () => ({}), + applyRecallInjection: (_settings, recallInput) => ({ + injectionText: `注入:${recallInput.userMessage}`, + }), + createRecallInputRecord, + createRecallRunResult, + isAbortError: () => false, + toastr: { + warning() {}, + error() {}, + }, + finishStageAbortController() {}, + getActiveRecallPromise: () => null, + setActiveRecallPromise() {}, + setPendingRecallSendIntent() {}, + refreshPanelLiveState() {}, + }; + + const result = await runRecallController(runtime, { + hookName: "GENERATE_BEFORE_COMBINE_PROMPTS", + }); + + assert.equal(result.status, "completed"); + assert.equal(result.didRecall, true); + assert.equal(result.injectionText, "注入:发送前输入"); + assert.equal( + statuses.some(([title]) => title === "等待图谱加载"), + false, + "runtime graph 可读时不应再被 loading 门禁误判为等待图谱加载", + ); +} + async function testPersistentRecallDataLayerLifecycleAndCompatibility() { const chat = [ { is_user: true, mes: "u0" }, @@ -2244,7 +2503,10 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { assert.equal(loaded.manuallyEdited, false); chat[2].mes = "u2 edited"; - assert.equal(readPersistedRecallFromUserMessage(chat, 2)?.injectionText, "fresh-memory"); + assert.equal( + readPersistedRecallFromUserMessage(chat, 2)?.injectionText, + "fresh-memory", + ); const bumped = bumpPersistedRecallGenerationCount(chat, 2); assert.equal(bumped?.generationCount, 1); @@ -2278,7 +2540,10 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { assert.equal(removePersistedRecallFromUserMessage(chat, 2), true); assert.equal(readPersistedRecallFromUserMessage(chat, 2), null); - assert.equal(readPersistedRecallFromUserMessage([{ is_user: true, mes: "legacy" }], 0), null); + assert.equal( + readPersistedRecallFromUserMessage([{ is_user: true, mes: "legacy" }], 0), + null, + ); } async function testPersistentRecallSourceResolutionAndTargetRouting() { @@ -2289,21 +2554,42 @@ async function testPersistentRecallSourceResolutionAndTargetRouting() { { is_user: false, mes: "a3" }, ]; - assert.equal(resolveGenerationTargetUserMessageIndex(chat, { generationType: "normal" }), null); - assert.equal(resolveGenerationTargetUserMessageIndex(chat, { generationType: "continue" }), 2); + assert.equal( + resolveGenerationTargetUserMessageIndex(chat, { generationType: "normal" }), + null, + ); + assert.equal( + resolveGenerationTargetUserMessageIndex(chat, { + generationType: "continue", + }), + 2, + ); const withTailUser = [...chat, { is_user: true, mes: "u4" }]; - assert.equal(resolveGenerationTargetUserMessageIndex(withTailUser, { generationType: "normal" }), 4); + assert.equal( + resolveGenerationTargetUserMessageIndex(withTailUser, { + generationType: "normal", + }), + 4, + ); const freshWins = resolveFinalRecallInjectionSource({ - freshRecallResult: { status: "completed", didRecall: true, injectionText: "fresh" }, + freshRecallResult: { + status: "completed", + didRecall: true, + injectionText: "fresh", + }, persistedRecord: { injectionText: "persisted" }, }); assert.equal(freshWins.source, "fresh"); assert.equal(freshWins.injectionText, "fresh"); const fallback = resolveFinalRecallInjectionSource({ - freshRecallResult: { status: "skipped", didRecall: false, injectionText: "" }, + freshRecallResult: { + status: "skipped", + didRecall: false, + injectionText: "", + }, persistedRecord: { injectionText: "persisted" }, }); assert.equal(fallback.source, "persisted"); @@ -2318,7 +2604,13 @@ async function testRecallSubGraphAndDataLayerEntryPoints() { nodes: [ { id: "n1", type: "character", name: "赵管家", importance: 7 }, { id: "n2", type: "event", name: "喂食", importance: 5 }, - { id: "n3", type: "location", name: "厨房", importance: 3, archived: true }, + { + id: "n3", + type: "location", + name: "厨房", + importance: 3, + archived: true, + }, { id: "n4", type: "thread", name: "主线", importance: 8 }, ], edges: [ @@ -2344,7 +2636,27 @@ async function testRecallSubGraphAndDataLayerEntryPoints() { assert.equal(buildRecallSubGraph(graph, []).nodes.length, 0); // Data layer: edit and delete still work - const chat = [{ is_user: true, mes: "u0", extra: { bme_recall: { version: 1, injectionText: "test", selectedNodeIds: ["n1"], generationCount: 0, manuallyEdited: false, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", recallInput: "u0", recallSource: "test", hookName: "TEST", tokenEstimate: 4 } } }]; + const chat = [ + { + is_user: true, + mes: "u0", + extra: { + bme_recall: { + version: 1, + injectionText: "test", + selectedNodeIds: ["n1"], + generationCount: 0, + manuallyEdited: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + recallInput: "u0", + recallSource: "test", + hookName: "TEST", + tokenEstimate: 4, + }, + }, + }, + ]; assert.ok(readPersistedRecallFromUserMessage(chat, 0)); assert.equal(removePersistedRecallFromUserMessage(chat, 0), true); assert.equal(readPersistedRecallFromUserMessage(chat, 0), null); @@ -2416,7 +2728,10 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() { assert.equal(harness.refreshPanelCalls, 2); assert.equal(harness.clearInjectionCalls, 1); assert.equal(harness.onManualExtractCalls, 1); - assert.equal(harness.currentGraph.historyState.processedMessageHashes[3], undefined); + assert.equal( + harness.currentGraph.historyState.processedMessageHashes[3], + undefined, + ); assert.equal(harness.lastExtractedItems.length, 0); } @@ -2704,6 +3019,7 @@ await testGenerationRecallBeforeCombineCanUseProvisionalSendIntentBinding(); await testGenerationRecallAfterCommandsStillSkipsWithoutStableUserFloor(); await testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration(); await testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow(); +await testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphReadable(); await testGenerationRecallBeforeCombineRunsStandalone(); await testGenerationRecallDifferentKeyCanRunAgain(); await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();