From 746f8cf08b146d4aef10c92a74719277b9afe383 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 4 Apr 2026 00:01:09 +0800 Subject: [PATCH] Fix recall card persistence backfill --- event-binding.js | 12 ++- index.js | 186 +++++++++++++++++++++++++++++++++++++++ tests/p0-regressions.mjs | 96 +++++++++++++++++--- 3 files changed, 281 insertions(+), 13 deletions(-) diff --git a/event-binding.js b/event-binding.js index 06ee303..c03b73d 100644 --- a/event-binding.js +++ b/event-binding.js @@ -427,8 +427,16 @@ export async function onGenerationAfterCommandsController( // 后续 GENERATE_BEFORE_COMBINE_PROMPTS 阶段会通过 // applyFinalRecallInjectionForGeneration 做 deferred rewrite 兜底。 if (deliveryMode === "immediate") { - // immediate 路径下 runRecall 已经完成持久化 recall record, - // 这里补一次 UI 刷新,避免需要等到消息编辑/历史恢复后才看到 Recall Card。 + runtime.ensurePersistedRecallRecordForGeneration?.({ + generationType: recallContext.generationType, + recallResult, + transaction: recallContext.transaction, + recallOptions: runtimeRecallOptions, + hookName: recallContext.hookName, + }); + // immediate 路径通常会在 runRecall 内完成持久化;如果当时 user 楼层还没稳定, + // 上面的兜底补写会把 fresh recall 绑定回最终 user 楼层。 + // 这里再补一次 UI 刷新,避免需要等到消息编辑/历史恢复后才看到 Recall Card。 runtime.refreshPersistedRecallMessageUi?.(); console.warn("[ST-BME:DIAG] DONE: immediate mode, injection via setExtensionPrompt in runRecall"); return recallResult; diff --git a/index.js b/index.js index b137039..6f18ffc 100644 --- a/index.js +++ b/index.js @@ -1128,6 +1128,16 @@ function normalizeRecallNodeIdList(nodeIds = []) { .filter(Boolean); } +function areRecallNodeIdListsEqual(left = [], right = []) { + const normalizedLeft = normalizeRecallNodeIdList(left); + const normalizedRight = normalizeRecallNodeIdList(right); + if (normalizedLeft.length !== normalizedRight.length) return false; + for (let index = 0; index < normalizedLeft.length; index++) { + if (normalizedLeft[index] !== normalizedRight[index]) return false; + } + return true; +} + function getLatestPersistedRecallDisplayRecord(chat = getContext()?.chat) { if (!Array.isArray(chat) || chat.length === 0) return null; for (let index = chat.length - 1; index >= 0; index--) { @@ -1541,6 +1551,170 @@ function persistRecallInjectionRecord({ }; } +function ensurePersistedRecallRecordForGeneration({ + generationType = "normal", + recallResult = null, + transaction = null, + recallOptions = null, + hookName = "", +} = {}) { + const injectionText = String(recallResult?.injectionText || "").trim(); + if ( + recallResult?.status !== "completed" || + !recallResult?.didRecall || + !injectionText + ) { + return { + persisted: false, + reason: "no-fresh-recall", + targetUserMessageIndex: null, + record: null, + }; + } + + const chat = getContext()?.chat; + if (!Array.isArray(chat) || chat.length === 0) { + return { + persisted: false, + reason: "missing-chat", + targetUserMessageIndex: null, + record: null, + }; + } + + const frozenRecallOptions = + transaction?.frozenRecallOptions && + typeof transaction.frozenRecallOptions === "object" + ? transaction.frozenRecallOptions + : null; + const targetUserMessageIndex = resolveRecallPersistenceTargetUserMessageIndex( + chat, + { + generationType, + explicitTargetUserMessageIndex: + frozenRecallOptions?.targetUserMessageIndex ?? + recallOptions?.targetUserMessageIndex ?? + recallOptions?.explicitTargetUserMessageIndex ?? + null, + candidateTexts: [ + frozenRecallOptions?.overrideUserMessage, + frozenRecallOptions?.userMessage, + recallOptions?.overrideUserMessage, + recallOptions?.userMessage, + recallResult?.recallInput, + recallResult?.userMessage, + ...(Array.isArray(recallResult?.sourceCandidates) + ? recallResult.sourceCandidates.map((candidate) => candidate?.text) + : []), + lastRecallSentUserMessage?.text, + ], + preferredRecord: lastRecallSentUserMessage, + }, + ); + + if ( + !Number.isFinite(targetUserMessageIndex) || + !chat[targetUserMessageIndex]?.is_user + ) { + return { + persisted: false, + reason: "target-unresolved", + targetUserMessageIndex: Number.isFinite(targetUserMessageIndex) + ? targetUserMessageIndex + : null, + record: null, + }; + } + + const selectedNodeIds = normalizeRecallNodeIdList( + recallResult?.selectedNodeIds || [], + ); + const existingRecord = readPersistedRecallFromUserMessage( + chat, + targetUserMessageIndex, + ); + if ( + existingRecord && + String(existingRecord.injectionText || "").trim() === injectionText && + areRecallNodeIdListsEqual(existingRecord.selectedNodeIds, selectedNodeIds) + ) { + return { + persisted: false, + reason: "already-up-to-date", + targetUserMessageIndex, + record: existingRecord, + }; + } + + const nextRecord = buildPersistedRecallRecord( + { + injectionText, + selectedNodeIds, + recallInput: String( + recallResult?.recallInput || + recallResult?.userMessage || + frozenRecallOptions?.overrideUserMessage || + recallOptions?.overrideUserMessage || + recallOptions?.userMessage || + "", + ), + recallSource: String( + recallResult?.source || + frozenRecallOptions?.lockedSource || + frozenRecallOptions?.overrideSource || + recallOptions?.overrideSource || + "", + ), + hookName: String( + hookName || + recallResult?.hookName || + frozenRecallOptions?.hookName || + recallOptions?.hookName || + "", + ), + tokenEstimate: estimateTokens(injectionText), + manuallyEdited: false, + }, + existingRecord, + ); + + if (!writePersistedRecallToUserMessage(chat, targetUserMessageIndex, nextRecord)) { + return { + persisted: false, + reason: "write-failed", + targetUserMessageIndex, + record: null, + }; + } + + triggerChatMetadataSave(getContext(), { immediate: false }); + schedulePersistedRecallMessageUiRefresh(); + debugPersistedRecallPersistence( + "最终阶段已补写召回记录", + { + targetUserMessageIndex, + hookName: + String( + hookName || + recallResult?.hookName || + frozenRecallOptions?.hookName || + recallOptions?.hookName || + "", + ) || "", + injectionTextLength: injectionText.length, + selectedNodeCount: selectedNodeIds.length, + }, + `finalize-persist:${targetUserMessageIndex}`, + ); + + return { + persisted: true, + reason: "backfilled", + targetUserMessageIndex, + record: nextRecord, + }; +} + function removeMessageRecallRecord(messageIndex) { const chat = getContext()?.chat; if (!Array.isArray(chat)) return false; @@ -1753,6 +1927,14 @@ function applyFinalRecallInjectionForGeneration({ return emptyResolution; } + const ensuredPersistence = ensurePersistedRecallRecordForGeneration({ + generationType, + recallResult, + transaction, + recallOptions: transaction?.frozenRecallOptions || null, + hookName, + }); + targetUserMessageIndex = resolveRecallPersistenceTargetUserMessageIndex(chat, { generationType, explicitTargetUserMessageIndex: @@ -1766,6 +1948,9 @@ function applyFinalRecallInjectionForGeneration({ ], preferredRecord: lastRecallSentUserMessage, }); + if (Number.isFinite(ensuredPersistence?.targetUserMessageIndex)) { + targetUserMessageIndex = ensuredPersistence.targetUserMessageIndex; + } const persistedRecord = Number.isFinite(targetUserMessageIndex) ? readPersistedRecallFromUserMessage(chat, targetUserMessageIndex) @@ -8628,6 +8813,7 @@ async function onGenerationAfterCommands(type, params = {}, dryRun = false) { clearLiveRecallInjectionPromptForRewrite, consumeHostGenerationInputSnapshot, createGenerationRecallContext, + ensurePersistedRecallRecordForGeneration, getContext, getGenerationRecallHookStateFromResult, getGenerationRecallTransactionResult, diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 6d7569b..e3c01ed 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -365,15 +365,11 @@ function createGenerationRecallHarness(options = {}) { onBeforeCombinePromptsController, onGenerationAfterCommandsController, onGenerationStartedController, - readPersistedRecallFromUserMessage: () => null, - resolveFinalRecallInjectionSource: ({ - freshRecallResult = null, - } = {}) => ({ - source: freshRecallResult?.didRecall ? "fresh" : "none", - injectionText: String(freshRecallResult?.injectionText || ""), - record: null, - }), - bumpPersistedRecallGenerationCount: () => null, + readPersistedRecallFromUserMessage, + writePersistedRecallToUserMessage, + buildPersistedRecallRecord, + resolveFinalRecallInjectionSource, + bumpPersistedRecallGenerationCount, applyModuleInjectionPrompt: (text = "") => { const normalizedText = String(text || ""); context.moduleInjectionCalls.push(normalizedText); @@ -384,14 +380,23 @@ function createGenerationRecallHarness(options = {}) { }; }, getSettings: () => ({}), - triggerChatMetadataSave: () => "debounced", + triggerChatMetadataSave: () => { + context.metadataSaveCalls += 1; + return "debounced"; + }, refreshPanelLiveState: () => { context.refreshPanelCalls += 1; }, recordInjectionSnapshot: (_kind, snapshot = {}) => { context.recordedInjectionSnapshots.push({ ...snapshot }); }, - schedulePersistedRecallMessageUiRefresh: () => {}, + schedulePersistedRecallMessageUiRefresh: () => { + context.recallUiRefreshCalls += 1; + }, + estimateTokens: (text = "") => + normalizeRecallInputText(text) + .split(/\s+/) + .filter(Boolean).length || (normalizeRecallInputText(text) ? 1 : 0), resolveGenerationTargetUserMessageIndex: ( chat = [], { generationType } = {}, @@ -404,6 +409,8 @@ function createGenerationRecallHarness(options = {}) { if (chat[index]?.is_user) return index; return null; }, + metadataSaveCalls: 0, + recallUiRefreshCalls: 0, }; vm.createContext(context); vm.runInContext( @@ -3634,6 +3641,71 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( } } +async function testGenerationRecallFinalInjectionBackfillsPersistedRecord() { + 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", + selectedNodeIds: ["node-a", "node-b"], + }, + transaction: { + frozenRecallOptions: { + generationType: "normal", + targetUserMessageIndex: null, + overrideUserMessage: "最终阶段补写目标", + lockedSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + }, + }, + }); + + assert.equal(resolution.source, "fresh"); + assert.equal(resolution.targetUserMessageIndex, 0); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.injectionText, + "fresh-memory", + ); + assert.deepEqual( + harness.chat[0]?.extra?.bme_recall?.selectedNodeIds, + ["node-a", "node-b"], + ); + assert.equal(harness.metadataSaveCalls > 0, true); +} + +async function testGenerationRecallImmediateAfterCommandsBackfillsPersistedRecord() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "即时模式补写目标" }]; + harness.result.recordRecallSentUserMessage(0, "即时模式补写目标", "message-sent"); + + const result = await harness.result.onGenerationAfterCommands( + "normal", + {}, + false, + ); + + assert.equal(result?.status, "completed"); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.injectionText, + "注入:即时模式补写目标", + ); + assert.deepEqual( + harness.chat[0]?.extra?.bme_recall?.selectedNodeIds, + ["node-test-1"], + ); + assert.equal(harness.metadataSaveCalls > 0, true); +} + async function testRecallSubGraphAndDataLayerEntryPoints() { // Sub-graph build test (pure function, no DOM needed) const { buildRecallSubGraph } = await import("../recall-message-ui.js"); @@ -4443,6 +4515,8 @@ await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting(); await testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(); +await testGenerationRecallFinalInjectionBackfillsPersistedRecord(); +await testGenerationRecallImmediateAfterCommandsBackfillsPersistedRecord(); await testRecallCardMountsOnStandardUserMessageDom(); await testRecallCardSkipsMountWithoutStableMessageIndex(); await testRecallCardDelayedDomInsertionEventuallyRenders();