diff --git a/host/event-binding.js b/host/event-binding.js index 71dc555..4f44615 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -467,9 +467,15 @@ export function onMessageUpdatedController(runtime, messageId, meta = null) { } export async function onMessageSwipedController(runtime, messageId, meta = null) { - runtime.invalidateRecallAfterHistoryMutation("已切换楼层 swipe"); const parsedFloor = Number(messageId); const fromFloor = Number.isFinite(parsedFloor) ? parsedFloor : undefined; + const preparedRerollReuse = runtime.prepareRerollRecallReuse?.({ + fromFloor, + meta, + }); + if (!preparedRerollReuse) { + runtime.invalidateRecallAfterHistoryMutation("已切换楼层 swipe"); + } let result = { success: false, rollbackPerformed: false, @@ -503,6 +509,9 @@ export async function onMessageSwipedController(runtime, messageId, meta = null) { messageId, meta }, ); } + if (!result?.success) { + runtime.clearPendingRerollRecallReuse?.("swipe-reroll-failed"); + } runtime.refreshPersistedRecallMessageUi?.(); return result; } diff --git a/index.js b/index.js index fb6a4be..d3a66ca 100644 --- a/index.js +++ b/index.js @@ -1356,6 +1356,7 @@ const dismissedStageNoticeSignatures = new Map(); let pendingRecallSendIntent = createRecallInputRecord(); let lastRecallSentUserMessage = createRecallInputRecord(); let pendingHostGenerationInputSnapshot = createRecallInputRecord(); +let pendingRerollRecallReuse = null; let currentGenerationTrivialSkip = null; let coreEventBindingState = { registered: false, @@ -5002,6 +5003,7 @@ function clearRecallInputTracking() { clearPendingRecallSendIntent(); lastRecallSentUserMessage = createRecallInputRecord(); clearPendingHostGenerationInputSnapshot(); + clearPendingRerollRecallReuse("recall-input-tracking-cleared"); if (typeof recordMessageTraceSnapshot === "function") { recordMessageTraceSnapshot({ lastSentUserMessage: null, @@ -19466,6 +19468,18 @@ function getLatestUserChatMessage(chat) { return null; } +function findLatestUserChatMessageWithIndex(chat) { + if (!Array.isArray(chat)) return null; + + for (let index = chat.length - 1; index >= 0; index--) { + const message = chat[index]; + if (isSystemMessageForExtraction(message, { index, chat })) continue; + if (message?.is_user) return { message, index }; + } + + return null; +} + function getLastNonSystemChatMessage(chat) { if (!Array.isArray(chat)) return null; @@ -19479,6 +19493,124 @@ function getLastNonSystemChatMessage(chat) { return null; } +function getPendingRerollRecallReuse() { + return pendingRerollRecallReuse; +} + +function clearPendingRerollRecallReuse(reason = "") { + const previous = pendingRerollRecallReuse; + pendingRerollRecallReuse = null; + return previous; +} + +function prepareRerollRecallReuse({ fromFloor = null, meta = null } = {}) { + const context = getContext(); + const chat = context?.chat; + if (!Array.isArray(chat) || chat.length === 0) { + pendingRerollRecallReuse = null; + return null; + } + + const latestUser = findLatestUserChatMessageWithIndex(chat); + const targetUserMessageIndex = Number.isFinite(latestUser?.index) + ? latestUser.index + : null; + if (!Number.isFinite(targetUserMessageIndex)) { + pendingRerollRecallReuse = null; + return null; + } + + const userMessage = chat[targetUserMessageIndex]; + const userText = normalizeRecallInputText(userMessage?.mes || ""); + if (!userText) { + pendingRerollRecallReuse = null; + return null; + } + + const persistedRecord = readPersistedRecallFromUserMessage( + chat, + targetUserMessageIndex, + ); + const persistedInjection = normalizeRecallInputText( + persistedRecord?.injectionText || "", + ); + if (!persistedRecord || !persistedInjection) { + pendingRerollRecallReuse = null; + return null; + } + + const boundText = normalizeRecallInputText( + persistedRecord?.boundUserFloorText || persistedRecord?.recallInput || "", + ); + if (boundText && boundText !== userText) { + pendingRerollRecallReuse = null; + return null; + } + + const chatId = normalizeChatIdCandidate(getCurrentChatId(context)); + pendingRerollRecallReuse = { + chatId, + fromFloor: Number.isFinite(Number(fromFloor)) ? Math.floor(Number(fromFloor)) : null, + targetUserMessageIndex, + userText, + userHash: hashRecallInput(userText), + createdAt: Date.now(), + meta, + }; + return pendingRerollRecallReuse; +} + +function consumePendingRerollRecallReuse(chat = getContext()?.chat) { + const reuse = pendingRerollRecallReuse; + if (!reuse) return null; + + const activeChatId = normalizeChatIdCandidate(getCurrentChatId()); + if (reuse.chatId && activeChatId && reuse.chatId !== activeChatId) { + pendingRerollRecallReuse = null; + return null; + } + if (Date.now() - Number(reuse.createdAt || 0) > GENERATION_RECALL_TRANSACTION_TTL_MS) { + pendingRerollRecallReuse = null; + return null; + } + + const latestUser = findLatestUserChatMessageWithIndex(chat); + const targetUserMessageIndex = Number.isFinite(latestUser?.index) + ? latestUser.index + : reuse.targetUserMessageIndex; + if (targetUserMessageIndex !== reuse.targetUserMessageIndex) { + pendingRerollRecallReuse = null; + return null; + } + + const userText = normalizeRecallInputText(chat?.[targetUserMessageIndex]?.mes || ""); + if (!userText || hashRecallInput(userText) !== reuse.userHash) { + pendingRerollRecallReuse = null; + return null; + } + + pendingRerollRecallReuse = null; + return { + overrideUserMessage: userText, + generationType: "normal", + targetUserMessageIndex, + overrideSource: "chat-last-user", + overrideSourceLabel: "历史最后用户楼层", + overrideReason: "reroll-user-floor-reuse", + sourceCandidates: [ + { + text: userText, + source: "chat-last-user", + sourceLabel: "历史最后用户楼层", + reason: "reroll-user-floor-reuse", + includeSyntheticUserMessage: false, + }, + ], + includeSyntheticUserMessage: false, + rerollRecallReuse: true, + }; +} + function buildRecallRecentMessages(chat, limit, syntheticUserMessage = "") { return buildRecallRecentMessagesController( chat, @@ -19574,6 +19706,11 @@ function createTrivialRecallSkipSentinel(reason = "") { } function buildNormalGenerationRecallInput(chat, options = {}) { + const rerollReuse = consumePendingRerollRecallReuse(chat); + if (rerollReuse) { + return rerollReuse; + } + const lastNonSystemMessage = getLastNonSystemChatMessage(chat); const tailUserText = lastNonSystemMessage?.is_user ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") @@ -22869,6 +23006,8 @@ async function onMessageSwiped(messageId, meta = null) { { invalidateRecallAfterHistoryMutation, onReroll, + prepareRerollRecallReuse, + clearPendingRerollRecallReuse, refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, scheduleHistoryMutationRecheck, }, diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index d74e6c7..a37fc71 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -269,7 +269,7 @@ export function createGenerationRecallHarness(options = {}) { }; vm.createContext(context); vm.runInContext( - `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, persistRecallInjectionRecord, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, preparePlannerRecallHandoff, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`, + `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, persistRecallInjectionRecord, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, prepareRerollRecallReuse, getPendingRerollRecallReuse, clearPendingRerollRecallReuse, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, preparePlannerRecallHandoff, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`, context, { filename: indexPath }, ); diff --git a/tests/recall-reroll-reuse.mjs b/tests/recall-reroll-reuse.mjs index 71c89be..a018cb0 100644 --- a/tests/recall-reroll-reuse.mjs +++ b/tests/recall-reroll-reuse.mjs @@ -508,6 +508,67 @@ assert.equal( console.log(" ✓ runRecallController reuses persisted record when host reports reroll as normal"); +const rerollInputHarness = await createGenerationRecallHarness({ realApplyFinal: true }); +Object.assign(rerollInputHarness.settings, { + ...defaultSettings, + enabled: true, + recallEnabled: true, + recallUseAuthoritativeGenerationInput: true, +}); +rerollInputHarness.chat = [ + { is_user: true, mes: "重 roll replacement 应复用这一楼" }, + { is_user: false, mes: "旧 assistant 回复", is_system: false }, +]; +writePersistedRecallToUserMessage( + rerollInputHarness.chat, + 0, + buildPersistedRecallRecord({ + injectionText: "注入:重 roll replacement 应复用这一楼", + selectedNodeIds: ["node-reroll-input"], + recallInput: "重 roll replacement 应复用这一楼", + recallSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 6, + manuallyEdited: false, + boundUserFloorText: "重 roll replacement 应复用这一楼", + }), +); + +const preparedRerollReuse = rerollInputHarness.result.prepareRerollRecallReuse({ + fromFloor: 1, +}); +assert.ok(preparedRerollReuse, "assistant-only reroll should prepare recall reuse marker"); + +rerollInputHarness.result.recordRecallSendIntent( + "错误的主动输入不应覆盖 reroll 用户楼", + "send-intent", +); +rerollInputHarness.result.freezeHostGenerationInputSnapshot( + "错误的宿主快照不应覆盖 reroll 用户楼", + "host-generation-lifecycle", +); + +const rerollReplacementInput = rerollInputHarness.result.buildNormalGenerationRecallInput( + rerollInputHarness.chat, + { + frozenInputSnapshot: rerollInputHarness.result.getPendingHostGenerationInputSnapshot(), + }, +); +assert.equal( + rerollReplacementInput.overrideUserMessage, + "重 roll replacement 应复用这一楼", + "reroll replacement should ignore stale live input sources and bind to stable user floor", +); +assert.equal(rerollReplacementInput.overrideSource, "chat-last-user"); +assert.equal(rerollReplacementInput.overrideReason, "reroll-user-floor-reuse"); +assert.equal( + rerollInputHarness.result.getPendingRerollRecallReuse(), + null, + "reroll reuse marker should be one-shot after binding recall input", +); + +console.log(" ✓ reroll replacement normal input is forced to stable user-floor recall source"); + const legacyUnboundReuseChat = [ { is_user: true, mes: "旧记录没有绑定楼层" }, { is_user: false, mes: "上一条回复。", is_system: false },