diff --git a/index.js b/index.js index b3c03fc..b137039 100644 --- a/index.js +++ b/index.js @@ -1101,7 +1101,7 @@ function updateLastRecalledItems(nodeIds = []) { return; } - lastRecalledItems = nodeIds + lastRecalledItems = normalizeRecallNodeIdList(nodeIds) .map((id) => getNode(currentGraph, id)) .filter(Boolean) .slice(0, 8) @@ -1113,6 +1113,61 @@ function updateLastRecalledItems(nodeIds = []) { ); } +function normalizeRecallNodeIdList(nodeIds = []) { + if (!Array.isArray(nodeIds)) return []; + return nodeIds + .map((entry) => { + if (typeof entry === "string" || typeof entry === "number") { + return String(entry).trim(); + } + if (entry && typeof entry === "object") { + return String(entry.id || entry.nodeId || "").trim(); + } + return ""; + }) + .filter(Boolean); +} + +function getLatestPersistedRecallDisplayRecord(chat = getContext()?.chat) { + if (!Array.isArray(chat) || chat.length === 0) return null; + for (let index = chat.length - 1; index >= 0; index--) { + if (!chat[index]?.is_user) continue; + const record = readPersistedRecallFromUserMessage(chat, index); + if (record?.injectionText) { + return { + messageIndex: index, + record, + }; + } + } + return null; +} + +function restoreRecallUiStateFromPersistence(chat = getContext()?.chat) { + const latestPersisted = getLatestPersistedRecallDisplayRecord(chat); + const graphRecallNodeIds = normalizeRecallNodeIdList( + currentGraph?.lastRecallResult, + ); + const persistedNodeIds = normalizeRecallNodeIdList( + latestPersisted?.record?.selectedNodeIds, + ); + const effectiveNodeIds = graphRecallNodeIds.length + ? graphRecallNodeIds + : persistedNodeIds; + + updateLastRecalledItems(effectiveNodeIds); + lastInjectionContent = String(latestPersisted?.record?.injectionText || "").trim(); + + return { + restored: Boolean(lastInjectionContent || effectiveNodeIds.length), + latestPersistedMessageIndex: Number.isFinite(latestPersisted?.messageIndex) + ? latestPersisted.messageIndex + : null, + selectedNodeIds: effectiveNodeIds, + injectionTextLength: lastInjectionContent.length, + }; +} + function clearRecallInputTracking() { pendingRecallSendIntent = createRecallInputRecord(); lastRecallSentUserMessage = createRecallInputRecord(); @@ -1309,6 +1364,8 @@ function resolveRecallPersistenceTargetUserMessageIndex( } = {}, ) { if (!Array.isArray(chat) || chat.length === 0) return null; + const normalizedGenerationType = + String(generationType || "normal").trim() || "normal"; const explicitIndex = Number.isFinite(explicitTargetUserMessageIndex) ? Math.floor(Number(explicitTargetUserMessageIndex)) @@ -1363,8 +1420,28 @@ function resolveRecallPersistenceTargetUserMessageIndex( } } + // 正常生成阶段里,ST 可能会在真正发送前改写用户文本 + // (命令展开、包装显示、助手 UI 处理等),导致 hash 已无法精确匹配。 + // 这时仍应优先回绑到“当前最新 user 楼层”,否则召回记录虽然生成了, + // 但 Recall Card 会因为找不到目标楼层而消失。 if ( - String(generationType || "normal").trim() !== "normal" && + normalizedGenerationType === "normal" && + Number.isFinite(latestUserIndex) && + chat[latestUserIndex]?.is_user + ) { + return latestUserIndex; + } + + if ( + normalizedGenerationType === "normal" && + Number.isFinite(preferredMessageId) && + chat[preferredMessageId]?.is_user + ) { + return preferredMessageId; + } + + if ( + normalizedGenerationType !== "normal" && Number.isFinite(latestUserIndex) && chat[latestUserIndex]?.is_user ) { @@ -3397,8 +3474,9 @@ function applyIndexedDbSnapshotToRuntime( ? currentGraph.historyState.extractionCount : 0; lastExtractedItems = []; - updateLastRecalledItems(currentGraph.lastRecallResult || []); - lastInjectionContent = ""; + const restoredRecallUi = restoreRecallUiStateFromPersistence( + getContext()?.chat, + ); runtimeStatus = createUiStatus("待命", "已从 IndexedDB 加载聊天图谱", "idle"); lastExtractionStatus = createUiStatus( "待命", @@ -3413,7 +3491,9 @@ function applyIndexedDbSnapshotToRuntime( ); lastRecallStatus = createUiStatus( "待命", - "已从 IndexedDB 加载聊天图谱,等待下一次召回", + restoredRecallUi.restored + ? "已从持久化召回记录恢复显示,等待下一次召回" + : "已从 IndexedDB 加载聊天图谱,等待下一次召回", "idle", ); @@ -3456,6 +3536,7 @@ function applyIndexedDbSnapshotToRuntime( removeGraphShadowSnapshot(normalizedChatId); refreshPanelLiveState(); + schedulePersistedRecallMessageUiRefresh(30); console.debug("[ST-BME] 已从 IndexedDB 加载图谱", { chatId: normalizedChatId, source, @@ -5310,8 +5391,9 @@ function loadGraphFromChat(options = {}) { ? currentGraph.historyState.extractionCount : 0; lastExtractedItems = []; - updateLastRecalledItems(currentGraph.lastRecallResult || []); - lastInjectionContent = ""; + const restoredRecallUi = restoreRecallUiStateFromPersistence( + context?.chat, + ); runtimeStatus = createUiStatus( "图谱加载中", "已从兼容 metadata 暂载图谱,等待 IndexedDB 权威确认", @@ -5330,7 +5412,9 @@ function loadGraphFromChat(options = {}) { ); lastRecallStatus = createUiStatus( "待命", - "兼容图谱暂载中,等待 IndexedDB 确认后再执行召回", + restoredRecallUi.restored + ? "已从持久化召回记录恢复显示,等待 IndexedDB 权威确认" + : "兼容图谱暂载中,等待 IndexedDB 确认后再执行召回", "idle", ); applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, { @@ -5377,6 +5461,7 @@ function loadGraphFromChat(options = {}) { }); refreshPanelLiveState(); + schedulePersistedRecallMessageUiRefresh(30); return { success: true, loaded: true, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 02a8e3e..7e7b9ed 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -34,6 +34,11 @@ import { getNode, serializeGraph, } from "../graph.js"; +import { + buildPersistedRecallRecord, + readPersistedRecallFromUserMessage, +} from "../recall-persistence.js"; +import { getNodeDisplayName } from "../node-labels.js"; import { normalizeGraphRuntimeState } from "../runtime-state.js"; import { clampFloat, @@ -216,6 +221,7 @@ async function createGraphPersistenceHarness({ deserializeGraph, getGraphStats, getNode, + getNodeDisplayName, createUiStatus, createGraphPersistenceState, createRecallInputRecord, @@ -229,6 +235,7 @@ async function createGraphPersistenceHarness({ clampInt, clampFloat, formatRecallContextLine, + readPersistedRecallFromUserMessage, cloneGraphForPersistence, cloneRuntimeDebugValue, onMessageReceivedController, @@ -511,6 +518,12 @@ result = { getCurrentGraph() { return currentGraph; }, + getLastInjectionContent() { + return lastInjectionContent; + }, + getLastRecalledItems() { + return lastRecalledItems; + }, setGraphPersistenceState(patch = {}) { graphPersistenceState = { ...graphPersistenceState, @@ -605,6 +618,54 @@ result = { ); } +{ + const graph = createMeaningfulGraph("chat-recall-ui", "recall-ui"); + graph.nodes[0].id = "restore-node"; + graph.lastRecallResult = [{ id: "restore-node" }]; + stampPersistedGraph(graph, { + revision: 7, + chatId: "chat-recall-ui", + reason: "recall-ui-restore", + }); + + const harness = await createGraphPersistenceHarness({ + chatId: "chat-recall-ui", + globalChatId: "chat-recall-ui", + indexedDbSnapshot: buildSnapshotFromGraph(graph, { + chatId: "chat-recall-ui", + revision: 7, + }), + chat: [ + { + is_user: true, + mes: "用户楼层", + extra: { + bme_recall: buildPersistedRecallRecord({ + injectionText: "已持久化的召回注入", + selectedNodeIds: [], + nowIso: "2026-01-01T00:00:00.000Z", + }), + }, + }, + { + is_user: false, + mes: "assistant", + }, + ], + }); + + const result = harness.api.syncGraphLoadFromLiveContext({ + source: "indexeddb-recall-ui-restore", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(result.synced, true); + assert.equal(harness.api.getGraphPersistenceState().dbReady, true); + assert.equal(harness.api.getLastInjectionContent(), "已持久化的召回注入"); + assert.equal(harness.api.getLastRecalledItems().length, 1); + assert.equal(harness.api.getLastRecalledItems()[0]?.id, "restore-node"); +} + { const harness = await createGraphPersistenceHarness({ chatId: "", diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 4b1027a..6d7569b 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -3594,6 +3594,44 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( assert.equal(resolution.targetUserMessageIndex, 0); } + + { + const harness = await createGenerationRecallHarness({ realApplyFinal: true }); + harness.chat = [ + { is_user: true, mes: "酒馆最终写入的用户楼层文本" }, + { is_user: false, mes: "assistant-tail" }, + ]; + harness.result.recordRecallSentUserMessage(0, "发送前捕获的原始文本", "message-sent"); + + const resolution = + harness.result.applyFinalRecallInjectionForGeneration({ + generationType: "normal", + hookName: "GENERATION_AFTER_COMMANDS", + freshRecallResult: { + status: "completed", + didRecall: true, + injectionText: "fresh-memory", + sourceCandidates: [ + { + text: "发送前捕获的原始文本", + }, + ], + }, + transaction: { + frozenRecallOptions: { + generationType: "normal", + targetUserMessageIndex: null, + overrideUserMessage: "发送前捕获的原始文本", + }, + }, + }); + + assert.equal( + resolution.targetUserMessageIndex, + 0, + "normal 生成时即便用户文本被宿主改写,也应回绑到最新 user 楼层", + ); + } } async function testRecallSubGraphAndDataLayerEntryPoints() {