diff --git a/event-binding.js b/event-binding.js index f8bd466..10d7251 100644 --- a/event-binding.js +++ b/event-binding.js @@ -162,11 +162,11 @@ export function registerCoreEventHooksController(runtime) { } export function onChatChangedController(runtime) { - runtime.clearCoreEventBindingState?.(); runtime.clearPendingHistoryMutationChecks(); runtime.clearTimeout(runtime.getPendingHistoryRecoveryTimer()); runtime.setPendingHistoryRecoveryTimer(null); runtime.setPendingHistoryRecoveryTrigger(""); + runtime.clearPendingAutoExtraction?.(); runtime.clearPendingGraphLoadRetry(); runtime.setSkipBeforeCombineRecallUntil(0); runtime.setLastPreGenerationRecallKey(""); @@ -452,7 +452,11 @@ export async function onBeforeCombinePromptsController( }); } -export function onMessageReceivedController(runtime) { +export function onMessageReceivedController( + runtime, + messageId = null, + _type = "", +) { const persistenceState = runtime.getGraphPersistenceState?.() || {}; const loadState = persistenceState.loadState || ""; const dbReady = @@ -488,10 +492,17 @@ export function onMessageReceivedController(runtime) { const context = runtime.getContext(); const chat = context?.chat; + const receivedMessage = + Array.isArray(chat) && Number.isFinite(Number(messageId)) + ? chat[Number(messageId)] + : null; const lastMessage = Array.isArray(chat) && chat.length > 0 ? chat[chat.length - 1] : null; + const targetMessage = runtime.isAssistantChatMessage(receivedMessage) + ? receivedMessage + : lastMessage; - if (runtime.isAssistantChatMessage(lastMessage)) { + if (runtime.isAssistantChatMessage(targetMessage)) { runtime.queueMicrotask(() => { void runtime.runExtraction().catch((error) => { runtime.console.error("[ST-BME] 异步自动提取失败:", error); diff --git a/extraction-controller.js b/extraction-controller.js index 588836c..9324128 100644 --- a/extraction-controller.js +++ b/extraction-controller.js @@ -125,11 +125,12 @@ export async function executeExtractionBatchController( } export async function runExtractionController(runtime) { - if (runtime.getIsExtracting() || !runtime.getCurrentGraph()) return; + if (runtime.getIsExtracting()) return; const settings = runtime.getSettings(); if (!settings.enabled) return; if (!runtime.ensureGraphMutationReady("自动提取", { notify: false })) { + runtime.deferAutoExtraction?.("graph-not-ready"); runtime.setLastExtractionStatus( "等待图谱加载", runtime.getGraphMutationBlockReason("自动提取"), @@ -138,7 +139,17 @@ export async function runExtractionController(runtime) { ); return; } - if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) return; + + if (!runtime.getCurrentGraph()) { + runtime.ensureCurrentGraphRuntimeState?.(); + } + + if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) { + if (runtime.getIsRecoveringHistory?.()) { + runtime.deferAutoExtraction?.("history-recovering"); + } + return; + } const context = runtime.getContext(); const chat = context.chat; diff --git a/index.js b/index.js index bbb05fe..2a83b3d 100644 --- a/index.js +++ b/index.js @@ -470,6 +470,7 @@ const RECALL_INPUT_RECORD_TTL_MS = 60000; const HISTORY_RECOVERY_SETTLE_MS = 80; const HISTORY_MUTATION_RETRY_DELAYS_MS = [80, 220, 500, 900]; const GRAPH_LOAD_RETRY_DELAYS_MS = [120, 450, 1200, 2500]; +const AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS = [120, 320, 800, 1600, 2800]; let runtimeStatus = createUiStatus("待命", "准备就绪", "idle"); let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle"); let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle"); @@ -491,6 +492,14 @@ let pendingHistoryRecoveryTrigger = ""; let pendingHistoryMutationCheckTimers = []; let pendingGraphLoadRetryTimer = null; let pendingGraphLoadRetryChatId = ""; +let pendingAutoExtractionTimer = null; +let pendingAutoExtraction = { + chatId: "", + messageId: null, + reason: "", + requestedAt: 0, + attempts: 0, +}; let skipBeforeCombineRecallUntil = 0; let lastPreGenerationRecallKey = ""; let lastPreGenerationRecallAt = 0; @@ -810,6 +819,18 @@ function applyGraphLoadState( dbReady, storageMode: "indexeddb", }); + + if (dbReady && isGraphLoadStateDbReady(loadState)) { + const enqueueMicrotask = + typeof globalThis.queueMicrotask === "function" + ? globalThis.queueMicrotask.bind(globalThis) + : (task) => Promise.resolve().then(task); + enqueueMicrotask(() => { + if (typeof maybeResumePendingAutoExtraction === "function") { + void maybeResumePendingAutoExtraction(`graph-ready:${loadState}`); + } + }); + } } function createAbortError(message = "操作已终止") { @@ -3487,6 +3508,148 @@ function isGraphLoadRetryPending(chatId = getCurrentChatId()) { ); } +function clearPendingAutoExtraction({ resetState = true } = {}) { + if (pendingAutoExtractionTimer) { + clearTimeout(pendingAutoExtractionTimer); + pendingAutoExtractionTimer = null; + } + + if (resetState) { + pendingAutoExtraction = { + chatId: "", + messageId: null, + reason: "", + requestedAt: 0, + attempts: 0, + }; + } +} + +function deferAutoExtraction( + reason = "auto-extraction-deferred", + { chatId = getCurrentChatId(), messageId = null, delayMs = null } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + clearPendingAutoExtraction(); + return { + scheduled: false, + reason: "missing-chat-id", + chatId: "", + }; + } + + const sameChat = normalizedChatId === pendingAutoExtraction.chatId; + const previousAttempts = sameChat + ? Math.max(0, Math.floor(Number(pendingAutoExtraction.attempts) || 0)) + : 0; + const nextAttempts = previousAttempts + 1; + const resolvedDelayMs = Number.isFinite(Number(delayMs)) + ? Math.max(0, Math.floor(Number(delayMs))) + : AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS[ + Math.min( + previousAttempts, + AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS.length - 1, + ) + ]; + + pendingAutoExtraction = { + chatId: normalizedChatId, + messageId: Number.isFinite(Number(messageId)) + ? Math.floor(Number(messageId)) + : sameChat + ? pendingAutoExtraction.messageId + : null, + reason: String(reason || "auto-extraction-deferred"), + requestedAt: + sameChat && pendingAutoExtraction.requestedAt > 0 + ? pendingAutoExtraction.requestedAt + : Date.now(), + attempts: nextAttempts, + }; + + if (pendingAutoExtractionTimer) { + clearTimeout(pendingAutoExtractionTimer); + } + + pendingAutoExtractionTimer = setTimeout(() => { + pendingAutoExtractionTimer = null; + void maybeResumePendingAutoExtraction( + `retry:${pendingAutoExtraction.reason || "auto-extraction-deferred"}`, + ); + }, resolvedDelayMs); + + return { + scheduled: true, + chatId: normalizedChatId, + messageId: pendingAutoExtraction.messageId, + reason: pendingAutoExtraction.reason, + attempts: nextAttempts, + delayMs: resolvedDelayMs, + }; +} + +function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") { + const pendingChatId = normalizeChatIdCandidate(pendingAutoExtraction.chatId); + if (!pendingChatId) { + return { + resumed: false, + reason: "no-pending-auto-extraction", + }; + } + + const currentChatId = normalizeChatIdCandidate(getCurrentChatId()); + if (!currentChatId || currentChatId !== pendingChatId) { + clearPendingAutoExtraction(); + return { + resumed: false, + reason: "chat-switched", + chatId: pendingChatId, + currentChatId, + }; + } + + if (isExtracting) { + return deferAutoExtraction("extracting", { + chatId: pendingChatId, + messageId: pendingAutoExtraction.messageId, + }); + } + + if (isRecoveringHistory) { + return deferAutoExtraction("history-recovering", { + chatId: pendingChatId, + messageId: pendingAutoExtraction.messageId, + }); + } + + if (!ensureGraphMutationReady("自动提取", { notify: false })) { + return deferAutoExtraction("graph-not-ready", { + chatId: pendingChatId, + messageId: pendingAutoExtraction.messageId, + }); + } + + const pendingRequest = { ...pendingAutoExtraction }; + clearPendingAutoExtraction(); + const enqueueMicrotask = + typeof globalThis.queueMicrotask === "function" + ? globalThis.queueMicrotask.bind(globalThis) + : (task) => Promise.resolve().then(task); + enqueueMicrotask(() => { + void runExtraction().catch((error) => { + console.error("[ST-BME] 延迟自动提取失败:", error); + notifyExtractionIssue(error?.message || String(error) || "自动提取失败"); + }); + }); + + return { + resumed: true, + source, + ...pendingRequest, + }; +} + function isGraphEffectivelyEmpty(graph) { if (!graph || typeof graph !== "object") { return true; @@ -7296,6 +7459,15 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { } finally { finishStageAbortController("history", historyController); isRecoveringHistory = false; + const enqueueMicrotask = + typeof globalThis.queueMicrotask === "function" + ? globalThis.queueMicrotask.bind(globalThis) + : (task) => Promise.resolve().then(task); + enqueueMicrotask(() => { + if (typeof maybeResumePendingAutoExtraction === "function") { + void maybeResumePendingAutoExtraction("history-recovery-finished"); + } + }); } } @@ -7307,6 +7479,8 @@ async function runExtraction() { beginStageAbortController, clampInt, console, + deferAutoExtraction, + ensureCurrentGraphRuntimeState, ensureGraphMutationReady, executeExtractionBatch, finishStageAbortController, @@ -7315,6 +7489,7 @@ async function runExtraction() { getCurrentGraph: () => currentGraph, getGraphMutationBlockReason, getIsExtracting: () => isExtracting, + getIsRecoveringHistory: () => isRecoveringHistory, getLastProcessedAssistantFloor, getSettings, getSmartTriggerDecision, @@ -7468,6 +7643,7 @@ function onChatChanged() { clearCoreEventBindingState, clearGenerationRecallTransactionsForChat, clearInjectionState, + clearPendingAutoExtraction, clearPendingGraphLoadRetry, clearPendingHistoryMutationChecks, clearRecallInputTracking, @@ -7635,7 +7811,7 @@ async function onBeforeCombinePrompts(promptData = null) { ); } -function onMessageReceived() { +function onMessageReceived(messageId = null, type = "") { return onMessageReceivedController({ console, createRecallInputRecord, @@ -7660,7 +7836,7 @@ function onMessageReceived() { setPendingRecallSendIntent: (record) => { pendingRecallSendIntent = record; }, - }); + }, messageId, type); } // ==================== UI 操作 ==================== diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index e1e596f..02a8e3e 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -73,7 +73,7 @@ const persistenceCore = extractSnippet( "function handleGraphShadowSnapshotPageHide() {", ); const messageSnippet = extractSnippet( - "function onMessageReceived() {", + 'function onMessageReceived(messageId = null, type = "") {', "// ==================== UI 操作 ====================", ); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index b1946fa..974d140 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -7,11 +7,15 @@ import vm from "node:vm"; import { pruneProcessedMessageHashesFromFloor } from "../chat-history.js"; import { onBeforeCombinePromptsController, + onChatChangedController, onGenerationAfterCommandsController, onGenerationStartedController, registerCoreEventHooksController, } from "../event-binding.js"; -import { onRerollController } from "../extraction-controller.js"; +import { + onRerollController, + runExtractionController, +} from "../extraction-controller.js"; import { GRAPH_LOAD_STATES, GRAPH_METADATA_KEY, @@ -249,7 +253,9 @@ function createGenerationRecallHarness(options = {}) { const { realApplyFinal = false } = options; return fs.readFile(indexPath, "utf8").then((source) => { const start = source.indexOf("const RECALL_INPUT_RECORD_TTL_MS = 60000;"); - const end = source.indexOf("function onMessageReceived() {"); + const end = source.indexOf( + 'function onMessageReceived(messageId = null, type = "") {', + ); if (start < 0 || end < 0 || end <= start) { throw new Error("无法从 index.js 提取生成召回事务定义"); } @@ -2791,6 +2797,86 @@ async function testRegisterCoreEventHooksIsIdempotent() { assert.equal(bindingState.registered, true); } +async function testChatChangedDoesNotClearCoreEventBindings() { + let clearCoreBindingsCalls = 0; + let clearPendingAutoExtractionCalls = 0; + + onChatChangedController({ + clearCoreEventBindingState() { + clearCoreBindingsCalls += 1; + }, + clearPendingHistoryMutationChecks() {}, + clearTimeout() {}, + getPendingHistoryRecoveryTimer: () => null, + setPendingHistoryRecoveryTimer() {}, + setPendingHistoryRecoveryTrigger() {}, + clearPendingAutoExtraction() { + clearPendingAutoExtractionCalls += 1; + }, + clearPendingGraphLoadRetry() {}, + setSkipBeforeCombineRecallUntil() {}, + setLastPreGenerationRecallKey() {}, + setLastPreGenerationRecallAt() {}, + clearGenerationRecallTransactionsForChat() {}, + abortAllRunningStages() {}, + dismissAllStageNotices() {}, + syncGraphLoadFromLiveContext() {}, + clearInjectionState() {}, + clearRecallInputTracking() {}, + installSendIntentHooks() {}, + refreshPersistedRecallMessageUi() {}, + }); + + assert.equal( + clearCoreBindingsCalls, + 0, + "聊天切换不应清空核心事件监听,否则后续自动链会失联", + ); + assert.equal(clearPendingAutoExtractionCalls, 1); +} + +async function testAutoExtractionDefersWhenGraphNotReady() { + const deferredReasons = []; + const statuses = []; + + await runExtractionController({ + getIsExtracting: () => false, + getCurrentGraph: () => null, + getSettings: () => ({ enabled: true }), + ensureGraphMutationReady: () => false, + deferAutoExtraction(reason) { + deferredReasons.push(reason); + }, + setLastExtractionStatus(...args) { + statuses.push(args); + }, + getGraphMutationBlockReason: () => + "自动提取已暂停:正在加载 IndexedDB 图谱。", + }); + + assert.deepEqual(deferredReasons, ["graph-not-ready"]); + assert.equal(statuses[0]?.[0], "等待图谱加载"); +} + +async function testAutoExtractionDefersWhenHistoryRecoveryBusy() { + const deferredReasons = []; + + await runExtractionController({ + getIsExtracting: () => false, + getCurrentGraph: () => ({}), + getSettings: () => ({ enabled: true }), + ensureGraphMutationReady: () => true, + ensureCurrentGraphRuntimeState() {}, + recoverHistoryIfNeeded: async () => false, + getIsRecoveringHistory: () => true, + deferAutoExtraction(reason) { + deferredReasons.push(reason); + }, + }); + + assert.deepEqual(deferredReasons, ["history-recovering"]); +} + async function testRemoveNodeHandlesCyclicChildGraph() { const graph = createEmptyGraph(); const nodeA = addNode( @@ -3888,6 +3974,9 @@ await testGenerationRecallDifferentKeyCanRunAgain(); await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine(); await testGenerationRecallSentMessageClearsStaleTransactionForSameKey(); await testRegisterCoreEventHooksIsIdempotent(); +await testChatChangedDoesNotClearCoreEventBindings(); +await testAutoExtractionDefersWhenGraphNotReady(); +await testAutoExtractionDefersWhenHistoryRecoveryBusy(); await testRemoveNodeHandlesCyclicChildGraph(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload();