diff --git a/chat-history.js b/chat-history.js new file mode 100644 index 0000000..9f8e28c --- /dev/null +++ b/chat-history.js @@ -0,0 +1,189 @@ +// ST-BME: 聊天历史纯函数 +// 此模块中的函数均不依赖 index.js 模块级可变状态, +// 可被 index.js 及其他模块安全导入。 + +import { clampInt } from "./ui-status.js"; +import { rollbackBatch } from "./runtime-state.js"; + +export function isAssistantChatMessage(message) { + return Boolean(message) && !message.is_user && !message.is_system; +} + +export function getAssistantTurns(chat) { + const assistantTurns = []; + // 从 index 1 开始:index 0 是角色卡首条消息(greeting),不参与提取 + for (let index = 1; index < chat.length; index++) { + if (isAssistantChatMessage(chat[index])) { + assistantTurns.push(index); + } + } + return assistantTurns; +} + +export function getMinExtractableAssistantFloor(chat) { + const assistantTurns = getAssistantTurns(chat); + return assistantTurns.length > 0 ? assistantTurns[0] : null; +} + +export function buildExtractionMessages(chat, startIdx, endIdx, settings) { + const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20); + const contextStart = Math.max(0, startIdx - contextTurns * 2); + const messages = []; + + for ( + let index = contextStart; + index <= endIdx && index < chat.length; + index++ + ) { + const msg = chat[index]; + if (msg.is_system) continue; + messages.push({ + seq: index, + role: msg.is_user ? "user" : "assistant", + content: msg.mes || "", + }); + } + + return messages; +} + +export function getChatIndexForPlayableSeq(chat, playableSeq) { + if (!Array.isArray(chat) || !Number.isFinite(playableSeq)) return null; + + let currentSeq = -1; + for (let index = 0; index < chat.length; index++) { + const message = chat[index]; + if (message?.is_system) continue; + currentSeq++; + if (currentSeq >= playableSeq) { + return index; + } + } + + return chat.length; +} + +export function getChatIndexForAssistantSeq(chat, assistantSeq) { + if (!Array.isArray(chat) || !Number.isFinite(assistantSeq)) return null; + + let currentSeq = -1; + for (let index = 0; index < chat.length; index++) { + if (!isAssistantChatMessage(chat[index])) continue; + currentSeq++; + if (currentSeq >= assistantSeq) { + return index; + } + } + + return chat.length; +} + +export function resolveDirtyFloorFromMutationMeta(trigger, primaryArg, meta, chat) { + if (!meta || typeof meta !== "object") return null; + + const candidates = []; + const isDeleteTrigger = String(trigger || "").includes("message-deleted"); + const minExtractableFloor = getMinExtractableAssistantFloor(chat); + + // 删除后 chat 已是收缩后的状态,删除事件携带的 seq 更接近“被删区间起点”, + // 因此这里额外向前退一层,避免恢复仍停留在被删楼层对应的旧图谱边界。 + if (!isDeleteTrigger && Number.isFinite(meta.messageId)) { + candidates.push({ + floor: meta.messageId, + source: `${trigger}-meta`, + }); + } + if (Number.isFinite(meta.deletedPlayableSeqFrom)) { + const floor = getChatIndexForPlayableSeq(chat, meta.deletedPlayableSeqFrom); + if (Number.isFinite(floor)) { + candidates.push({ + floor: Number.isFinite(minExtractableFloor) + ? Math.max(minExtractableFloor, floor - 1) + : Math.max(0, floor - 1), + source: `${trigger}-meta-delete-boundary`, + }); + } + } + if (Number.isFinite(meta.deletedAssistantSeqFrom)) { + const floor = getChatIndexForAssistantSeq( + chat, + meta.deletedAssistantSeqFrom, + ); + if (Number.isFinite(floor)) { + candidates.push({ + floor: Number.isFinite(minExtractableFloor) + ? Math.max(minExtractableFloor, floor - 1) + : Math.max(0, floor - 1), + source: `${trigger}-meta-delete-boundary`, + }); + } + } + if (!isDeleteTrigger && Number.isFinite(meta.playableSeq)) { + const floor = getChatIndexForPlayableSeq(chat, meta.playableSeq); + if (Number.isFinite(floor)) { + candidates.push({ + floor, + source: `${trigger}-meta`, + }); + } + } + if (!isDeleteTrigger && Number.isFinite(meta.assistantSeq)) { + const floor = getChatIndexForAssistantSeq(chat, meta.assistantSeq); + if (Number.isFinite(floor)) { + candidates.push({ + floor, + source: `${trigger}-meta`, + }); + } + } + if (!isDeleteTrigger && Number.isFinite(primaryArg)) { + candidates.push({ + floor: primaryArg, + source: `${trigger}-meta`, + }); + } + + if (candidates.length === 0) return null; + const validCandidates = Number.isFinite(minExtractableFloor) + ? candidates.filter((c) => c.floor >= minExtractableFloor) + : candidates; + if (validCandidates.length === 0) return null; + return validCandidates.reduce((earliest, current) => + current.floor < earliest.floor ? current : earliest, + ); +} + +export function clampRecoveryStartFloor(chat, floor) { + if (!Number.isFinite(floor)) return floor; + + const minExtractableFloor = getMinExtractableAssistantFloor(chat); + if (!Number.isFinite(minExtractableFloor)) { + return floor; + } + + return Math.max(floor, minExtractableFloor); +} + +export function rollbackAffectedJournals(graph, affectedJournals = []) { + for (let index = affectedJournals.length - 1; index >= 0; index--) { + rollbackBatch(graph, affectedJournals[index]); + } + graph.batchJournal = Array.isArray(graph.batchJournal) + ? graph.batchJournal.slice( + 0, + Math.max(0, graph.batchJournal.length - affectedJournals.length), + ) + : []; +} + +export function pruneProcessedMessageHashesFromFloor(graph, fromFloor) { + if (!graph?.historyState?.processedMessageHashes) return; + if (!Number.isFinite(fromFloor)) return; + + const hashes = graph.historyState.processedMessageHashes; + for (const key of Object.keys(hashes)) { + if (Number(key) >= fromFloor) { + delete hashes[key]; + } + } +} diff --git a/index.js b/index.js index 000e173..4d92da7 100644 --- a/index.js +++ b/index.js @@ -123,6 +123,18 @@ import { writeChatMetadataPatch, writeGraphShadowSnapshot, } from "./graph-persistence.js"; +import { + buildExtractionMessages, + clampRecoveryStartFloor, + getAssistantTurns, + getChatIndexForAssistantSeq, + getChatIndexForPlayableSeq, + getMinExtractableAssistantFloor, + isAssistantChatMessage, + pruneProcessedMessageHashesFromFloor, + resolveDirtyFloorFromMutationMeta, + rollbackAffectedJournals, +} from "./chat-history.js"; // 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; @@ -3540,174 +3552,6 @@ async function handleExtractionSuccess( }; } -function isAssistantChatMessage(message) { - return Boolean(message) && !message.is_user && !message.is_system; -} - -function getAssistantTurns(chat) { - const assistantTurns = []; - // 从 index 1 开始:index 0 是角色卡首条消息(greeting),不参与提取 - for (let index = 1; index < chat.length; index++) { - if (isAssistantChatMessage(chat[index])) { - assistantTurns.push(index); - } - } - return assistantTurns; -} - -function buildExtractionMessages(chat, startIdx, endIdx, settings) { - const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20); - const contextStart = Math.max(0, startIdx - contextTurns * 2); - const messages = []; - - for ( - let index = contextStart; - index <= endIdx && index < chat.length; - index++ - ) { - const msg = chat[index]; - if (msg.is_system) continue; - messages.push({ - seq: index, - role: msg.is_user ? "user" : "assistant", - content: msg.mes || "", - }); - } - - return messages; -} - -function getChatIndexForPlayableSeq(chat, playableSeq) { - if (!Array.isArray(chat) || !Number.isFinite(playableSeq)) return null; - - let currentSeq = -1; - for (let index = 0; index < chat.length; index++) { - const message = chat[index]; - if (message?.is_system) continue; - currentSeq++; - if (currentSeq >= playableSeq) { - return index; - } - } - - return chat.length; -} - -function getChatIndexForAssistantSeq(chat, assistantSeq) { - if (!Array.isArray(chat) || !Number.isFinite(assistantSeq)) return null; - - let currentSeq = -1; - for (let index = 0; index < chat.length; index++) { - if (!isAssistantChatMessage(chat[index])) continue; - currentSeq++; - if (currentSeq >= assistantSeq) { - return index; - } - } - - return chat.length; -} - -function resolveDirtyFloorFromMutationMeta(trigger, primaryArg, meta, chat) { - if (!meta || typeof meta !== "object") return null; - - const candidates = []; - const isDeleteTrigger = String(trigger || "").includes("message-deleted"); - const minExtractableFloor = getMinExtractableAssistantFloor(chat); - - // 删除后 chat 已是收缩后的状态,删除事件携带的 seq 更接近“被删区间起点”, - // 因此这里额外向前退一层,避免恢复仍停留在被删楼层对应的旧图谱边界。 - if (!isDeleteTrigger && Number.isFinite(meta.messageId)) { - candidates.push({ - floor: meta.messageId, - source: `${trigger}-meta`, - }); - } - if (Number.isFinite(meta.deletedPlayableSeqFrom)) { - const floor = getChatIndexForPlayableSeq(chat, meta.deletedPlayableSeqFrom); - if (Number.isFinite(floor)) { - candidates.push({ - floor: Number.isFinite(minExtractableFloor) - ? Math.max(minExtractableFloor, floor - 1) - : Math.max(0, floor - 1), - source: `${trigger}-meta-delete-boundary`, - }); - } - } - if (Number.isFinite(meta.deletedAssistantSeqFrom)) { - const floor = getChatIndexForAssistantSeq( - chat, - meta.deletedAssistantSeqFrom, - ); - if (Number.isFinite(floor)) { - candidates.push({ - floor: Number.isFinite(minExtractableFloor) - ? Math.max(minExtractableFloor, floor - 1) - : Math.max(0, floor - 1), - source: `${trigger}-meta-delete-boundary`, - }); - } - } - if (!isDeleteTrigger && Number.isFinite(meta.playableSeq)) { - const floor = getChatIndexForPlayableSeq(chat, meta.playableSeq); - if (Number.isFinite(floor)) { - candidates.push({ - floor, - source: `${trigger}-meta`, - }); - } - } - if (!isDeleteTrigger && Number.isFinite(meta.assistantSeq)) { - const floor = getChatIndexForAssistantSeq(chat, meta.assistantSeq); - if (Number.isFinite(floor)) { - candidates.push({ - floor, - source: `${trigger}-meta`, - }); - } - } - if (!isDeleteTrigger && Number.isFinite(primaryArg)) { - candidates.push({ - floor: primaryArg, - source: `${trigger}-meta`, - }); - } - - if (candidates.length === 0) return null; - const validCandidates = Number.isFinite(minExtractableFloor) - ? candidates.filter((c) => c.floor >= minExtractableFloor) - : candidates; - if (validCandidates.length === 0) return null; - return validCandidates.reduce((earliest, current) => - current.floor < earliest.floor ? current : earliest, - ); -} - -function getLastProcessedAssistantFloor() { - ensureCurrentGraphRuntimeState(); - return Number.isFinite( - currentGraph?.historyState?.lastProcessedAssistantFloor, - ) - ? currentGraph.historyState.lastProcessedAssistantFloor - : -1; -} - -function getMinExtractableAssistantFloor(chat) { - const assistantTurns = getAssistantTurns(chat); - return assistantTurns.length > 0 ? assistantTurns[0] : null; -} - -function clampRecoveryStartFloor(chat, floor) { - if (!Number.isFinite(floor)) return floor; - - const minExtractableFloor = getMinExtractableAssistantFloor(chat); - if (!Number.isFinite(minExtractableFloor)) { - return floor; - } - - return Math.max(floor, minExtractableFloor); -} - function notifyHistoryDirty(dirtyFrom, reason) { updateStageNotice( "history", @@ -4152,30 +3996,6 @@ function applyRecoveryPlanToVectorState( : "历史恢复后需要修复受影响后缀的向量索引"; } -function rollbackAffectedJournals(graph, affectedJournals = []) { - for (let index = affectedJournals.length - 1; index >= 0; index--) { - rollbackBatch(graph, affectedJournals[index]); - } - graph.batchJournal = Array.isArray(graph.batchJournal) - ? graph.batchJournal.slice( - 0, - Math.max(0, graph.batchJournal.length - affectedJournals.length), - ) - : []; -} - -function pruneProcessedMessageHashesFromFloor(graph, fromFloor) { - if (!graph?.historyState?.processedMessageHashes) return; - if (!Number.isFinite(fromFloor)) return; - - const hashes = graph.historyState.processedMessageHashes; - for (const key of Object.keys(hashes)) { - if (Number(key) >= fromFloor) { - delete hashes[key]; - } - } -} - async function rollbackGraphForReroll(targetFloor, context = getContext()) { ensureCurrentGraphRuntimeState(); const chatId = getCurrentChatId(context); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 101676c..5a810f6 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -42,6 +42,17 @@ import { writeChatMetadataPatch, writeGraphShadowSnapshot, } from "../graph-persistence.js"; +import { + buildExtractionMessages, + clampRecoveryStartFloor, + getAssistantTurns, + getChatIndexForAssistantSeq, + getChatIndexForPlayableSeq, + getMinExtractableAssistantFloor, + isAssistantChatMessage, + pruneProcessedMessageHashesFromFloor, + rollbackAffectedJournals, +} from "../chat-history.js"; const extensionsShimSource = [ "export const extension_settings = globalThis.__p0ExtensionSettings || {};", @@ -180,7 +191,7 @@ const schema = [ function createBatchStageHarness() { return fs.readFile(indexPath, "utf8").then((source) => { - const marker = "function isAssistantChatMessage(message) {"; + const marker = "function notifyHistoryDirty(dirtyFrom, reason) {"; const start = source.indexOf("function shouldAdvanceProcessedHistory("); const end = source.indexOf(marker); if (start < 0 || end < 0 || end <= start) { @@ -309,26 +320,21 @@ function createGenerationRecallHarness() { function createRerollHarness() { return fs.readFile(indexPath, "utf8").then((source) => { - const helperStart = source.indexOf( - "function pruneProcessedMessageHashesFromFloor(", - ); - const helperEnd = source.indexOf("async function recoverHistoryIfNeeded("); + const rollbackStart = source.indexOf("async function rollbackGraphForReroll("); + const rollbackEnd = source.indexOf("async function recoverHistoryIfNeeded("); const rerollStart = source.indexOf("async function onReroll("); const rerollEnd = source.indexOf("async function onManualSleep()"); if ( - helperStart < 0 || - helperEnd < 0 || + rollbackStart < 0 || + rollbackEnd < 0 || rerollStart < 0 || rerollEnd < 0 || - helperEnd <= helperStart || + rollbackEnd <= rollbackStart || rerollEnd <= rerollStart ) { throw new Error("无法从 index.js 提取 reroll 定义"); } - const snippet = [ - source.slice(helperStart, helperEnd), - source.slice(rerollStart, rerollEnd), - ] + const snippet = [source.slice(rollbackStart, rollbackEnd), source.slice(rerollStart, rerollEnd)] .join("\n") .replace(/^export\s+/gm, ""); const context = { @@ -414,6 +420,9 @@ function createRerollHarness() { async deleteBackendVectorHashesForRecovery(...args) { context.deletedHashesCalls.push(args); }, + pruneProcessedMessageHashesFromFloor(graph, fromFloor) { + return pruneProcessedMessageHashesFromFloor(graph, fromFloor); + }, async prepareVectorStateForReplay(...args) { context.prepareVectorStateCalls.push(args); }, @@ -459,7 +468,7 @@ function createRerollHarness() { }; vm.createContext(context); vm.runInContext( - `${snippet}\nresult = { pruneProcessedMessageHashesFromFloor, rollbackGraphForReroll, onReroll };`, + `${snippet}\nresult = { rollbackGraphForReroll, onReroll };`, context, { filename: indexPath }, );