From b0f9d191bddf002fb9a02024bdc4c20610283baf Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 31 Mar 2026 03:53:53 +0800 Subject: [PATCH] fix: finalize deepfix p2 recall binding and p3 validation matrix --- event-binding.js | 6 +- index.js | 272 ++++++++++++++++++++++++++++++++++-- package.json | 4 +- tests/graph-persistence.mjs | 80 +++++++++++ tests/indexeddb-sync.mjs | 5 + tests/p0-regressions.mjs | 91 ++++++++++++ ui-status.js | 27 +++- 7 files changed, 465 insertions(+), 20 deletions(-) diff --git a/event-binding.js b/event-binding.js index fcf1eaf..53f845a 100644 --- a/event-binding.js +++ b/event-binding.js @@ -200,13 +200,14 @@ export async function onGenerationAfterCommandsController( return; } + const runtimeRecallOptions = recallContext.recallOptions || recallOptions || {}; runtime.markGenerationRecallTransactionHookState( recallContext.transaction, recallContext.hookName, "running", ); const recallResult = await runtime.runRecall({ - ...recallOptions, + ...runtimeRecallOptions, recallKey: recallContext.recallKey, hookName: recallContext.hookName, signal: params?.signal, @@ -240,13 +241,14 @@ export async function onBeforeCombinePromptsController(runtime) { return; } + const runtimeRecallOptions = recallContext.recallOptions || recallOptions || {}; runtime.markGenerationRecallTransactionHookState( recallContext.transaction, recallContext.hookName, "running", ); const recallResult = await runtime.runRecall({ - ...recallOptions, + ...runtimeRecallOptions, recallKey: recallContext.recallKey, hookName: recallContext.hookName, }); diff --git a/index.js b/index.js index 8c7653f..08c908b 100644 --- a/index.js +++ b/index.js @@ -483,6 +483,7 @@ const PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS = 1500; const persistedRecallUiDiagnosticTimestamps = new Map(); const persistedRecallPersistDiagnosticTimestamps = new Map(); const GENERATION_RECALL_TRANSACTION_TTL_MS = 15000; +const GENERATION_RECALL_HOOK_BRIDGE_MS = 1200; const stageNoticeHandles = { extraction: null, vector: null, @@ -4686,10 +4687,12 @@ function buildPreGenerationRecallKey(type, options = {}) { const seedText = options.overrideUserMessage || options.userMessage || `@target:${targetUserMessageIndex}`; + const normalizedChatId = normalizeChatIdCandidate(options.chatId || getCurrentChatId()); + return [ - getCurrentChatId(), + normalizedChatId, String(type || "normal").trim() || "normal", - hashRecallInput(seedText), + hashRecallInput(seedText || ""), ].join(":"); } @@ -4707,6 +4710,90 @@ function cleanupGenerationRecallTransactions(now = Date.now()) { } } +function getGenerationRecallPeerHookName(hookName = "") { + const normalized = String(hookName || "").trim(); + if (normalized === "GENERATION_AFTER_COMMANDS") { + return "GENERATE_BEFORE_COMBINE_PROMPTS"; + } + if (normalized === "GENERATE_BEFORE_COMBINE_PROMPTS") { + return "GENERATION_AFTER_COMMANDS"; + } + return ""; +} + +function isGenerationRecallTransactionWithinBridgeWindow( + transaction, + now = Date.now(), +) { + if (!transaction) return false; + return now - Number(transaction.updatedAt || transaction.createdAt || 0) <= GENERATION_RECALL_HOOK_BRIDGE_MS; +} + +function normalizeGenerationRecallTransactionType(generationType = "normal") { + const normalized = String(generationType || "normal").trim() || "normal"; + return normalized === "normal" ? "normal" : "history"; +} + +function freezeGenerationRecallOptionsForTransaction( + chat, + generationType = "normal", + recallOptions = {}, +) { + if (!Array.isArray(chat)) return null; + + const optionGenerationType = String( + recallOptions?.generationType || generationType || "normal", + ).trim() || "normal"; + const normalizedGenerationType = optionGenerationType; + + let targetUserMessageIndex = Number.isFinite(recallOptions?.targetUserMessageIndex) + ? Math.floor(Number(recallOptions.targetUserMessageIndex)) + : resolveGenerationTargetUserMessageIndex(chat, { + generationType: normalizedGenerationType, + }); + + if (!Number.isFinite(targetUserMessageIndex)) { + return null; + } + targetUserMessageIndex = Math.floor(targetUserMessageIndex); + + const targetUserMessage = chat[targetUserMessageIndex]; + if (!targetUserMessage?.is_user) { + return null; + } + + const frozenUserMessage = normalizeRecallInputText( + targetUserMessage?.mes || + recallOptions?.overrideUserMessage || + recallOptions?.userMessage || + "", + ); + if (!frozenUserMessage) { + return null; + } + + const source = + String(recallOptions?.overrideSource || recallOptions?.source || "").trim() || + (normalizeGenerationRecallTransactionType(normalizedGenerationType) === "normal" + ? "chat-tail-user" + : "chat-last-user"); + const sourceLabel = + String( + recallOptions?.overrideSourceLabel || + recallOptions?.sourceLabel || + getRecallUserMessageSourceLabel(source), + ).trim() || getRecallUserMessageSourceLabel(source); + + return { + generationType: normalizedGenerationType, + targetUserMessageIndex, + overrideUserMessage: frozenUserMessage, + overrideSource: source, + overrideSourceLabel: sourceLabel, + includeSyntheticUserMessage: false, + }; +} + function buildGenerationRecallTransactionId(chatId, generationType, recallKey) { return [ String(chatId || ""), @@ -4719,6 +4806,7 @@ function beginGenerationRecallTransaction({ chatId, generationType = "normal", recallKey = "", + forceNew = false, } = {}) { const normalizedChatId = String(chatId || ""); const normalizedGenerationType = @@ -4732,20 +4820,98 @@ function beginGenerationRecallTransaction({ normalizedGenerationType, normalizedRecallKey, ); + const now = Date.now(); - const transaction = generationRecallTransactions.get(transactionId) || { + const existingTransaction = generationRecallTransactions.get(transactionId) || null; + if ( + existingTransaction && + isGenerationRecallTransactionWithinBridgeWindow(existingTransaction, now) && + !forceNew + ) { + existingTransaction.updatedAt = now; + generationRecallTransactions.set(transactionId, existingTransaction); + return existingTransaction; + } + + const transaction = { id: transactionId, chatId: normalizedChatId, generationType: normalizedGenerationType, recallKey: normalizedRecallKey, hookStates: {}, createdAt: now, + frozenRecallOptions: null, }; transaction.updatedAt = now; generationRecallTransactions.set(transactionId, transaction); return transaction; } +function findRecentGenerationRecallTransactionForChat( + chatId = getCurrentChatId(), + now = Date.now(), +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + + 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)) { + latestTransaction = transaction; + } + } + + return latestTransaction; +} + +function shouldReuseRecentGenerationRecallTransaction( + transaction, + hookName, + recallKey = "", + now = Date.now(), +) { + if (!transaction || !hookName) return false; + if (!isGenerationRecallTransactionWithinBridgeWindow(transaction, now)) { + return false; + } + + const hookStates = transaction.hookStates || {}; + const normalizedRecallKey = String(recallKey || ""); + const transactionRecallKey = String(transaction.recallKey || ""); + + if (Object.values(hookStates).includes("running")) { + return true; + } + + const peerHookName = getGenerationRecallPeerHookName(hookName); + const peerHookState = peerHookName ? hookStates[peerHookName] : ""; + if (peerHookState) { + return true; + } + + const ownState = hookStates[hookName]; + if (ownState) { + return ownState === "running"; + } + + if (!Object.keys(hookStates).length) { + if (!transactionRecallKey) { + return true; + } + if (!normalizedRecallKey) { + return false; + } + if (normalizedRecallKey !== transactionRecallKey) { + return false; + } + return true; + } + + return false; +} + function markGenerationRecallTransactionHookState( transaction, hookName, @@ -4820,20 +4986,102 @@ function createGenerationRecallContext({ recallOptions = {}, chatId = getCurrentChatId(), } = {}) { - const recallKey = - recallOptions.recallKey || - buildPreGenerationRecallKey(generationType, recallOptions); - const transaction = beginGenerationRecallTransaction({ - chatId, + const context = getContext(); + const chat = context?.chat; + const normalizedChatId = normalizeChatIdCandidate( + chatId || context?.chatId || getCurrentChatId(), + ); + + const frozenRecallOptions = freezeGenerationRecallOptionsForTransaction( + chat, generationType, - recallKey, - }); + recallOptions, + ); + if (!frozenRecallOptions) { + return { + hookName, + generationType, + recallKey: "", + transaction: null, + recallOptions: null, + shouldRun: false, + }; + } + + const transactionGenerationType = normalizeGenerationRecallTransactionType( + frozenRecallOptions.generationType || generationType, + ); + const fallbackRecallKey = + recallOptions.recallKey || + buildPreGenerationRecallKey(transactionGenerationType, { + ...frozenRecallOptions, + chatId: normalizedChatId, + userMessage: frozenRecallOptions.overrideUserMessage, + }); + + const now = Date.now(); + const recentTransaction = findRecentGenerationRecallTransactionForChat( + normalizedChatId, + now, + ); + let transaction = recentTransaction; + if ( + !shouldReuseRecentGenerationRecallTransaction( + transaction, + hookName, + fallbackRecallKey, + now, + ) + ) { + transaction = beginGenerationRecallTransaction({ + chatId: normalizedChatId, + generationType: transactionGenerationType, + recallKey: fallbackRecallKey, + forceNew: true, + }); + } + + if (!transaction) { + return { + hookName, + generationType, + recallKey: "", + transaction: null, + recallOptions: null, + shouldRun: false, + }; + } + + if (!transaction.frozenRecallOptions || typeof transaction.frozenRecallOptions !== "object") { + transaction.frozenRecallOptions = { + ...frozenRecallOptions, + }; + } + if (!String(transaction.recallKey || "").trim()) { + transaction.recallKey = fallbackRecallKey; + } + if (!String(transaction.generationType || "").trim()) { + transaction.generationType = transactionGenerationType; + } + transaction.updatedAt = now; + generationRecallTransactions.set(transaction.id, transaction); + + const boundRecallOptions = { + ...(transaction.frozenRecallOptions || frozenRecallOptions), + recallKey: transaction.recallKey, + generationType: transaction.frozenRecallOptions?.generationType || generationType, + }; + + const recallKey = String(transaction.recallKey || fallbackRecallKey || ""); + const shouldRun = shouldRunRecallForTransaction(transaction, hookName); + return { hookName, - generationType, + generationType: boundRecallOptions.generationType, recallKey, transaction, - shouldRun: shouldRunRecallForTransaction(transaction, hookName), + recallOptions: boundRecallOptions, + shouldRun, }; } diff --git a/package.json b/package.json index 6f2f781..d0749e1 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "scripts": { "test:p0": "node tests/p0-regressions.mjs", + "test:runtime-history": "node tests/runtime-history.mjs", "test:graph-persistence": "node tests/graph-persistence.mjs", "test:indexeddb-persistence": "node tests/indexeddb-persistence.mjs", "test:indexeddb-sync": "node tests/indexeddb-sync.mjs", "test:indexeddb-migration": "node tests/indexeddb-migration.mjs", "test:indexeddb": "npm run test:indexeddb-persistence && npm run test:indexeddb-sync && npm run test:indexeddb-migration", - "test:all": "npm run test:p0 && npm run test:graph-persistence && npm run test:indexeddb", + "test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb", + "test:all": "npm run test:persistence-matrix", "check": "node --check index.js && node --check bme-db.js && node --check panel.js && node --check ui-status.js && node --check event-binding.js" }, "dependencies": { diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 134d4ff..07b1358 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -882,6 +882,86 @@ result = { ); } +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-sync-refresh-merge", + chatMetadata: { + integrity: "chat-sync-refresh-merge-ready", + }, + }); + harness.api.setCurrentGraph( + normalizeGraphRuntimeState( + createMeaningfulGraph("chat-sync-refresh-merge", "stale-runtime-merge"), + "chat-sync-refresh-merge", + ), + ); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId: "chat-sync-refresh-merge", + reason: "runtime-stale", + revision: 3, + lastPersistedRevision: 3, + dbReady: true, + writesBlocked: false, + }); + harness.api.setIndexedDbSnapshot( + buildSnapshotFromGraph( + createMeaningfulGraph("chat-sync-refresh-merge", "fresh-indexeddb-merge"), + { + chatId: "chat-sync-refresh-merge", + revision: 8, + }, + ), + ); + + const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions(); + await runtimeOptions.onSyncApplied({ + chatId: "chat-sync-refresh-merge", + action: "merge", + }); + + assert.equal( + harness.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-fresh-indexeddb-merge", + "merge 后应刷新当前运行时图谱", + ); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-sync-refresh-active", + chatMetadata: { + integrity: "chat-sync-refresh-active-ready", + }, + }); + harness.api.setCurrentGraph( + normalizeGraphRuntimeState( + createMeaningfulGraph("chat-sync-refresh-active", "active-runtime"), + "chat-sync-refresh-active", + ), + ); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId: "chat-sync-refresh-active", + reason: "runtime-active", + revision: 4, + dbReady: true, + writesBlocked: false, + }); + + const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions(); + await runtimeOptions.onSyncApplied({ + chatId: "chat-sync-refresh-other", + action: "download", + }); + + assert.equal( + harness.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-active-runtime", + "active chat 与 sync payload chat 不一致时不应覆盖当前运行时图谱", + ); +} + { const sharedSession = new Map(); const writer = await createGraphPersistenceHarness({ diff --git a/tests/indexeddb-sync.mjs b/tests/indexeddb-sync.mjs index 9d4f765..7191e2f 100644 --- a/tests/indexeddb-sync.mjs +++ b/tests/indexeddb-sync.mjs @@ -712,7 +712,12 @@ async function testSyncAppliedHook() { const mergeResult = await syncNow("chat-hook-merge", runtime); assert.equal(mergeResult.action, "merge"); + assert.equal(downloadResult.revision, 3); + assert.equal(mergeResult.revision, 5); + assert.deepEqual(hookCalls.map((item) => item.action), ["download", "merge"]); + assert.deepEqual(hookCalls.map((item) => item.chatId), ["chat-hook-download", "chat-hook-merge"]); + assert.deepEqual(hookCalls.map((item) => item.revision), [3, 5]); } async function main() { diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 4515e95..be89337 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -1929,6 +1929,91 @@ async function testGenerationRecallTransactionDedupesDoubleHookBySameKey() { assert.equal(harness.runRecallCalls[0].hookName, "GENERATION_AFTER_COMMANDS"); } +async function testGenerationRecallTransactionDedupesReverseHookOrder() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "逆序同轮输入" }]; + + await harness.result.onBeforeCombinePrompts(); + await harness.result.onGenerationAfterCommands("normal", {}, false); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal( + harness.runRecallCalls[0].hookName, + "GENERATE_BEFORE_COMBINE_PROMPTS", + ); +} + +async function testGenerationRecallHistoryModesUseSameBindingAcrossHooks() { + for (const generationType of ["continue", "regenerate", "swipe"]) { + const harness = await createGenerationRecallHarness(); + const userMessage = `历史输入-${generationType}`; + harness.chat = [ + { is_user: true, mes: userMessage }, + { is_user: false, mes: "assistant-tail" }, + ]; + + 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[0].targetUserMessageIndex, 0); + assert.equal(harness.runRecallCalls[0].overrideUserMessage, userMessage); + } +} + +async function testGenerationRecallFrozenBindingSurvivesCrossHookInputDrift() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "稳定输入-A" }]; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + harness.chat = [{ is_user: true, mes: "稳定输入-B" }]; + await harness.result.onBeforeCombinePrompts(); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal(harness.runRecallCalls[0].overrideUserMessage, "稳定输入-A"); +} + +async function testGenerationRecallSkipsUntilTargetUserFloorAvailable() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: false, mes: "assistant-only" }]; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + assert.equal(harness.runRecallCalls.length, 0); + + harness.chat = [{ is_user: true, mes: "补齐 user 楼层" }]; + await harness.result.onBeforeCombinePrompts(); + assert.equal(harness.runRecallCalls.length, 1); + assert.equal( + harness.runRecallCalls[0].hookName, + "GENERATE_BEFORE_COMBINE_PROMPTS", + ); +} + +async function testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "同 key 连续生成" }]; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + await harness.result.onGenerationAfterCommands("normal", {}, false); + + assert.equal(harness.runRecallCalls.length, 2); + assert.equal(harness.runRecallCalls[0].recallKey, harness.runRecallCalls[1].recallKey); +} + +async function testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "同 key 重复生成" }]; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + 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); + + assert.equal(harness.runRecallCalls.length, 2); +} + async function testGenerationRecallBeforeCombineRunsStandalone() { const harness = await createGenerationRecallHarness(); harness.chat = [{ is_user: true, mes: "仅 before combine" }]; @@ -2471,6 +2556,12 @@ await testBatchStatusSemanticFailureDoesNotHideCoreSuccess(); await testBatchStatusFinalizeFailureIsNotCompleteSuccess(); await testProcessedHistoryAdvanceRequiresCompleteStrongSuccess(); await testGenerationRecallTransactionDedupesDoubleHookBySameKey(); +await testGenerationRecallTransactionDedupesReverseHookOrder(); +await testGenerationRecallHistoryModesUseSameBindingAcrossHooks(); +await testGenerationRecallFrozenBindingSurvivesCrossHookInputDrift(); +await testGenerationRecallSkipsUntilTargetUserFloorAvailable(); +await testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration(); +await testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow(); await testGenerationRecallBeforeCombineRunsStandalone(); await testGenerationRecallDifferentKeyCanRunAgain(); await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine(); diff --git a/ui-status.js b/ui-status.js index 8cd0fc4..54449a7 100644 --- a/ui-status.js +++ b/ui-status.js @@ -278,16 +278,33 @@ export function isTerminalGenerationRecallHookState(state = "") { export function shouldRunRecallForTransaction(transaction, hookName) { if (!hookName) return true; if (!transaction) return true; + const hookStates = transaction.hookStates || {}; - if (isTerminalGenerationRecallHookState(hookStates[hookName])) { - return false; - } + const currentHookState = hookStates[hookName]; if ( - hookName === "GENERATE_BEFORE_COMBINE_PROMPTS" && - isTerminalGenerationRecallHookState(hookStates.GENERATION_AFTER_COMMANDS) + currentHookState === "running" || + isTerminalGenerationRecallHookState(currentHookState) ) { return false; } + + const peerHookName = + hookName === "GENERATION_AFTER_COMMANDS" + ? "GENERATE_BEFORE_COMBINE_PROMPTS" + : hookName === "GENERATE_BEFORE_COMBINE_PROMPTS" + ? "GENERATION_AFTER_COMMANDS" + : ""; + + if (!peerHookName) return true; + + const peerHookState = hookStates[peerHookName]; + if ( + peerHookState === "running" || + isTerminalGenerationRecallHookState(peerHookState) + ) { + return false; + } + return true; }