refactor: extract chat history helpers

This commit is contained in:
Youzini-afk
2026-03-29 16:28:54 +08:00
parent a371150661
commit d9f04d4388
3 changed files with 223 additions and 205 deletions

189
chat-history.js Normal file
View File

@@ -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];
}
}
}

204
index.js
View File

@@ -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);

View File

@@ -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 },
);