diff --git a/chat-history.js b/chat-history.js index 4c657f6..161eed1 100644 --- a/chat-history.js +++ b/chat-history.js @@ -51,9 +51,9 @@ export function getAssistantTurns(chat) { const assistantTurns = []; // 从 index 1 开始:index 0 是角色卡首条消息(greeting),不参与提取 for (let index = 1; index < chat.length; index++) { - if (isAssistantChatMessage(chat[index], { index, chat })) { - assistantTurns.push(index); - } + if (!isAssistantChatMessage(chat[index], { index, chat })) continue; + if (!String(chat[index]?.mes ?? "").trim()) continue; + assistantTurns.push(index); } return assistantTurns; } @@ -75,10 +75,12 @@ export function buildExtractionMessages(chat, startIdx, endIdx, settings) { ) { const msg = chat[index]; if (isSystemMessageForExtraction(msg, { index, chat })) continue; + const content = sanitizePlannerMessageText(msg); + if (!String(content || "").trim()) continue; messages.push({ seq: index, role: msg.is_user ? "user" : "assistant", - content: sanitizePlannerMessageText(msg), + content, }); } diff --git a/event-binding.js b/event-binding.js index 8650016..bca60c2 100644 --- a/event-binding.js +++ b/event-binding.js @@ -685,6 +685,24 @@ export function onMessageReceivedController( dbReady, }, ); + if ( + runtime.getIsHostGenerationRunning?.() === true && + typeof runtime.deferAutoExtraction === "function" + ) { + runtime.console?.debug?.( + "[ST-BME] assistant message received during host generation, deferring auto extraction", + { + messageId: Number.isFinite(Number(targetMessageIndex)) + ? Number(targetMessageIndex) + : null, + }, + ); + runtime.deferAutoExtraction("generation-running", { + messageId: targetMessageIndex, + }); + runtime.refreshPersistedRecallMessageUi?.(); + return; + } enqueueMicrotask(() => { void runtime.runExtraction().catch((error) => { runtime.console.error("[ST-BME] 异步自动提取失败:", error); diff --git a/graph.js b/graph.js index 5e12e55..59b780c 100644 --- a/graph.js +++ b/graph.js @@ -6,6 +6,7 @@ import { createDefaultHistoryState, createDefaultVectorIndexState, normalizeGraphRuntimeState, + PROCESSED_MESSAGE_HASH_VERSION, } from "./runtime-state.js"; import { hasSameScopeIdentity, @@ -717,7 +718,10 @@ export function importGraph(json) { node.embedding = null; } graph.batchJournal = createDefaultBatchJournal(); + graph.historyState.processedMessageHashVersion = + PROCESSED_MESSAGE_HASH_VERSION; graph.historyState.processedMessageHashes = {}; + graph.historyState.processedMessageHashesNeedRefresh = true; graph.historyState.historyDirtyFrom = null; graph.vectorIndexState.hashToNodeId = {}; graph.vectorIndexState.nodeToHash = {}; diff --git a/index.js b/index.js index fa7ead1..30e3247 100644 --- a/index.js +++ b/index.js @@ -181,6 +181,7 @@ import { markHistoryDirty, normalizeGraphRuntimeState, PROCESSED_MESSAGE_HASH_VERSION, + rebindProcessedHistoryStateToChat, snapshotProcessedMessageHashes, undoLatestMaintenance, } from "./runtime-state.js"; @@ -546,6 +547,7 @@ 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]; +const AUTO_EXTRACTION_HOST_SETTLE_MS = 120; let runtimeStatus = createUiStatus("待命", "准备就绪", "idle"); let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle"); let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle"); @@ -576,6 +578,8 @@ let pendingAutoExtraction = { requestedAt: 0, attempts: 0, }; +let isHostGenerationRunning = false; +let lastHostGenerationEndedAt = 0; let skipBeforeCombineRecallUntil = 0; let lastPreGenerationRecallKey = ""; let lastPreGenerationRecallAt = 0; @@ -3788,6 +3792,178 @@ function resolveCompatibleGraphShadowSnapshot( }); } +function createShadowComparisonGraph({ + chatId = "", + revision = 0, + integrity = "", +} = {}) { + const graph = createEmptyGraph(); + stampGraphPersistenceMeta(graph, { + revision: Math.max(0, normalizeIndexedDbRevision(revision)), + chatId: String(chatId || ""), + integrity: String(integrity || ""), + reason: "shadow-compare-reference", + }); + return graph; +} + +function applyShadowSnapshotToRuntime( + chatId, + shadowSnapshot, + { + source = "shadow-restore", + attemptIndex = 0, + promoteToIndexedDb = true, + } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate( + chatId || shadowSnapshot?.chatId, + ); + if (!normalizedChatId || !shadowSnapshot?.serializedGraph) { + return { + success: false, + loaded: false, + loadState: graphPersistenceState.loadState, + reason: "shadow-invalid", + chatId: normalizedChatId || "", + attemptIndex, + }; + } + + let shadowGraph = null; + try { + shadowGraph = cloneGraphForPersistence( + normalizeGraphRuntimeState( + deserializeGraph(shadowSnapshot.serializedGraph), + normalizedChatId, + ), + normalizedChatId, + ); + } catch (error) { + console.warn("[ST-BME] shadow snapshot 恢复失败:", error); + return { + success: false, + loaded: false, + loadState: graphPersistenceState.loadState, + reason: "shadow-deserialize-failed", + detail: error?.message || String(error), + chatId: normalizedChatId, + attemptIndex, + }; + } + + const shadowRevision = Math.max( + 1, + normalizeIndexedDbRevision(shadowSnapshot.revision), + ); + stampGraphPersistenceMeta(shadowGraph, { + revision: shadowRevision, + reason: `shadow:${String(source || "shadow-restore")}`, + chatId: normalizedChatId, + integrity: + String(shadowSnapshot.integrity || "").trim() || + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, + }); + + currentGraph = shadowGraph; + extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) + ? currentGraph.historyState.extractionCount + : 0; + lastExtractedItems = []; + const restoredRecallUi = restoreRecallUiStateFromPersistence( + getContext()?.chat, + ); + runtimeStatus = createUiStatus( + "图谱临时恢复", + "已从本次会话临时快照恢复最近图谱,正在补写 IndexedDB", + "warning", + ); + lastExtractionStatus = createUiStatus( + "待命", + "已从会话快照恢复最近图谱,等待下一次提取", + "idle", + ); + lastVectorStatus = createUiStatus( + "待命", + currentGraph.vectorIndexState?.lastWarning || + "已从会话快照恢复最近图谱,等待下一次向量任务", + "idle", + ); + lastRecallStatus = createUiStatus( + "待命", + restoredRecallUi.restored + ? "已从持久化召回记录恢复显示,并已恢复最近图谱" + : "已从会话快照恢复最近图谱,等待下一次召回", + "idle", + ); + + applyGraphLoadState(GRAPH_LOAD_STATES.SHADOW_RESTORED, { + chatId: normalizedChatId, + reason: `shadow:${String(source || "shadow-restore")}`, + attemptIndex, + revision: shadowRevision, + lastPersistedRevision: Math.max( + normalizeIndexedDbRevision(graphPersistenceState.lastPersistedRevision), + shadowRevision, + ), + queuedPersistRevision: Math.max( + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + shadowRevision, + ), + queuedPersistChatId: normalizedChatId, + pendingPersist: Boolean(promoteToIndexedDb), + shadowSnapshotUsed: true, + shadowSnapshotRevision: shadowRevision, + shadowSnapshotUpdatedAt: String(shadowSnapshot.updatedAt || ""), + shadowSnapshotReason: String( + shadowSnapshot.debugReason || shadowSnapshot.reason || source || "", + ), + dbReady: true, + writesBlocked: false, + }); + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: true, + indexedDbLastError: "", + metadataIntegrity: + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, + dualWriteLastResult: { + action: "load", + source: `${String(source || "shadow-restore")}:shadow`, + success: true, + provisional: true, + revision: shadowRevision, + resultCode: "graph.load.shadow-restored", + reason: `shadow:${String(source || "shadow-restore")}`, + at: Date.now(), + }, + }); + rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); + + if (promoteToIndexedDb) { + queueGraphPersistToIndexedDb(normalizedChatId, currentGraph, { + revision: shadowRevision, + reason: `shadow-restore-promote:${String(source || "shadow-restore")}`, + }); + } + + refreshPanelLiveState(); + schedulePersistedRecallMessageUiRefresh(30); + return { + success: true, + loaded: true, + loadState: GRAPH_LOAD_STATES.SHADOW_RESTORED, + reason: `shadow:${String(source || "shadow-restore")}`, + chatId: normalizedChatId, + attemptIndex, + revision: shadowRevision, + shadowRestored: true, + }; +} + async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) { const action = String(syncPayload?.action || "") .trim() @@ -4924,10 +5100,26 @@ async function loadGraphFromIndexedDb( identityRecoveryResult?.snapshot || migrationResult?.snapshot || (await db.exportSnapshot()); + const shadowSnapshot = resolveCompatibleGraphShadowSnapshot( + resolveCurrentChatIdentity(getContext()), + ); cacheIndexedDbSnapshot(normalizedChatId, snapshot); if (!isIndexedDbSnapshotMeaningful(snapshot)) { + if (shadowSnapshot) { + const shadowRestoreResult = applyShadowSnapshotToRuntime( + normalizedChatId, + shadowSnapshot, + { + source: `${source}:shadow-indexeddb-empty`, + attemptIndex, + }, + ); + if (shadowRestoreResult?.loaded) { + return shadowRestoreResult; + } + } if (applyEmptyState && getCurrentChatId() === normalizedChatId) { return applyIndexedDbEmptyToRuntime(normalizedChatId, { source, @@ -4946,6 +5138,39 @@ async function loadGraphFromIndexedDb( const snapshotRevision = normalizeIndexedDbRevision( snapshot?.meta?.revision, ); + const snapshotIntegrity = String(snapshot?.meta?.integrity || "").trim(); + const shadowDecision = shouldPreferShadowSnapshotOverOfficial( + createShadowComparisonGraph({ + chatId: normalizedChatId, + revision: snapshotRevision, + integrity: snapshotIntegrity, + }), + shadowSnapshot, + ); + if (shadowSnapshot && shadowDecision?.reason) { + updateGraphPersistenceState({ + dualWriteLastResult: { + action: "shadow-compare", + source: `${source}:indexeddb-shadow-compare`, + success: Boolean(shadowDecision.prefer), + reason: shadowDecision.reason, + resultCode: String(shadowDecision.resultCode || ""), + shadowRevision: Number(shadowSnapshot.revision || 0), + officialRevision: snapshotRevision, + at: Date.now(), + }, + }); + } + if (shadowSnapshot && shadowDecision?.prefer) { + return applyShadowSnapshotToRuntime( + normalizedChatId, + shadowSnapshot, + { + source: `${source}:shadow-newer-than-indexeddb`, + attemptIndex, + }, + ); + } const shouldAllowOverride = allowOverride || BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has( @@ -5193,7 +5418,10 @@ function deferAutoExtraction( ? Math.max(0, Math.floor(Number(pendingAutoExtraction.attempts) || 0)) : 0; const nextAttempts = previousAttempts + 1; - const resolvedDelayMs = Number.isFinite(Number(delayMs)) + const resolvedDelayMs = + delayMs !== null && + delayMs !== undefined && + Number.isFinite(Number(delayMs)) ? Math.max(0, Math.floor(Number(delayMs))) : AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS[ Math.min( @@ -5272,6 +5500,26 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") { }); } + if (isHostGenerationRunning) { + return deferAutoExtraction("generation-running", { + chatId: pendingChatId, + messageId: pendingAutoExtraction.messageId, + }); + } + + const hostGenerationSettleRemainingMs = + lastHostGenerationEndedAt > 0 + ? AUTO_EXTRACTION_HOST_SETTLE_MS - + (Date.now() - lastHostGenerationEndedAt) + : 0; + if (hostGenerationSettleRemainingMs > 0) { + return deferAutoExtraction("generation-settling", { + chatId: pendingChatId, + messageId: pendingAutoExtraction.messageId, + delayMs: hostGenerationSettleRemainingMs, + }); + } + if (isRecoveringHistory) { return deferAutoExtraction("history-recovering", { chatId: pendingChatId, @@ -5295,6 +5543,31 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") { }); } + const resumeContext = getContext(); + const resumeChat = resumeContext?.chat; + if ( + Array.isArray(resumeChat) && + Number.isFinite(Number(pendingAutoExtraction.messageId)) + ) { + const pendingMessageIndex = Math.floor( + Number(pendingAutoExtraction.messageId), + ); + const pendingMessage = resumeChat[pendingMessageIndex]; + if ( + isAssistantChatMessage(pendingMessage, { + index: pendingMessageIndex, + chat: resumeChat, + }) && + !String(pendingMessage?.mes ?? "").trim() + ) { + return deferAutoExtraction("assistant-message-empty", { + chatId: pendingChatId, + messageId: pendingMessageIndex, + delayMs: AUTO_EXTRACTION_HOST_SETTLE_MS, + }); + } + } + const pendingRequest = { ...pendingAutoExtraction }; clearPendingAutoExtraction(); console.debug?.("[ST-BME] resuming pending auto extraction", { @@ -6607,6 +6880,7 @@ function loadGraphFromChat(options = {}) { const context = getContext(); const chatIdentity = resolveCurrentChatIdentity(context); const chatId = chatIdentity.chatId; + const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(chatIdentity); const normalizedExpectedChatId = String(expectedChatId || ""); if (attemptIndex === 0) { clearPendingGraphLoadRetry(); @@ -6757,7 +7031,6 @@ function loadGraphFromChat(options = {}) { normalizeGraphRuntimeState(deserializeGraph(savedData), chatId), chatId, ); - const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(chatIdentity); const shadowDecision = shouldPreferShadowSnapshotOverOfficial( officialGraph, shadowSnapshot, @@ -6827,6 +7100,14 @@ function loadGraphFromChat(options = {}) { }); } + if (shadowSnapshot && shadowDecision?.prefer) { + clearPendingGraphLoadRetry(); + return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { + source: `${source}:metadata-shadow`, + attemptIndex, + }); + } + clearPendingGraphLoadRetry(); currentGraph = officialGraph; stampGraphPersistenceMeta(currentGraph, { @@ -6929,6 +7210,14 @@ function loadGraphFromChat(options = {}) { } } + if (shadowSnapshot) { + clearPendingGraphLoadRetry(); + return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, { + source: `${source}:shadow-no-official`, + attemptIndex, + }); + } + applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, { chatId, reason: `indexeddb-probe-pending:${String(source || "direct-load")}`, @@ -7043,6 +7332,37 @@ async function saveGraphToIndexedDb( at: Date.now(), }, }); + if ( + graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED && + areChatIdsEquivalentForResolvedIdentity( + normalizedChatId, + graphPersistenceState.chatId || getCurrentChatId(), + ) + ) { + applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { + chatId: normalizedChatId, + reason: `shadow-promoted:${String(reason || "graph-save")}`, + revision: snapshot.meta.revision, + lastPersistedRevision: snapshot.meta.revision, + queuedPersistRevision: 0, + queuedPersistChatId: "", + pendingPersist: false, + shadowSnapshotUsed: true, + shadowSnapshotRevision: Math.max( + Number(graphPersistenceState.shadowSnapshotRevision || 0), + snapshot.meta.revision, + ), + shadowSnapshotUpdatedAt: String( + graphPersistenceState.shadowSnapshotUpdatedAt || "", + ), + shadowSnapshotReason: String( + graphPersistenceState.shadowSnapshotReason || + "shadow-restore-promoted", + ), + dbReady: true, + writesBlocked: false, + }); + } rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); return { @@ -7268,11 +7588,23 @@ function saveGraphToChat(options = {}) { } function handleGraphShadowSnapshotPageHide() { + saveGraphToChat({ + reason: "pagehide-passive-persist", + markMutation: false, + captureShadow: true, + immediate: false, + }); maybeCaptureGraphShadowSnapshot("pagehide"); } function handleGraphShadowSnapshotVisibilityChange() { if (document.visibilityState === "hidden") { + saveGraphToChat({ + reason: "visibility-hidden-passive-persist", + markMutation: false, + captureShadow: true, + immediate: false, + }); maybeCaptureGraphShadowSnapshot("visibility-hidden"); } } @@ -9013,9 +9345,10 @@ function inspectHistoryMutation( Array.isArray(chat) && currentGraph.historyState?.processedMessageHashesNeedRefresh === true ) { - updateProcessedHistorySnapshot( + rebindProcessedHistoryStateToChat( + currentGraph, chat, - currentGraph.historyState.lastProcessedAssistantFloor ?? -1, + getAssistantTurns(chat), ); console.debug?.( "[ST-BME] refreshed processed message hashes after hash-version migration", @@ -10104,6 +10437,8 @@ async function runRecall(options = {}) { // ==================== 事件钩子 ==================== function onChatChanged() { + isHostGenerationRunning = false; + lastHostGenerationEndedAt = 0; if (typeof clearMessageHideState === "function") { clearMessageHideState("chat-changed"); } @@ -10211,13 +10546,15 @@ function onUserMessageRendered(messageId = null) { } function onCharacterMessageRendered(messageId = null, type = "") { - return onCharacterMessageRenderedController( + const result = onCharacterMessageRenderedController( { refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, }, messageId, type, ); + void maybeResumePendingAutoExtraction("character-message-rendered"); + return result; } function onMessageDeleted(chatLengthOrMessageId, meta = null) { @@ -10270,6 +10607,16 @@ async function onMessageSwiped(messageId, meta = null) { } function onGenerationStarted(type, params = {}, dryRun = false) { + const generationType = String(type || "normal").trim() || "normal"; + if ( + !dryRun && + !params?.automatic_trigger && + !params?.quiet_prompt && + generationType === "normal" + ) { + isHostGenerationRunning = true; + lastHostGenerationEndedAt = 0; + } return onGenerationStartedController( { clearDryRunPromptPreview, @@ -10293,6 +10640,8 @@ function onGenerationStarted(type, params = {}, dryRun = false) { } function onGenerationEnded(_chatLength = null) { + isHostGenerationRunning = false; + lastHostGenerationEndedAt = Date.now(); const recentTransaction = findRecentGenerationRecallTransactionForChat(); const recentRecallResult = getGenerationRecallTransactionResult(recentTransaction); @@ -10307,6 +10656,7 @@ function onGenerationEnded(_chatLength = null) { "", }); schedulePersistedRecallMessageUiRefresh(320); + void maybeResumePendingAutoExtraction("generation-ended"); if (typeof scheduleMessageHideApply === "function") { scheduleMessageHideApply("generation-ended", 180); } @@ -10362,9 +10712,11 @@ function onMessageReceived(messageId = null, type = "") { console, consumeCurrentGenerationTrivialSkip, createRecallInputRecord, + deferAutoExtraction, getContext, getCurrentGraph: () => currentGraph, getGraphPersistenceState: () => graphPersistenceState, + getIsHostGenerationRunning: () => isHostGenerationRunning, getPendingHostGenerationInputSnapshot, getPendingRecallSendIntent: () => pendingRecallSendIntent, isAssistantChatMessage, @@ -10519,10 +10871,13 @@ async function onImportGraph() { clearTimeout, document, ensureGraphMutationReady, + getAssistantTurns, + getContext, getCurrentChatId, importGraph, markVectorStateDirty, normalizeGraphRuntimeState, + rebindProcessedHistoryStateToChat, saveGraphToChat, setCurrentGraph: (graph) => { currentGraph = graph; diff --git a/runtime-state.js b/runtime-state.js index 9dbf7d4..61bbbdf 100644 --- a/runtime-state.js +++ b/runtime-state.js @@ -278,6 +278,72 @@ export function snapshotProcessedMessageHashes( return result; } +export function rebindProcessedHistoryStateToChat( + graph, + chat, + assistantTurns = [], +) { + if (!graph || typeof graph !== "object") { + return { + rebound: false, + reason: "missing-graph", + lastProcessedAssistantFloor: -1, + maxAssistantFloor: -1, + clamped: false, + }; + } + + const historyState = + graph.historyState && typeof graph.historyState === "object" + ? graph.historyState + : createDefaultHistoryState(); + graph.historyState = historyState; + + const normalizedAssistantTurns = Array.isArray(assistantTurns) + ? assistantTurns + .map((value) => Number.parseInt(value, 10)) + .filter(Number.isFinite) + .sort((a, b) => a - b) + : []; + const maxAssistantFloor = + normalizedAssistantTurns.length > 0 + ? normalizedAssistantTurns[normalizedAssistantTurns.length - 1] + : -1; + const rawLastProcessedAssistantFloor = Number.isFinite( + historyState.lastProcessedAssistantFloor, + ) + ? Math.floor(historyState.lastProcessedAssistantFloor) + : -1; + + let safeLastProcessedAssistantFloor = rawLastProcessedAssistantFloor; + if (!Array.isArray(chat) || chat.length === 0 || maxAssistantFloor < 0) { + safeLastProcessedAssistantFloor = -1; + } else if (safeLastProcessedAssistantFloor > maxAssistantFloor) { + safeLastProcessedAssistantFloor = maxAssistantFloor; + } + + historyState.lastProcessedAssistantFloor = safeLastProcessedAssistantFloor; + historyState.processedMessageHashVersion = PROCESSED_MESSAGE_HASH_VERSION; + historyState.processedMessageHashes = + safeLastProcessedAssistantFloor >= 0 + ? snapshotProcessedMessageHashes(chat, safeLastProcessedAssistantFloor) + : {}; + historyState.processedMessageHashesNeedRefresh = false; + graph.lastProcessedSeq = safeLastProcessedAssistantFloor; + + return { + rebound: true, + reason: + safeLastProcessedAssistantFloor < 0 + ? "no-processed-assistant-floor" + : "ok", + lastProcessedAssistantFloor: safeLastProcessedAssistantFloor, + maxAssistantFloor, + clamped: + safeLastProcessedAssistantFloor !== rawLastProcessedAssistantFloor, + }; +} + export function detectHistoryMutation(chat, historyState) { const lastProcessedAssistantFloor = historyState?.lastProcessedAssistantFloor ?? -1; diff --git a/tests/chat-history.mjs b/tests/chat-history.mjs index 0b1b6a3..7a06920 100644 --- a/tests/chat-history.mjs +++ b/tests/chat-history.mjs @@ -87,6 +87,34 @@ assert.deepEqual( "extraction should keep BME-managed hidden context but still skip real system messages", ); +const blankAssistantChat = [ + { is_user: false, is_system: true, mes: "greeting/system" }, + { is_user: true, is_system: false, mes: "user-1" }, + { is_user: false, is_system: false, mes: " " }, + { is_user: true, is_system: false, mes: "secret" }, + { is_user: false, is_system: false, mes: "assistant-2" }, +]; + +assert.deepEqual( + getAssistantTurns(blankAssistantChat), + [4], + "blank assistant floors should not be treated as extractable turns", +); +assert.deepEqual( + buildExtractionMessages(blankAssistantChat, 4, 4, { + extractContextTurns: 3, + }).map((message) => ({ + seq: message.seq, + role: message.role, + content: message.content, + })), + [ + { seq: 1, role: "user", content: "user-1" }, + { seq: 4, role: "assistant", content: "assistant-2" }, + ], + "blank assistant text and planner-tag-only user text should be skipped", +); + resetHideState(); const autoHiddenChat = [ { is_user: false, is_system: true, mes: "greeting/system" }, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 98417b4..39c5d2d 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -1641,13 +1641,16 @@ result = { source: "shadow-test", }); - assert.equal(result.loadState, "loading"); - assert.equal(reader.api.getCurrentGraph(), null); + assert.equal(result.loadState, "shadow-restored"); + assert.equal( + reader.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-shadow", + ); assert.equal( reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed, - false, + true, ); - assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, true); + assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, false); } { @@ -1949,7 +1952,7 @@ result = { }); const live = reader.api.getGraphPersistenceLiveState(); - assert.equal(result.loadState, "loading"); + assert.equal(result.loadState, "shadow-restored"); assert.equal( reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes ?.length, @@ -1961,8 +1964,8 @@ result = { ); assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0); assert.equal(reader.runtimeContext.__contextSaveCalls, 0); - assert.equal(live.lastPersistedRevision, 0); - assert.equal(live.pendingPersist, false); + assert.equal(live.lastPersistedRevision, 9); + assert.equal(live.pendingPersist, true); } { @@ -2102,7 +2105,7 @@ result = { source: "load-shadow-decoupled", }); - assert.equal(result.loadState, "loading"); + assert.equal(result.loadState, "shadow-restored"); const runtimeGraph = reader.api.getCurrentGraph(); const persistedGraph = reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph; @@ -2113,6 +2116,10 @@ result = { ); runtimeGraph.nodes[0].fields.title = "runtime-shadow-mutated"; + assert.equal( + runtimeGraph.nodes[0].fields.title, + "runtime-shadow-mutated", + ); assert.equal( persistedGraph.nodes[0].fields.title, "事件-official-older", @@ -2355,6 +2362,65 @@ result = { ); } +{ + const sharedSession = new Map(); + const writer = await createGraphPersistenceHarness({ + chatId: "chat-indexeddb-shadow-restore", + globalChatId: "chat-indexeddb-shadow-restore", + sessionStore: sharedSession, + }); + writer.api.writeGraphShadowSnapshot( + "chat-indexeddb-shadow-restore", + createMeaningfulGraph("chat-indexeddb-shadow-restore", "shadow-newer"), + { + revision: 9, + reason: "pagehide-refresh", + }, + ); + + const indexedDbGraph = stampPersistedGraph( + createMeaningfulGraph("chat-indexeddb-shadow-restore", "indexeddb-older"), + { + revision: 4, + integrity: "meta-indexeddb-shadow-restore", + chatId: "chat-indexeddb-shadow-restore", + reason: "indexeddb-older", + }, + ); + const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, { + chatId: "chat-indexeddb-shadow-restore", + revision: 4, + }); + + const harness = await createGraphPersistenceHarness({ + chatId: "chat-indexeddb-shadow-restore", + globalChatId: "chat-indexeddb-shadow-restore", + indexedDbSnapshot, + sessionStore: sharedSession, + }); + + const result = await harness.api.loadGraphFromIndexedDb( + "chat-indexeddb-shadow-restore", + { + source: "indexeddb-shadow-restore", + allowOverride: true, + applyEmptyState: true, + }, + ); + + assert.equal(result.loadState, "shadow-restored"); + assert.equal( + harness.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-shadow-newer", + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + harness.api.getIndexedDbSnapshot().meta.revision, + 9, + "shadow 恢复后应回补 IndexedDB 修正旧快照", + ); +} + { const legacyGraph = stampPersistedGraph( createMeaningfulGraph("chat-legacy-migration", "legacy"), diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index 215781a..0022e1e 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -140,6 +140,10 @@ export function createGenerationRecallHarness(options = {}) { recordedInjectionSnapshots: [], refreshPanelCalls: 0, hideScheduleCalls: [], + isExtracting: false, + isRecoveringHistory: false, + isAssistantChatMessage: (message) => + Boolean(message) && !message.is_user && !message.is_system, createRecallInputRecord, createRecallRunResult, hashRecallInput, @@ -215,7 +219,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, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, 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, 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, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`, context, { filename: indexPath }, ); @@ -322,9 +326,12 @@ export function createGenerationRecallHarness(options = {}) { consumeCurrentGenerationTrivialSkip: context.result.consumeCurrentGenerationTrivialSkip, createRecallInputRecord, + deferAutoExtraction: context.result.deferAutoExtraction, getContext: context.getContext, getCurrentGraph: () => context.currentGraph, getGraphPersistenceState: () => context.result.getGraphPersistenceState(), + getIsHostGenerationRunning: () => + context.result.getIsHostGenerationRunning(), getPendingHostGenerationInputSnapshot: context.result.getPendingHostGenerationInputSnapshot, getPendingRecallSendIntent: () => context.result.getPendingRecallSendIntent(), diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 04c9fa8..3805ef2 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -3716,6 +3716,89 @@ async function testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask() assert.equal(refreshCalls, 1); } +async function testMessageReceivedDefersExtractionDuringHostGeneration() { + let runExtractionCalls = 0; + const deferred = []; + + onMessageReceivedController( + { + getGraphPersistenceState: () => ({ loadState: "loaded", dbReady: true }), + getCurrentGraph: () => null, + getPendingRecallSendIntent: () => ({ text: "", at: 0 }), + getIsHostGenerationRunning: () => true, + isFreshRecallInputRecord: () => true, + createRecallInputRecord: () => ({ text: "", at: 0 }), + deferAutoExtraction(reason, meta = {}) { + deferred.push({ + reason, + messageId: Number.isFinite(Number(meta?.messageId)) + ? Number(meta.messageId) + : null, + }); + }, + setPendingRecallSendIntent() {}, + getContext: () => ({ + chat: [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ], + }), + isAssistantChatMessage(message) { + return Boolean(message) && !message.is_user && !message.is_system; + }, + runExtraction: async () => { + runExtractionCalls += 1; + }, + console: { + error() {}, + }, + notifyExtractionIssue() {}, + refreshPersistedRecallMessageUi() {}, + }, + 1, + "assistant", + ); + + await waitForTick(); + + assert.equal(runExtractionCalls, 0); + assert.deepEqual(deferred, [ + { + reason: "generation-running", + messageId: 1, + }, + ]); +} + +async function testGenerationEndedResumesPendingAutoExtractionAfterSettle() { + const harness = await createGenerationRecallHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "streaming response" }, + ]; + harness.result.setGraphPersistenceState({ + loadState: "loaded", + dbReady: true, + chatId: "chat-main", + }); + + harness.result.onGenerationStarted("normal", {}, false); + harness.invokeOnMessageReceived(1, "assistant"); + await waitForTick(); + + assert.equal(harness.runExtractionCalls.length, 0); + assert.equal( + harness.result.getPendingAutoExtraction().reason, + "generation-running", + ); + + harness.result.onGenerationEnded(); + await new Promise((resolve) => setTimeout(resolve, 180)); + + assert.equal(harness.runExtractionCalls.length, 1); + harness.result.clearPendingAutoExtraction(); +} + async function testAutoExtractionDefersWhenGraphNotReady() { const deferredReasons = []; const statuses = []; @@ -5671,6 +5754,8 @@ await testMessageSentFallsBackToLatestUserWhenHostMessageIdInvalid(); await testUserMessageRenderedRefreshesRecallUiAfterRealDomRender(); await testCharacterMessageRenderedRefreshesRecallUiAfterAssistantRender(); await testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask(); +await testMessageReceivedDefersExtractionDuringHostGeneration(); +await testGenerationEndedResumesPendingAutoExtractionAfterSettle(); await testAutoExtractionDefersWhenGraphNotReady(); await testAutoExtractionDefersWhenAlreadyExtracting(); await testAutoExtractionDefersWhenHistoryRecoveryBusy(); diff --git a/tests/runtime-history.mjs b/tests/runtime-history.mjs index f0d0981..529ffc7 100644 --- a/tests/runtime-history.mjs +++ b/tests/runtime-history.mjs @@ -7,6 +7,7 @@ import { findJournalRecoveryPoint, normalizeGraphRuntimeState, PROCESSED_MESSAGE_HASH_VERSION, + rebindProcessedHistoryStateToChat, rollbackBatch, snapshotProcessedMessageHashes, } from "../runtime-state.js"; @@ -94,6 +95,28 @@ assert.equal(migratedGraph.historyState.processedMessageHashesNeedRefresh, true) const migratedDetection = detectHistoryMutation(chat, migratedGraph.historyState); assert.equal(migratedDetection.dirty, false); +const importedGraph = normalizeGraphRuntimeState({ + historyState: { + chatId: "chat-history-test", + lastProcessedAssistantFloor: 99, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, + processedMessageHashes: {}, + processedMessageHashesNeedRefresh: true, + }, +}); +const reboundResult = rebindProcessedHistoryStateToChat(importedGraph, chat, [ + 1, + 3, +]); +assert.equal(reboundResult.rebound, true); +assert.equal(reboundResult.lastProcessedAssistantFloor, 3); +assert.equal(reboundResult.clamped, true); +assert.equal(importedGraph.historyState.processedMessageHashesNeedRefresh, false); +assert.deepEqual( + importedGraph.historyState.processedMessageHashes, + snapshotProcessedMessageHashes(chat, 3), +); + const truncatedChat = chat.slice(0, 2); const truncatedDetection = detectHistoryMutation(truncatedChat, { lastProcessedAssistantFloor: 3, diff --git a/ui-actions-controller.js b/ui-actions-controller.js index c792576..a6236bc 100644 --- a/ui-actions-controller.js +++ b/ui-actions-controller.js @@ -126,6 +126,35 @@ function updateManualActionUiState(runtime, text, meta = "", level = "idle") { runtime?.refreshPanelLiveState?.(); } +function rebindImportedGraphToCurrentChat(runtime, importedGraph) { + if (!importedGraph || typeof importedGraph !== "object") { + return { + rebound: false, + reason: "missing-graph", + }; + } + + const chat = runtime.getContext?.()?.chat; + const assistantTurns = + typeof runtime.getAssistantTurns === "function" && Array.isArray(chat) + ? runtime.getAssistantTurns(chat) + : []; + + if (typeof runtime.rebindProcessedHistoryStateToChat === "function") { + return runtime.rebindProcessedHistoryStateToChat( + importedGraph, + chat, + assistantTurns, + ); + } + + importedGraph.historyState.processedMessageHashesNeedRefresh = true; + return { + rebound: false, + reason: "missing-history-rebind-helper", + }; +} + export async function onViewGraphController(runtime) { const graph = runtime.getCurrentGraph(); if (!graph) { @@ -497,14 +526,24 @@ export async function onImportGraphController(runtime) { runtime.importGraph(text), runtime.getCurrentChatId(), ); + const historyRebind = rebindImportedGraphToCurrentChat( + runtime, + importedGraph, + ); runtime.setCurrentGraph(importedGraph); runtime.markVectorStateDirty("导入图谱后需要重建向量索引"); - runtime.setExtractionCount(0); + runtime.setExtractionCount( + Math.max(0, Number(importedGraph?.historyState?.extractionCount) || 0), + ); runtime.setLastExtractedItems([]); runtime.updateLastRecalledItems(importedGraph.lastRecallResult || []); runtime.clearInjectionState(); runtime.saveGraphToChat({ reason: "graph-import-complete" }); - runtime.toastr.success("图谱已导入"); + runtime.toastr.success( + historyRebind?.rebound === true + ? "图谱已导入,并已重新绑定当前聊天历史" + : "图谱已导入", + ); finish({ imported: true, handledToast: true }); } catch (err) { const error =