Fix recall card persistence backfill

This commit is contained in:
Youzini-afk
2026-04-04 00:01:09 +08:00
parent f2c35b725a
commit 746f8cf08b
3 changed files with 281 additions and 13 deletions

View File

@@ -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;

186
index.js
View File

@@ -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,

View File

@@ -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();