Files
ST-Bionic-Memory-Ecology/maintenance/chat-history.js
2026-04-10 18:38:54 +08:00

467 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ST-BME: 聊天历史纯函数
// 此模块中的函数均不依赖 index.js 模块级可变状态,
// 可被 index.js 及其他模块安全导入。
import { clampInt } from "../ui/ui-status.js";
import { sanitizePlannerMessageText } from "../runtime/planner-tag-utils.js";
import { rollbackBatch } from "../runtime/runtime-state.js";
import { isInManagedHideRange } from "../ui/hide-engine.js";
export function isBmeManagedHiddenMessage(
message,
{ index = null, chat = null } = {},
) {
if (
Number.isFinite(index) &&
index > 0 &&
isInManagedHideRange(index, chat)
) {
return true;
}
return Boolean(
message?.extra &&
typeof message.extra === "object" &&
message.extra.__st_bme_hide_managed === true,
);
}
export function isDialogueGreetingMessage(
message,
{ index = null } = {},
) {
if (!Number.isFinite(index) || index !== 0) return false;
if (!message || typeof message !== "object") return false;
return String(message?.mes ?? "").trim().length > 0;
}
export function isTrueSystemMessage(
message,
{ index = null, chat = null } = {},
) {
if (!message?.is_system) return false;
if (isDialogueGreetingMessage(message, { index, chat })) return false;
return !isBmeManagedHiddenMessage(message, { index, chat });
}
export function isDialogueCountedMessage(
message,
{ index = null, chat = null } = {},
) {
if (!message || typeof message !== "object") return false;
if (!String(message?.mes ?? "").trim()) return false;
return !isTrueSystemMessage(message, { index, chat });
}
export function isDialogueAssistantMessage(
message,
{ index = null, chat = null } = {},
) {
if (!isDialogueCountedMessage(message, { index, chat })) return false;
if (isDialogueGreetingMessage(message, { index, chat })) return false;
return Boolean(message) && !message.is_user;
}
export function buildDialogueFloorMap(chat = []) {
const floorToChatIndex = [];
const chatIndexToFloor = {};
const floorToRole = {};
const assistantDialogueFloors = [];
const assistantChatIndices = [];
if (!Array.isArray(chat)) {
return {
latestDialogueFloor: -1,
floorToChatIndex,
chatIndexToFloor,
floorToRole,
assistantDialogueFloors,
assistantChatIndices,
};
}
let currentFloor = -1;
for (let index = 0; index < chat.length; index += 1) {
const message = chat[index];
if (!isDialogueCountedMessage(message, { index, chat })) continue;
currentFloor += 1;
floorToChatIndex[currentFloor] = index;
chatIndexToFloor[index] = currentFloor;
if (isDialogueGreetingMessage(message, { index, chat })) {
floorToRole[currentFloor] = "greeting";
continue;
}
const role = message?.is_user ? "user" : "assistant";
floorToRole[currentFloor] = role;
if (role === "assistant") {
assistantDialogueFloors.push(currentFloor);
assistantChatIndices.push(index);
}
}
return {
latestDialogueFloor: currentFloor,
floorToChatIndex,
chatIndexToFloor,
floorToRole,
assistantDialogueFloors,
assistantChatIndices,
};
}
export function normalizeDialogueFloorRange(
chat = [],
startFloor = null,
endFloor = null,
) {
const map = buildDialogueFloorMap(chat);
const latestDialogueFloor = Number(map.latestDialogueFloor);
const hasStart =
startFloor !== null &&
startFloor !== undefined &&
startFloor !== "" &&
Number.isFinite(Number(startFloor));
const hasEnd =
endFloor !== null &&
endFloor !== undefined &&
endFloor !== "" &&
Number.isFinite(Number(endFloor));
if (latestDialogueFloor < 0) {
return {
map,
latestDialogueFloor,
valid: false,
reason: "empty-dialogue",
startFloor: null,
endFloor: null,
};
}
if (!hasStart && hasEnd) {
return {
map,
latestDialogueFloor,
valid: false,
reason: "end-without-start",
startFloor: null,
endFloor: null,
};
}
const normalizedStart = hasStart
? Math.max(0, Math.min(latestDialogueFloor, Math.floor(Number(startFloor))))
: null;
const normalizedEnd = hasEnd
? Math.max(
normalizedStart ?? 0,
Math.min(latestDialogueFloor, Math.floor(Number(endFloor))),
)
: hasStart
? latestDialogueFloor
: null;
return {
map,
latestDialogueFloor,
valid: true,
reason: "",
startFloor: normalizedStart,
endFloor: normalizedEnd,
};
}
export function getDialogueFloorForChatIndex(chat = [], chatIndex = null) {
if (!Number.isFinite(Number(chatIndex))) return null;
const map = buildDialogueFloorMap(chat);
const floor = map.chatIndexToFloor[Math.floor(Number(chatIndex))];
return Number.isFinite(Number(floor)) ? Number(floor) : null;
}
function cloneChatMessageForPluginView(message) {
if (!message || typeof message !== "object") {
return message;
}
try {
if (typeof structuredClone === "function") {
return structuredClone(message);
}
} catch {
// ignore and fall back to JSON clone
}
try {
return JSON.parse(JSON.stringify(message));
} catch {
return {
...message,
extra:
message.extra && typeof message.extra === "object"
? { ...message.extra }
: message.extra,
};
}
}
export function buildPluginVisibleChatMessages(chat = []) {
if (!Array.isArray(chat)) return [];
return chat.map((message, index) => {
const cloned = cloneChatMessageForPluginView(message);
if (
cloned &&
typeof cloned === "object" &&
isBmeManagedHiddenMessage(message, { index, chat })
) {
cloned.is_system = false;
}
return cloned;
});
}
export function isSystemMessageForExtraction(
message,
{ index = null, chat = null } = {},
) {
if (!message?.is_system) return false;
if (Number.isFinite(index) && index === 0) return true;
return !isBmeManagedHiddenMessage(message, { index, chat });
}
export function isSystemMessageForSummary(
message,
{ index = null, chat = null } = {},
) {
if (!message?.is_system) return false;
if (Number.isFinite(index) && index === 0) return true;
return !isBmeManagedHiddenMessage(message, { index, chat });
}
export function isAssistantChatMessage(
message,
{ index = null, chat = null } = {},
) {
return (
Boolean(message) &&
!message.is_user &&
!isSystemMessageForExtraction(message, { index, chat })
);
}
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 })) continue;
if (!String(chat[index]?.mes ?? "").trim()) continue;
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 (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,
});
}
return messages;
}
export function buildSummarySourceMessages(
chat,
startIdx,
endIdx,
options = {},
) {
const extraContextFloors = clampInt(
options.rawChatContextFloors,
0,
0,
200,
);
const contextStart = Math.max(0, Number(startIdx || 0) - extraContextFloors);
const messages = [];
for (
let index = contextStart;
index <= endIdx && index < chat.length;
index += 1
) {
const msg = chat[index];
if (isSystemMessageForSummary(msg, { index, chat })) continue;
const content = sanitizePlannerMessageText(msg);
if (!String(content || "").trim()) continue;
messages.push({
seq: index,
role: msg.is_user ? "user" : "assistant",
content,
hiddenManaged: isBmeManagedHiddenMessage(msg, { index, chat }),
});
}
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 (isSystemMessageForExtraction(message, { index, chat })) 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], { index, chat })) 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];
}
}
}