mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
1088 lines
33 KiB
JavaScript
1088 lines
33 KiB
JavaScript
import { debugLog } from "../runtime/debug-logging.js";
|
||
import { callLLMForJSON } from "../llm/llm.js";
|
||
import {
|
||
buildTaskExecutionDebugContext,
|
||
buildTaskLlmPayload,
|
||
buildTaskPrompt,
|
||
} from "../prompting/prompt-builder.js";
|
||
import { applyTaskRegex } from "../prompting/task-regex.js";
|
||
import { getActiveTaskProfile } from "../prompting/prompt-profiles.js";
|
||
import {
|
||
appendSummaryEntry,
|
||
createDefaultSummaryState,
|
||
getActiveSummaryEntries,
|
||
markSummaryEntriesFolded,
|
||
normalizeGraphSummaryState,
|
||
} from "../graph/summary-state.js";
|
||
import {
|
||
buildDialogueFloorMap,
|
||
buildSummarySourceMessages,
|
||
getDialogueFloorForChatIndex,
|
||
} from "./chat-history.js";
|
||
import { getSTContextForPrompt } from "../host/st-context.js";
|
||
import {
|
||
deriveStoryTimeSpanFromNodes,
|
||
describeNodeStoryTime,
|
||
} from "../graph/story-timeline.js";
|
||
import { getNode, getActiveNodes } from "../graph/graph.js";
|
||
import { getNodeDisplayName } from "../graph/node-labels.js";
|
||
import { normalizeMemoryScope } from "../graph/memory-scope.js";
|
||
|
||
function createAbortError(message = "操作已终止") {
|
||
const error = new Error(message);
|
||
error.name = "AbortError";
|
||
return error;
|
||
}
|
||
|
||
function throwIfAborted(signal) {
|
||
if (signal?.aborted) {
|
||
throw signal.reason instanceof Error ? signal.reason : createAbortError();
|
||
}
|
||
}
|
||
|
||
function createTaskLlmDebugContext(promptBuild, regexInput) {
|
||
return typeof buildTaskExecutionDebugContext === "function"
|
||
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
|
||
: null;
|
||
}
|
||
|
||
function resolveTaskPromptPayload(promptBuild, fallbackUserPrompt = "") {
|
||
if (typeof buildTaskLlmPayload === "function") {
|
||
return buildTaskLlmPayload(promptBuild, fallbackUserPrompt);
|
||
}
|
||
|
||
return {
|
||
systemPrompt: String(promptBuild?.systemPrompt || ""),
|
||
userPrompt: String(fallbackUserPrompt || ""),
|
||
promptMessages: [],
|
||
additionalMessages: Array.isArray(promptBuild?.privateTaskMessages)
|
||
? promptBuild.privateTaskMessages
|
||
: [],
|
||
};
|
||
}
|
||
|
||
function resolveTaskLlmSystemPrompt(promptPayload, fallbackSystemPrompt = "") {
|
||
const hasPromptMessages =
|
||
Array.isArray(promptPayload?.promptMessages) &&
|
||
promptPayload.promptMessages.length > 0;
|
||
if (hasPromptMessages) {
|
||
return String(promptPayload?.systemPrompt || "");
|
||
}
|
||
return String(promptPayload?.systemPrompt || fallbackSystemPrompt || "");
|
||
}
|
||
|
||
function clampInt(value, fallback = 0, min = 0, max = 999999) {
|
||
const parsed = Math.floor(Number(value));
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
return Math.max(min, Math.min(max, parsed));
|
||
}
|
||
|
||
function normalizeRange(range, fallback = [-1, -1]) {
|
||
if (!Array.isArray(range) || range.length < 2) {
|
||
return [...fallback];
|
||
}
|
||
const start = Number.isFinite(Number(range[0])) ? Number(range[0]) : fallback[0];
|
||
const end = Number.isFinite(Number(range[1])) ? Number(range[1]) : fallback[1];
|
||
return [start, end];
|
||
}
|
||
|
||
function getSummaryTaskInputConfig(settings = {}, taskType = "synopsis") {
|
||
const profile = getActiveTaskProfile(settings, taskType);
|
||
const input =
|
||
profile?.input && typeof profile.input === "object" && !Array.isArray(profile.input)
|
||
? profile.input
|
||
: {};
|
||
return {
|
||
rawChatContextFloors: clampInt(input.rawChatContextFloors, 0, 0, 200),
|
||
rawChatSourceMode:
|
||
String(input.rawChatSourceMode || "ignore_bme_hide").trim() ===
|
||
"ignore_bme_hide"
|
||
? "ignore_bme_hide"
|
||
: "ignore_bme_hide",
|
||
};
|
||
}
|
||
|
||
function buildTranscript(messages = []) {
|
||
return (Array.isArray(messages) ? messages : [])
|
||
.map((message) => {
|
||
const seq = Number.isFinite(Number(message?.seq)) ? Number(message.seq) : "?";
|
||
const role = String(message?.role || "assistant").trim() || "assistant";
|
||
return `#${seq} [${role}]: ${String(message?.content || "")}`;
|
||
})
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
}
|
||
|
||
function uniqueIds(values = []) {
|
||
return [...new Set(
|
||
(Array.isArray(values) ? values : [])
|
||
.map((value) => String(value || "").trim())
|
||
.filter(Boolean),
|
||
)];
|
||
}
|
||
|
||
function collectJournalTouchedNodeIds(journal = {}) {
|
||
return uniqueIds([
|
||
...(Array.isArray(journal?.createdNodeIds) ? journal.createdNodeIds : []),
|
||
...((Array.isArray(journal?.previousNodeSnapshots)
|
||
? journal.previousNodeSnapshots
|
||
: []
|
||
).map((node) => node?.id)),
|
||
]);
|
||
}
|
||
|
||
function intersectsRange(leftRange, rightRange) {
|
||
const [leftStart, leftEnd] = normalizeRange(leftRange);
|
||
const [rightStart, rightEnd] = normalizeRange(rightRange);
|
||
if (leftStart < 0 || leftEnd < 0 || rightStart < 0 || rightEnd < 0) {
|
||
return false;
|
||
}
|
||
return leftStart <= rightEnd && rightStart <= leftEnd;
|
||
}
|
||
|
||
function buildDialogueRangeFromMessageRange(chat = [], messageRange = [-1, -1]) {
|
||
const [messageStart, messageEnd] = normalizeRange(messageRange);
|
||
if (messageStart < 0 || messageEnd < 0) {
|
||
return [-1, -1];
|
||
}
|
||
const startFloor = getDialogueFloorForChatIndex(chat, messageStart);
|
||
const endFloor = getDialogueFloorForChatIndex(chat, messageEnd);
|
||
return [
|
||
Number.isFinite(Number(startFloor)) ? Number(startFloor) : -1,
|
||
Number.isFinite(Number(endFloor)) ? Number(endFloor) : -1,
|
||
];
|
||
}
|
||
|
||
function getSummaryEntryDialogueRange(chat = [], entry = {}) {
|
||
const directRange = normalizeRange(entry?.dialogueRange);
|
||
if (directRange[0] >= 0 && directRange[1] >= 0) {
|
||
return directRange;
|
||
}
|
||
return buildDialogueRangeFromMessageRange(chat, entry?.messageRange);
|
||
}
|
||
|
||
function removeSummaryEntriesByIds(graph, entryIds = []) {
|
||
normalizeGraphSummaryState(graph);
|
||
const targetIds = new Set(uniqueIds(entryIds));
|
||
if (targetIds.size === 0) return 0;
|
||
const queue = [...targetIds];
|
||
while (queue.length > 0) {
|
||
const currentId = queue.shift();
|
||
for (const entry of graph.summaryState.entries || []) {
|
||
if (targetIds.has(entry.id)) continue;
|
||
const sourceSummaryIds = Array.isArray(entry?.sourceSummaryIds)
|
||
? entry.sourceSummaryIds
|
||
: [];
|
||
if (!sourceSummaryIds.includes(currentId)) continue;
|
||
targetIds.add(entry.id);
|
||
queue.push(entry.id);
|
||
}
|
||
}
|
||
|
||
graph.summaryState.entries = (graph.summaryState.entries || []).filter(
|
||
(entry) => !targetIds.has(entry.id),
|
||
);
|
||
graph.summaryState.activeEntryIds = (graph.summaryState.activeEntryIds || []).filter(
|
||
(entryId) => !targetIds.has(entryId),
|
||
);
|
||
return targetIds.size;
|
||
}
|
||
|
||
function findJournalForExtractionCount(graph, extractionCountBefore) {
|
||
const target = Number(extractionCountBefore);
|
||
const journals = Array.isArray(graph?.batchJournal) ? graph.batchJournal : [];
|
||
for (let index = journals.length - 1; index >= 0; index -= 1) {
|
||
const journal = journals[index];
|
||
if (
|
||
Number(journal?.stateBefore?.extractionCount) === target &&
|
||
Array.isArray(journal?.processedRange)
|
||
) {
|
||
return journal;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildPseudoCurrentSlice(
|
||
currentExtractionCount,
|
||
currentRange,
|
||
currentNodeIds = [],
|
||
currentDialogueRange = null,
|
||
) {
|
||
return {
|
||
id: `summary-pending-${currentExtractionCount}`,
|
||
extractionCountBefore: Math.max(0, currentExtractionCount - 1),
|
||
extractionCountAfter: currentExtractionCount,
|
||
processedRange: normalizeRange(currentRange),
|
||
processedDialogueRange: normalizeRange(currentDialogueRange),
|
||
touchedNodeIds: uniqueIds(currentNodeIds),
|
||
};
|
||
}
|
||
|
||
function buildSliceFromJournal(journal = {}) {
|
||
return {
|
||
id: String(journal?.id || ""),
|
||
extractionCountBefore: clampInt(journal?.stateBefore?.extractionCount, 0, 0, 999999),
|
||
extractionCountAfter:
|
||
clampInt(journal?.stateBefore?.extractionCount, 0, 0, 999999) + 1,
|
||
processedRange: normalizeRange(journal?.processedRange),
|
||
processedDialogueRange: normalizeRange(journal?.processedDialogueRange),
|
||
touchedNodeIds: uniqueIds([
|
||
...(Array.isArray(journal?.touchedNodeIds) ? journal.touchedNodeIds : []),
|
||
...collectJournalTouchedNodeIds(journal),
|
||
]),
|
||
};
|
||
}
|
||
|
||
function collectSlicesForSummaryWindow(
|
||
graph,
|
||
{
|
||
lastSummarizedExtractionCount = 0,
|
||
currentExtractionCount = 0,
|
||
currentRange = null,
|
||
currentDialogueRange = null,
|
||
currentNodeIds = [],
|
||
includeCurrentPending = false,
|
||
} = {},
|
||
) {
|
||
const slices = [];
|
||
const safeLastCount = clampInt(lastSummarizedExtractionCount, 0, 0, 999999);
|
||
const safeCurrentCount = clampInt(currentExtractionCount, 0, 0, 999999);
|
||
const hasCurrentPendingRange =
|
||
includeCurrentPending &&
|
||
Array.isArray(currentRange) &&
|
||
Number.isFinite(Number(currentRange[0])) &&
|
||
Number.isFinite(Number(currentRange[1])) &&
|
||
Number(currentRange[1]) >= Number(currentRange[0]);
|
||
for (
|
||
let beforeCount = safeLastCount;
|
||
beforeCount < safeCurrentCount - (hasCurrentPendingRange ? 1 : 0);
|
||
beforeCount += 1
|
||
) {
|
||
const journal = findJournalForExtractionCount(graph, beforeCount);
|
||
if (!journal) continue;
|
||
slices.push(buildSliceFromJournal(journal));
|
||
}
|
||
if (hasCurrentPendingRange && safeCurrentCount > safeLastCount) {
|
||
slices.push(
|
||
buildPseudoCurrentSlice(
|
||
safeCurrentCount,
|
||
currentRange,
|
||
currentNodeIds,
|
||
currentDialogueRange,
|
||
),
|
||
);
|
||
}
|
||
return slices.sort(
|
||
(left, right) => left.extractionCountAfter - right.extractionCountAfter,
|
||
);
|
||
}
|
||
|
||
function collectNodeHints(graph, nodeIds = []) {
|
||
const nodes = uniqueIds(nodeIds)
|
||
.map((nodeId) => getNode(graph, nodeId))
|
||
.filter(Boolean);
|
||
const regionHints = new Set();
|
||
const ownerHints = new Set();
|
||
for (const node of nodes) {
|
||
const scope = normalizeMemoryScope(node?.scope);
|
||
if (scope.regionPrimary) regionHints.add(scope.regionPrimary);
|
||
if (scope.ownerName) ownerHints.add(scope.ownerName);
|
||
}
|
||
return {
|
||
nodes,
|
||
regionHints: [...regionHints],
|
||
ownerHints: [...ownerHints],
|
||
};
|
||
}
|
||
|
||
function describeNodeForSummary(node) {
|
||
if (!node) return "";
|
||
const storyLabel = describeNodeStoryTime(node);
|
||
const prefix = storyLabel ? `[${storyLabel}] ` : "";
|
||
switch (String(node.type || "")) {
|
||
case "event":
|
||
return `${prefix}${node.fields?.title || getNodeDisplayName(node)}: ${node.fields?.summary || "(无摘要)"}`;
|
||
case "character":
|
||
return `${prefix}${node.fields?.name || getNodeDisplayName(node)}: ${node.fields?.state || node.fields?.summary || "(无状态)"}`;
|
||
case "thread":
|
||
return `${prefix}${node.fields?.title || getNodeDisplayName(node)}: ${node.fields?.status || node.fields?.summary || "(无状态)"}`;
|
||
case "pov_memory":
|
||
return `${prefix}${getNodeDisplayName(node)}: ${node.fields?.summary || "(无摘要)"}`;
|
||
default:
|
||
return `${prefix}${getNodeDisplayName(node)}: ${node.fields?.summary || node.fields?.title || node.fields?.name || "(无摘要)"}`;
|
||
}
|
||
}
|
||
|
||
function buildNodeDigest(graph, nodeIds = []) {
|
||
return collectNodeHints(graph, nodeIds).nodes
|
||
.map((node) => describeNodeForSummary(node))
|
||
.filter(Boolean)
|
||
.slice(0, 24)
|
||
.join("\n");
|
||
}
|
||
|
||
function buildFrontierHint(graph) {
|
||
const activeEntries = getActiveSummaryEntries(graph);
|
||
if (activeEntries.length === 0) {
|
||
return "当前还没有活跃总结前沿。";
|
||
}
|
||
return activeEntries
|
||
.slice(-6)
|
||
.map((entry) => {
|
||
const range = normalizeRange(entry.messageRange);
|
||
return `L${entry.level} · 楼 ${range[0]} ~ ${range[1]} · ${String(entry.text || "").slice(0, 90)}`;
|
||
})
|
||
.join("\n");
|
||
}
|
||
|
||
function buildSummaryGraphStats(graph, activeEntries = []) {
|
||
const historyState = graph?.historyState || {};
|
||
const activeRegion = String(historyState.activeRegion || historyState.lastExtractedRegion || "").trim();
|
||
const activeStoryTime = String(
|
||
historyState.activeStoryTimeLabel || historyState.activeStorySegmentId || "",
|
||
).trim();
|
||
return [
|
||
`active_summary_count=${activeEntries.length}`,
|
||
activeRegion ? `active_region=${activeRegion}` : "",
|
||
activeStoryTime ? `active_story_time=${activeStoryTime}` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
|
||
async function callSummaryTask({
|
||
settings = {},
|
||
taskType = "synopsis",
|
||
context = {},
|
||
fallbackSystemPrompt = "",
|
||
fallbackUserPrompt = "",
|
||
signal,
|
||
}) {
|
||
const promptBuild = await buildTaskPrompt(settings, taskType, {
|
||
taskName: taskType,
|
||
...context,
|
||
...getSTContextForPrompt(),
|
||
});
|
||
const regexInput = { entries: [] };
|
||
const systemPrompt = applyTaskRegex(
|
||
settings,
|
||
taskType,
|
||
"finalPrompt",
|
||
promptBuild.systemPrompt || fallbackSystemPrompt,
|
||
regexInput,
|
||
"system",
|
||
);
|
||
const promptPayload = resolveTaskPromptPayload(promptBuild, fallbackUserPrompt);
|
||
return await callLLMForJSON({
|
||
systemPrompt: resolveTaskLlmSystemPrompt(promptPayload, systemPrompt),
|
||
userPrompt: promptPayload.userPrompt,
|
||
maxRetries: 1,
|
||
signal,
|
||
taskType,
|
||
debugContext: createTaskLlmDebugContext(promptBuild, regexInput),
|
||
promptMessages: promptPayload.promptMessages,
|
||
additionalMessages: promptPayload.additionalMessages,
|
||
});
|
||
}
|
||
|
||
export async function generateSmallSummary({
|
||
graph,
|
||
chat = [],
|
||
settings = {},
|
||
currentExtractionCount = 0,
|
||
currentAssistantFloor = -1,
|
||
currentRange = [-1, -1],
|
||
currentNodeIds = [],
|
||
signal,
|
||
force = false,
|
||
} = {}) {
|
||
normalizeGraphSummaryState(graph);
|
||
const summaryState = createDefaultSummaryState(graph?.summaryState || {});
|
||
graph.summaryState = summaryState;
|
||
|
||
const threshold = clampInt(
|
||
settings.smallSummaryEveryNExtractions,
|
||
3,
|
||
1,
|
||
100,
|
||
);
|
||
const deltaCount = Math.max(
|
||
0,
|
||
clampInt(currentExtractionCount, 0, 0, 999999) -
|
||
clampInt(summaryState.lastSummarizedExtractionCount, 0, 0, 999999),
|
||
);
|
||
if (!force && deltaCount < threshold) {
|
||
return {
|
||
created: false,
|
||
skipped: true,
|
||
reason: `当前只累计了 ${deltaCount} 次未总结提取,未到小总结门槛 ${threshold}`,
|
||
};
|
||
}
|
||
|
||
const slices = collectSlicesForSummaryWindow(graph, {
|
||
lastSummarizedExtractionCount: summaryState.lastSummarizedExtractionCount,
|
||
currentExtractionCount,
|
||
currentRange,
|
||
currentDialogueRange: buildDialogueRangeFromMessageRange(chat, currentRange),
|
||
currentNodeIds,
|
||
includeCurrentPending: true,
|
||
});
|
||
if (slices.length === 0) {
|
||
return {
|
||
created: false,
|
||
skipped: true,
|
||
reason: "当前没有可用于生成小总结的提取批次",
|
||
};
|
||
}
|
||
|
||
const firstSlice = slices[0];
|
||
const lastSlice = slices[slices.length - 1];
|
||
const inputConfig = getSummaryTaskInputConfig(settings, "synopsis");
|
||
const messageStart = normalizeRange(firstSlice.processedRange)[0];
|
||
const messageEnd = Math.max(
|
||
normalizeRange(lastSlice.processedRange)[1],
|
||
clampInt(currentAssistantFloor, -1, -1, 999999),
|
||
);
|
||
const sourceMessages = buildSummarySourceMessages(chat, messageStart, messageEnd, {
|
||
rawChatContextFloors: inputConfig.rawChatContextFloors,
|
||
});
|
||
if (sourceMessages.length === 0) {
|
||
return {
|
||
created: false,
|
||
skipped: true,
|
||
reason: "小总结原文窗口为空,已跳过",
|
||
};
|
||
}
|
||
|
||
const messageRange = [
|
||
Number.isFinite(Number(sourceMessages[0]?.seq)) ? Number(sourceMessages[0].seq) : messageStart,
|
||
Number.isFinite(Number(sourceMessages[sourceMessages.length - 1]?.seq))
|
||
? Number(sourceMessages[sourceMessages.length - 1].seq)
|
||
: messageEnd,
|
||
];
|
||
const dialogueRange = buildDialogueRangeFromMessageRange(chat, messageRange);
|
||
const sourceNodeIds = uniqueIds(
|
||
slices.flatMap((slice) => Array.isArray(slice.touchedNodeIds) ? slice.touchedNodeIds : []),
|
||
);
|
||
const nodeDigest = buildNodeDigest(graph, sourceNodeIds) || "(无关键节点辅助)";
|
||
const activeFrontier = getActiveSummaryEntries(graph);
|
||
const result = await callSummaryTask({
|
||
settings,
|
||
taskType: "synopsis",
|
||
context: {
|
||
recentMessages: buildTranscript(sourceMessages),
|
||
chatMessages: sourceMessages,
|
||
candidateText: nodeDigest,
|
||
graphStats: [
|
||
buildSummaryGraphStats(graph, activeFrontier),
|
||
`frontier_hint:\n${buildFrontierHint(graph)}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n"),
|
||
currentRange: `楼 ${messageRange[0]} ~ ${messageRange[1]}`,
|
||
},
|
||
fallbackSystemPrompt: [
|
||
"你是小总结生成器。",
|
||
"请基于最近原文聊天窗口为主、关键节点为辅,生成一条贴近当前局面的短总结。",
|
||
'输出 JSON:{"summary":"总结文本(80-220字)"}',
|
||
"不要写未来预测,不要脱离原文杜撰,不要把多段时间线硬糅在一起。",
|
||
].join("\n"),
|
||
fallbackUserPrompt: [
|
||
"## 原文聊天窗口",
|
||
buildTranscript(sourceMessages),
|
||
"",
|
||
"## 关键节点辅助",
|
||
nodeDigest,
|
||
"",
|
||
"## 当前活跃总结前沿",
|
||
buildFrontierHint(graph),
|
||
].join("\n"),
|
||
signal,
|
||
});
|
||
|
||
const summaryText = String(result?.summary || "").trim();
|
||
if (!summaryText) {
|
||
return {
|
||
created: false,
|
||
skipped: true,
|
||
reason: "小总结任务未返回有效 summary",
|
||
};
|
||
}
|
||
|
||
const nodeHints = collectNodeHints(graph, sourceNodeIds);
|
||
const storyTimeSpan = deriveStoryTimeSpanFromNodes(
|
||
graph,
|
||
nodeHints.nodes,
|
||
"derived",
|
||
);
|
||
const entry = appendSummaryEntry(graph, {
|
||
level: 0,
|
||
kind: "small",
|
||
status: "active",
|
||
text: summaryText,
|
||
sourceTask: "synopsis",
|
||
extractionRange: [firstSlice.extractionCountAfter, lastSlice.extractionCountAfter],
|
||
messageRange,
|
||
dialogueRange,
|
||
sourceBatchIds: uniqueIds(slices.map((slice) => slice.id)),
|
||
sourceSummaryIds: [],
|
||
sourceNodeIds,
|
||
storyTimeSpan,
|
||
regionHints: nodeHints.regionHints,
|
||
ownerHints: nodeHints.ownerHints,
|
||
});
|
||
summaryState.lastSummarizedExtractionCount = lastSlice.extractionCountAfter;
|
||
summaryState.lastSummarizedAssistantFloor = messageRange[1];
|
||
debugLog("[ST-BME] 已生成小总结", {
|
||
entryId: entry.id,
|
||
extractionRange: entry.extractionRange,
|
||
messageRange: entry.messageRange,
|
||
});
|
||
return {
|
||
created: true,
|
||
entry,
|
||
sourceMessages,
|
||
sourceNodeIds,
|
||
messageRange,
|
||
dialogueRange,
|
||
};
|
||
}
|
||
|
||
function buildRollupCandidateText(entries = []) {
|
||
return entries
|
||
.map((entry, index) => {
|
||
const range = normalizeRange(entry.messageRange);
|
||
return [
|
||
`#${index + 1}`,
|
||
`level=L${entry.level}`,
|
||
`message_range=${range[0]}~${range[1]}`,
|
||
`text=${String(entry.text || "")}`,
|
||
].join(" | ");
|
||
})
|
||
.join("\n");
|
||
}
|
||
|
||
function getFoldableSummaryGroup(graph, fanIn = 3, options = {}) {
|
||
const requireExcess = options?.requireExcess === true;
|
||
const activeEntries = getActiveSummaryEntries(graph);
|
||
const byLevel = new Map();
|
||
for (const entry of activeEntries) {
|
||
if (!byLevel.has(entry.level)) {
|
||
byLevel.set(entry.level, []);
|
||
}
|
||
byLevel.get(entry.level).push(entry);
|
||
}
|
||
const sortedLevels = [...byLevel.keys()].sort((left, right) => left - right);
|
||
for (const level of sortedLevels) {
|
||
const entries = byLevel.get(level) || [];
|
||
if (requireExcess ? entries.length > fanIn : entries.length >= fanIn) {
|
||
return entries.slice(0, fanIn);
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
export async function rollupSummaryFrontier({
|
||
graph,
|
||
settings = {},
|
||
signal,
|
||
force = false,
|
||
} = {}) {
|
||
normalizeGraphSummaryState(graph);
|
||
const fanIn = clampInt(settings.summaryRollupFanIn, 3, 2, 10);
|
||
const requireExcess = force !== true;
|
||
const createdEntries = [];
|
||
let foldedCount = 0;
|
||
|
||
while (true) {
|
||
throwIfAborted(signal);
|
||
const candidates = getFoldableSummaryGroup(graph, fanIn, {
|
||
requireExcess,
|
||
});
|
||
if (candidates.length < fanIn) {
|
||
break;
|
||
}
|
||
|
||
const sourceNodeIds = uniqueIds(
|
||
candidates.flatMap((entry) =>
|
||
Array.isArray(entry.sourceNodeIds) ? entry.sourceNodeIds : [],
|
||
),
|
||
);
|
||
const nodeHints = collectNodeHints(graph, sourceNodeIds);
|
||
const result = await callSummaryTask({
|
||
settings,
|
||
taskType: "summary_rollup",
|
||
context: {
|
||
candidateText: buildRollupCandidateText(candidates),
|
||
graphStats: buildSummaryGraphStats(graph, getActiveSummaryEntries(graph)),
|
||
currentRange: `楼 ${normalizeRange(candidates[0]?.messageRange)[0]} ~ ${
|
||
normalizeRange(candidates[candidates.length - 1]?.messageRange)[1]
|
||
}`,
|
||
},
|
||
fallbackSystemPrompt: [
|
||
"你是总结折叠器。",
|
||
"请把多条同层活跃总结折叠成一条更稳定、更高层的总结。",
|
||
'输出 JSON:{"summary":"折叠后的总结文本(120-260字)"}',
|
||
"不要重复原句,不要丢掉当前仍然生效的局面,不要打乱先后顺序。",
|
||
].join("\n"),
|
||
fallbackUserPrompt: [
|
||
"## 待折叠总结",
|
||
buildRollupCandidateText(candidates),
|
||
"",
|
||
"## 关键节点辅助",
|
||
buildNodeDigest(graph, sourceNodeIds) || "(无关键节点辅助)",
|
||
].join("\n"),
|
||
signal,
|
||
});
|
||
const summaryText = String(result?.summary || "").trim();
|
||
if (!summaryText) {
|
||
return {
|
||
createdCount: createdEntries.length,
|
||
foldedCount,
|
||
skipped: createdEntries.length === 0,
|
||
reason: "总结折叠任务未返回有效 summary",
|
||
createdEntries,
|
||
};
|
||
}
|
||
|
||
const extractionRange = [
|
||
Math.min(...candidates.map((entry) => normalizeRange(entry.extractionRange)[0])),
|
||
Math.max(...candidates.map((entry) => normalizeRange(entry.extractionRange)[1])),
|
||
];
|
||
const messageRange = [
|
||
Math.min(...candidates.map((entry) => normalizeRange(entry.messageRange)[0])),
|
||
Math.max(...candidates.map((entry) => normalizeRange(entry.messageRange)[1])),
|
||
];
|
||
const dialogueRange = [
|
||
Math.min(
|
||
...candidates.map((entry) => {
|
||
const range = normalizeRange(entry?.dialogueRange, normalizeRange(entry?.messageRange));
|
||
return range[0];
|
||
}),
|
||
),
|
||
Math.max(
|
||
...candidates.map((entry) => {
|
||
const range = normalizeRange(entry?.dialogueRange, normalizeRange(entry?.messageRange));
|
||
return range[1];
|
||
}),
|
||
),
|
||
];
|
||
const storyTimeSpan = deriveStoryTimeSpanFromNodes(
|
||
graph,
|
||
nodeHints.nodes,
|
||
"derived",
|
||
);
|
||
markSummaryEntriesFolded(
|
||
graph,
|
||
candidates.map((entry) => entry.id),
|
||
);
|
||
foldedCount += candidates.length;
|
||
const createdEntry = appendSummaryEntry(graph, {
|
||
level: Number(candidates[0]?.level || 0) + 1,
|
||
kind: "rollup",
|
||
status: "active",
|
||
text: summaryText,
|
||
sourceTask: "summary_rollup",
|
||
extractionRange,
|
||
messageRange,
|
||
dialogueRange,
|
||
sourceBatchIds: uniqueIds(
|
||
candidates.flatMap((entry) =>
|
||
Array.isArray(entry.sourceBatchIds) ? entry.sourceBatchIds : [],
|
||
),
|
||
),
|
||
sourceSummaryIds: candidates.map((entry) => entry.id),
|
||
sourceNodeIds,
|
||
storyTimeSpan,
|
||
regionHints: nodeHints.regionHints,
|
||
ownerHints: nodeHints.ownerHints,
|
||
});
|
||
createdEntries.push(createdEntry);
|
||
debugLog("[ST-BME] 已完成总结折叠", {
|
||
createdEntryId: createdEntry.id,
|
||
sourceSummaryIds: createdEntry.sourceSummaryIds,
|
||
});
|
||
if (!force) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return {
|
||
createdCount: createdEntries.length,
|
||
foldedCount,
|
||
createdEntries,
|
||
skipped: createdEntries.length === 0,
|
||
reason:
|
||
createdEntries.length === 0
|
||
? requireExcess
|
||
? `当前没有超过 ${fanIn} 条同层活跃总结的折叠候选`
|
||
: `当前没有达到 ${fanIn} 条同层活跃总结的折叠候选`
|
||
: "",
|
||
};
|
||
}
|
||
|
||
export async function runHierarchicalSummaryPostProcess({
|
||
graph,
|
||
chat = [],
|
||
settings = {},
|
||
signal,
|
||
currentExtractionCount = 0,
|
||
currentAssistantFloor = -1,
|
||
currentRange = [-1, -1],
|
||
currentNodeIds = [],
|
||
} = {}) {
|
||
normalizeGraphSummaryState(graph);
|
||
if (settings.enableHierarchicalSummary === false) {
|
||
return {
|
||
smallSummary: null,
|
||
rollup: null,
|
||
created: false,
|
||
reason: "层级总结开关已关闭",
|
||
};
|
||
}
|
||
|
||
const smallSummary = await generateSmallSummary({
|
||
graph,
|
||
chat,
|
||
settings,
|
||
currentExtractionCount,
|
||
currentAssistantFloor,
|
||
currentRange,
|
||
currentNodeIds,
|
||
signal,
|
||
force: false,
|
||
});
|
||
if (!smallSummary?.created) {
|
||
return {
|
||
smallSummary,
|
||
rollup: null,
|
||
created: false,
|
||
reason: smallSummary?.reason || "",
|
||
};
|
||
}
|
||
|
||
const rollup = await rollupSummaryFrontier({
|
||
graph,
|
||
settings,
|
||
signal,
|
||
force: false,
|
||
});
|
||
return {
|
||
smallSummary,
|
||
rollup,
|
||
created: true,
|
||
};
|
||
}
|
||
|
||
function clearSummaryState(graph) {
|
||
graph.summaryState = createDefaultSummaryState();
|
||
}
|
||
|
||
function getSliceDialogueRange(chat = [], slice = {}) {
|
||
const directRange = normalizeRange(slice?.processedDialogueRange);
|
||
if (directRange[0] >= 0 && directRange[1] >= 0) {
|
||
return directRange;
|
||
}
|
||
return buildDialogueRangeFromMessageRange(chat, slice?.processedRange);
|
||
}
|
||
|
||
function getSuffixRebuildStartFromDialogueRange(
|
||
graph,
|
||
chat = [],
|
||
targetDialogueRange = [-1, -1],
|
||
) {
|
||
const slices = collectSlicesForSummaryWindow(graph, {
|
||
lastSummarizedExtractionCount: 0,
|
||
currentExtractionCount: clampInt(
|
||
graph?.historyState?.extractionCount,
|
||
0,
|
||
0,
|
||
999999,
|
||
),
|
||
currentRange: null,
|
||
currentNodeIds: [],
|
||
includeCurrentPending: false,
|
||
});
|
||
const affectedSlices = slices.filter((slice) =>
|
||
intersectsRange(getSliceDialogueRange(chat, slice), targetDialogueRange),
|
||
);
|
||
if (affectedSlices.length === 0) {
|
||
return null;
|
||
}
|
||
return {
|
||
rebuildFromExtractionCount: Math.min(
|
||
...affectedSlices.map((slice) =>
|
||
clampInt(slice.extractionCountBefore, 0, 0, 999999),
|
||
),
|
||
),
|
||
affectedSlices,
|
||
};
|
||
}
|
||
|
||
function resolveCurrentSummaryDialogueRange(graph, chat = []) {
|
||
const activeEntries = getActiveSummaryEntries(graph);
|
||
if (activeEntries.length > 0) {
|
||
return getSummaryEntryDialogueRange(
|
||
chat,
|
||
activeEntries[activeEntries.length - 1],
|
||
);
|
||
}
|
||
const slices = collectSlicesForSummaryWindow(graph, {
|
||
lastSummarizedExtractionCount: 0,
|
||
currentExtractionCount: clampInt(
|
||
graph?.historyState?.extractionCount,
|
||
0,
|
||
0,
|
||
999999,
|
||
),
|
||
currentRange: null,
|
||
currentNodeIds: [],
|
||
includeCurrentPending: false,
|
||
});
|
||
if (slices.length === 0) {
|
||
return [-1, -1];
|
||
}
|
||
return getSliceDialogueRange(chat, slices[slices.length - 1]);
|
||
}
|
||
|
||
function trimSummaryStateForSuffixRebuild(graph, rebuildFromExtractionCount = 0) {
|
||
normalizeGraphSummaryState(graph);
|
||
const entries = Array.isArray(graph.summaryState?.entries)
|
||
? graph.summaryState.entries
|
||
: [];
|
||
const removeIds = entries
|
||
.filter((entry) => {
|
||
const extractionRange = normalizeRange(entry?.extractionRange);
|
||
return extractionRange[1] >= rebuildFromExtractionCount;
|
||
})
|
||
.map((entry) => entry.id);
|
||
const removedCount = removeSummaryEntriesByIds(graph, removeIds);
|
||
const remainingEntries = Array.isArray(graph.summaryState?.entries)
|
||
? graph.summaryState.entries
|
||
: [];
|
||
graph.summaryState.lastSummarizedExtractionCount =
|
||
remainingEntries.length > 0
|
||
? Math.max(
|
||
0,
|
||
...remainingEntries.map((entry) =>
|
||
normalizeRange(entry?.extractionRange)[1],
|
||
),
|
||
)
|
||
: Math.max(0, rebuildFromExtractionCount);
|
||
graph.summaryState.lastSummarizedAssistantFloor =
|
||
remainingEntries.length > 0
|
||
? Math.max(
|
||
-1,
|
||
...remainingEntries.map((entry) =>
|
||
normalizeRange(entry?.messageRange)[1],
|
||
),
|
||
)
|
||
: -1;
|
||
return {
|
||
removedCount,
|
||
};
|
||
}
|
||
|
||
export async function rebuildHierarchicalSummaryState({
|
||
graph,
|
||
chat = [],
|
||
settings = {},
|
||
signal,
|
||
mode = "current",
|
||
startFloor = null,
|
||
endFloor = null,
|
||
} = {}) {
|
||
normalizeGraphSummaryState(graph);
|
||
const currentExtractionCount = clampInt(
|
||
graph?.historyState?.extractionCount,
|
||
0,
|
||
0,
|
||
999999,
|
||
);
|
||
if (currentExtractionCount <= 0) {
|
||
return {
|
||
rebuilt: false,
|
||
smallSummaryCount: 0,
|
||
rollupCount: 0,
|
||
reason: "当前还没有成功提取批次",
|
||
};
|
||
}
|
||
|
||
let targetDialogueRange = [-1, -1];
|
||
if (String(mode || "current") === "range") {
|
||
if (!Number.isFinite(Number(startFloor))) {
|
||
return {
|
||
rebuilt: false,
|
||
smallSummaryCount: 0,
|
||
rollupCount: 0,
|
||
reason: "范围重建必须填写起始楼层",
|
||
};
|
||
}
|
||
const latestDialogueFloor = buildDialogueFloorMap(chat).latestDialogueFloor;
|
||
targetDialogueRange = [
|
||
clampInt(startFloor, 0, 0, Math.max(0, latestDialogueFloor)),
|
||
Number.isFinite(Number(endFloor))
|
||
? clampInt(endFloor, 0, 0, Math.max(0, latestDialogueFloor))
|
||
: Math.max(0, latestDialogueFloor),
|
||
];
|
||
targetDialogueRange[1] = Math.max(
|
||
targetDialogueRange[0],
|
||
targetDialogueRange[1],
|
||
);
|
||
} else {
|
||
targetDialogueRange = resolveCurrentSummaryDialogueRange(graph, chat);
|
||
}
|
||
|
||
if (targetDialogueRange[0] < 0 || targetDialogueRange[1] < 0) {
|
||
return {
|
||
rebuilt: false,
|
||
smallSummaryCount: 0,
|
||
rollupCount: 0,
|
||
reason: "当前没有可重建的总结范围",
|
||
};
|
||
}
|
||
|
||
const rebuildWindow = getSuffixRebuildStartFromDialogueRange(
|
||
graph,
|
||
chat,
|
||
targetDialogueRange,
|
||
);
|
||
if (!rebuildWindow) {
|
||
return {
|
||
rebuilt: false,
|
||
smallSummaryCount: 0,
|
||
rollupCount: 0,
|
||
reason: "目标范围内没有命中的总结切片",
|
||
targetDialogueRange,
|
||
};
|
||
}
|
||
|
||
const trimmed = trimSummaryStateForSuffixRebuild(
|
||
graph,
|
||
rebuildWindow.rebuildFromExtractionCount,
|
||
);
|
||
|
||
const threshold = clampInt(settings.smallSummaryEveryNExtractions, 3, 1, 100);
|
||
const slices = collectSlicesForSummaryWindow(graph, {
|
||
lastSummarizedExtractionCount: rebuildWindow.rebuildFromExtractionCount,
|
||
currentExtractionCount,
|
||
currentRange: null,
|
||
currentNodeIds: [],
|
||
includeCurrentPending: false,
|
||
});
|
||
let pendingSlices = [];
|
||
let smallSummaryCount = 0;
|
||
let rollupCount = 0;
|
||
|
||
for (const slice of slices) {
|
||
pendingSlices.push(slice);
|
||
if (pendingSlices.length < threshold) {
|
||
continue;
|
||
}
|
||
|
||
const firstSlice = pendingSlices[0];
|
||
const lastSlice = pendingSlices[pendingSlices.length - 1];
|
||
const sourceNodeIds = uniqueIds(
|
||
pendingSlices.flatMap((item) => item.touchedNodeIds || []),
|
||
);
|
||
const sourceMessages = buildSummarySourceMessages(
|
||
chat,
|
||
normalizeRange(firstSlice.processedRange)[0],
|
||
normalizeRange(lastSlice.processedRange)[1],
|
||
{
|
||
rawChatContextFloors: getSummaryTaskInputConfig(settings, "synopsis")
|
||
.rawChatContextFloors,
|
||
},
|
||
);
|
||
if (sourceMessages.length > 0) {
|
||
const nodeHints = collectNodeHints(graph, sourceNodeIds);
|
||
const result = await callSummaryTask({
|
||
settings,
|
||
taskType: "synopsis",
|
||
context: {
|
||
recentMessages: buildTranscript(sourceMessages),
|
||
chatMessages: sourceMessages,
|
||
candidateText: buildNodeDigest(graph, sourceNodeIds) || "(无关键节点辅助)",
|
||
graphStats: [
|
||
buildSummaryGraphStats(graph, getActiveSummaryEntries(graph)),
|
||
`frontier_hint:\n${buildFrontierHint(graph)}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n"),
|
||
currentRange: `楼 ${sourceMessages[0]?.seq ?? "?"} ~ ${
|
||
sourceMessages[sourceMessages.length - 1]?.seq ?? "?"
|
||
}`,
|
||
},
|
||
fallbackSystemPrompt: [
|
||
"你是小总结生成器。",
|
||
'输出 JSON:{"summary":"总结文本(80-220字)"}',
|
||
].join("\n"),
|
||
fallbackUserPrompt: [
|
||
"## 原文聊天窗口",
|
||
buildTranscript(sourceMessages),
|
||
].join("\n"),
|
||
signal,
|
||
});
|
||
const summaryText = String(result?.summary || "").trim();
|
||
if (summaryText) {
|
||
const entryMessageRange = [
|
||
Number(sourceMessages[0]?.seq ?? -1),
|
||
Number(sourceMessages[sourceMessages.length - 1]?.seq ?? -1),
|
||
];
|
||
const entry = appendSummaryEntry(graph, {
|
||
level: 0,
|
||
kind: "small",
|
||
status: "active",
|
||
text: summaryText,
|
||
sourceTask: "synopsis",
|
||
extractionRange: [firstSlice.extractionCountAfter, lastSlice.extractionCountAfter],
|
||
messageRange: entryMessageRange,
|
||
dialogueRange: buildDialogueRangeFromMessageRange(chat, entryMessageRange),
|
||
sourceBatchIds: pendingSlices.map((item) => item.id),
|
||
sourceSummaryIds: [],
|
||
sourceNodeIds,
|
||
storyTimeSpan: deriveStoryTimeSpanFromNodes(
|
||
graph,
|
||
nodeHints.nodes,
|
||
"derived",
|
||
),
|
||
regionHints: nodeHints.regionHints,
|
||
ownerHints: nodeHints.ownerHints,
|
||
});
|
||
graph.summaryState.lastSummarizedExtractionCount =
|
||
lastSlice.extractionCountAfter;
|
||
graph.summaryState.lastSummarizedAssistantFloor =
|
||
normalizeRange(lastSlice.processedRange)[1];
|
||
if (entry) smallSummaryCount += 1;
|
||
const rollup = await rollupSummaryFrontier({
|
||
graph,
|
||
settings,
|
||
signal,
|
||
force: false,
|
||
});
|
||
rollupCount += Number(rollup?.createdCount || 0);
|
||
}
|
||
}
|
||
pendingSlices = [];
|
||
}
|
||
|
||
return {
|
||
rebuilt: smallSummaryCount > 0 || rollupCount > 0,
|
||
smallSummaryCount,
|
||
rollupCount,
|
||
targetDialogueRange,
|
||
rebuildFromExtractionCount: rebuildWindow.rebuildFromExtractionCount,
|
||
removedEntryCount: trimmed.removedCount,
|
||
reason:
|
||
smallSummaryCount > 0 || rollupCount > 0
|
||
? ""
|
||
: "根据现有提取批次未能重建出新的总结链",
|
||
};
|
||
}
|
||
|
||
export function resetHierarchicalSummaryState(graph) {
|
||
clearSummaryState(graph);
|
||
return graph?.summaryState || null;
|
||
}
|