2026-01-26 01:16:35 +08:00
|
|
|
|
// Story Summary - Generator
|
|
|
|
|
|
// 调用 LLM 生成总结
|
|
|
|
|
|
|
|
|
|
|
|
import { getContext } from "../../../../../../extensions.js";
|
|
|
|
|
|
import { xbLog } from "../../../core/debug-core.js";
|
|
|
|
|
|
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData } from "../data/store.js";
|
|
|
|
|
|
import { generateSummary, parseSummaryJson } from "./llm.js";
|
|
|
|
|
|
|
|
|
|
|
|
const MODULE_ID = 'summaryGenerator';
|
|
|
|
|
|
const SUMMARY_SESSION_ID = 'xb9';
|
2026-01-26 23:50:48 +08:00
|
|
|
|
const MAX_CAUSED_BY = 2;
|
2026-01-26 01:16:35 +08:00
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// worldUpdate 清洗
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeWorldUpdate(parsed) {
|
|
|
|
|
|
if (!parsed) return;
|
|
|
|
|
|
|
|
|
|
|
|
const wu = Array.isArray(parsed.worldUpdate) ? parsed.worldUpdate : [];
|
|
|
|
|
|
const ok = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const item of wu) {
|
|
|
|
|
|
const category = String(item?.category || '').trim().toLowerCase();
|
|
|
|
|
|
const topic = String(item?.topic || '').trim();
|
|
|
|
|
|
|
|
|
|
|
|
if (!category || !topic) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// status/knowledge/relation 必须包含 "::"
|
|
|
|
|
|
if (['status', 'knowledge', 'relation'].includes(category) && !topic.includes('::')) {
|
|
|
|
|
|
xbLog.warn(MODULE_ID, `丢弃不合格 worldUpdate: ${category}/${topic}`);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (item.cleared === true) {
|
|
|
|
|
|
ok.push({ category, topic, cleared: true });
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const content = String(item?.content || '').trim();
|
|
|
|
|
|
if (!content) continue;
|
|
|
|
|
|
|
|
|
|
|
|
ok.push({ category, topic, content });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
parsed.worldUpdate = ok;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 23:50:48 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// causedBy 清洗(事件因果边)
|
|
|
|
|
|
// - 允许引用:已存在事件 + 本次新输出事件
|
|
|
|
|
|
// - 限制长度:0-2
|
|
|
|
|
|
// - 去重、剔除非法ID、剔除自引用
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeEventsCausality(parsed, existingEventIds) {
|
|
|
|
|
|
if (!parsed) return;
|
|
|
|
|
|
|
|
|
|
|
|
const events = Array.isArray(parsed.events) ? parsed.events : [];
|
|
|
|
|
|
if (!events.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
const idRe = /^evt-\d+$/;
|
|
|
|
|
|
|
|
|
|
|
|
// 本次新输出事件ID集合(允许引用)
|
|
|
|
|
|
const newIds = new Set(
|
|
|
|
|
|
events
|
|
|
|
|
|
.map(e => String(e?.id || '').trim())
|
|
|
|
|
|
.filter(id => idRe.test(id))
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const allowed = new Set([...(existingEventIds || []), ...newIds]);
|
|
|
|
|
|
|
|
|
|
|
|
for (const e of events) {
|
|
|
|
|
|
const selfId = String(e?.id || '').trim();
|
|
|
|
|
|
if (!idRe.test(selfId)) {
|
|
|
|
|
|
// id 不合格的话,causedBy 直接清空,避免污染
|
|
|
|
|
|
e.causedBy = [];
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const raw = Array.isArray(e.causedBy) ? e.causedBy : [];
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
for (const x of raw) {
|
|
|
|
|
|
const cid = String(x || '').trim();
|
|
|
|
|
|
if (!idRe.test(cid)) continue;
|
|
|
|
|
|
if (cid === selfId) continue;
|
|
|
|
|
|
if (!allowed.has(cid)) continue;
|
|
|
|
|
|
if (seen.has(cid)) continue;
|
|
|
|
|
|
seen.add(cid);
|
|
|
|
|
|
out.push(cid);
|
|
|
|
|
|
if (out.length >= MAX_CAUSED_BY) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
e.causedBy = out;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 01:16:35 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 辅助函数
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
export function formatExistingSummaryForAI(store) {
|
|
|
|
|
|
if (!store?.json) return "(空白,这是首次总结)";
|
|
|
|
|
|
|
|
|
|
|
|
const data = store.json;
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (data.events?.length) {
|
|
|
|
|
|
parts.push("【已记录事件】");
|
|
|
|
|
|
data.events.forEach((ev, i) => parts.push(`${i + 1}. [${ev.timeLabel}] ${ev.title}:${ev.summary}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.characters?.main?.length) {
|
|
|
|
|
|
const names = data.characters.main.map(m => typeof m === 'string' ? m : m.name);
|
|
|
|
|
|
parts.push(`\n【主要角色】${names.join("、")}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.characters?.relationships?.length) {
|
|
|
|
|
|
parts.push("【人物关系】");
|
|
|
|
|
|
data.characters.relationships.forEach(r => parts.push(`- ${r.from} → ${r.to}:${r.label}(${r.trend})`));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.arcs?.length) {
|
|
|
|
|
|
parts.push("【角色弧光】");
|
|
|
|
|
|
data.arcs.forEach(a => parts.push(`- ${a.name}:${a.trajectory}(进度${Math.round(a.progress * 100)}%)`));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.keywords?.length) {
|
|
|
|
|
|
parts.push(`\n【关键词】${data.keywords.map(k => k.text).join("、")}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return parts.join("\n") || "(空白,这是首次总结)";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getNextEventId(store) {
|
|
|
|
|
|
const events = store?.json?.events || [];
|
|
|
|
|
|
if (!events.length) return 1;
|
|
|
|
|
|
|
|
|
|
|
|
const maxId = Math.max(...events.map(e => {
|
|
|
|
|
|
const match = e.id?.match(/evt-(\d+)/);
|
|
|
|
|
|
return match ? parseInt(match[1]) : 0;
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return maxId + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildIncrementalSlice(targetMesId, lastSummarizedMesId, maxPerRun = 100) {
|
|
|
|
|
|
const { chat, name1, name2 } = getContext();
|
|
|
|
|
|
|
|
|
|
|
|
const start = Math.max(0, (lastSummarizedMesId ?? -1) + 1);
|
|
|
|
|
|
const rawEnd = Math.min(targetMesId, chat.length - 1);
|
|
|
|
|
|
const end = Math.min(rawEnd, start + maxPerRun - 1);
|
|
|
|
|
|
|
|
|
|
|
|
if (start > end) return { text: "", count: 0, range: "", endMesId: -1 };
|
|
|
|
|
|
|
|
|
|
|
|
const userLabel = name1 || '用户';
|
|
|
|
|
|
const charLabel = name2 || '角色';
|
|
|
|
|
|
const slice = chat.slice(start, end + 1);
|
|
|
|
|
|
|
|
|
|
|
|
const text = slice.map((m, i) => {
|
|
|
|
|
|
const speaker = m.name || (m.is_user ? userLabel : charLabel);
|
|
|
|
|
|
return `#${start + i + 1} 【${speaker}】\n${m.mes}`;
|
|
|
|
|
|
}).join('\n\n');
|
|
|
|
|
|
|
|
|
|
|
|
return { text, count: slice.length, range: `${start + 1}-${end + 1}楼`, endMesId: end };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 主生成函数
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
export async function runSummaryGeneration(mesId, config, callbacks = {}) {
|
|
|
|
|
|
const { onStatus, onError, onComplete } = callbacks;
|
|
|
|
|
|
|
|
|
|
|
|
const store = getSummaryStore();
|
|
|
|
|
|
const lastSummarized = store?.lastSummarizedMesId ?? -1;
|
|
|
|
|
|
const maxPerRun = config.trigger?.maxPerRun || 100;
|
|
|
|
|
|
const slice = buildIncrementalSlice(mesId, lastSummarized, maxPerRun);
|
|
|
|
|
|
|
|
|
|
|
|
if (slice.count === 0) {
|
|
|
|
|
|
onStatus?.("没有新的对话需要总结");
|
|
|
|
|
|
return { success: true, noContent: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onStatus?.(`正在总结 ${slice.range}(${slice.count}楼新内容)...`);
|
|
|
|
|
|
|
|
|
|
|
|
const existingSummary = formatExistingSummaryForAI(store);
|
|
|
|
|
|
const existingWorld = store?.json?.world || [];
|
|
|
|
|
|
const nextEventId = getNextEventId(store);
|
|
|
|
|
|
const existingEventCount = store?.json?.events?.length || 0;
|
|
|
|
|
|
const useStream = config.trigger?.useStream !== false;
|
|
|
|
|
|
|
|
|
|
|
|
let raw;
|
|
|
|
|
|
try {
|
|
|
|
|
|
raw = await generateSummary({
|
|
|
|
|
|
existingSummary,
|
|
|
|
|
|
existingWorld,
|
|
|
|
|
|
newHistoryText: slice.text,
|
|
|
|
|
|
historyRange: slice.range,
|
|
|
|
|
|
nextEventId,
|
|
|
|
|
|
existingEventCount,
|
|
|
|
|
|
llmApi: {
|
|
|
|
|
|
provider: config.api?.provider,
|
|
|
|
|
|
url: config.api?.url,
|
|
|
|
|
|
key: config.api?.key,
|
|
|
|
|
|
model: config.api?.model,
|
|
|
|
|
|
},
|
|
|
|
|
|
genParams: config.gen || {},
|
|
|
|
|
|
useStream,
|
|
|
|
|
|
timeout: 120000,
|
|
|
|
|
|
sessionId: SUMMARY_SESSION_ID,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
xbLog.error(MODULE_ID, '生成失败', err);
|
|
|
|
|
|
onError?.(err?.message || "生成失败");
|
|
|
|
|
|
return { success: false, error: err };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!raw?.trim()) {
|
|
|
|
|
|
xbLog.error(MODULE_ID, 'AI返回为空');
|
|
|
|
|
|
onError?.("AI返回为空");
|
|
|
|
|
|
return { success: false, error: "empty" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = parseSummaryJson(raw);
|
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
|
xbLog.error(MODULE_ID, 'JSON解析失败');
|
|
|
|
|
|
onError?.("AI未返回有效JSON");
|
|
|
|
|
|
return { success: false, error: "parse" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sanitizeWorldUpdate(parsed);
|
2026-01-26 23:50:48 +08:00
|
|
|
|
const existingEventIds = new Set((store?.json?.events || []).map(e => e?.id).filter(Boolean));
|
|
|
|
|
|
sanitizeEventsCausality(parsed, existingEventIds);
|
2026-01-26 01:16:35 +08:00
|
|
|
|
|
|
|
|
|
|
const merged = mergeNewData(store?.json || {}, parsed, slice.endMesId);
|
|
|
|
|
|
|
|
|
|
|
|
store.lastSummarizedMesId = slice.endMesId;
|
|
|
|
|
|
store.json = merged;
|
|
|
|
|
|
store.updatedAt = Date.now();
|
|
|
|
|
|
addSummarySnapshot(store, slice.endMesId);
|
|
|
|
|
|
saveSummaryStore();
|
|
|
|
|
|
|
|
|
|
|
|
xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1} 楼`);
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.worldUpdate?.length) {
|
|
|
|
|
|
xbLog.info(MODULE_ID, `世界状态更新: ${parsed.worldUpdate.length} 条`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const newEventIds = (parsed.events || []).map(e => e.id);
|
|
|
|
|
|
|
|
|
|
|
|
onComplete?.({
|
|
|
|
|
|
merged,
|
|
|
|
|
|
endMesId: slice.endMesId,
|
|
|
|
|
|
newEventIds,
|
|
|
|
|
|
l3Stats: { worldUpdate: parsed.worldUpdate?.length || 0 },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { success: true, merged, endMesId: slice.endMesId, newEventIds };
|
|
|
|
|
|
}
|