Files
LittleWhiteBox/modules/story-summary/story-summary.js
2026-01-18 01:48:30 +08:00

1087 lines
43 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.
// ═══════════════════════════════════════════════════════════════════════════
// 导入
// ═══════════════════════════════════════════════════════════════════════════
import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js";
import {
chat_metadata,
extension_prompts,
extension_prompt_types,
extension_prompt_roles,
} from "../../../../../../script.js";
import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
import { createModuleEvents, event_types } from "../../core/event-manager.js";
import { xbLog, CacheRegistry } from "../../core/debug-core.js";
import { postToIframe, isTrustedMessage } from "../../core/iframe-messaging.js";
import { CommonSettingStorage } from "../../core/server-storage.js";
import { generateSummary, parseSummaryJson } from "./llm-service.js";
// ═══════════════════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════════════════
const MODULE_ID = 'storySummary';
const events = createModuleEvents(MODULE_ID);
const SUMMARY_SESSION_ID = 'xb9';
const SUMMARY_PROMPT_KEY = 'LittleWhiteBox_StorySummary';
const SUMMARY_CONFIG_KEY = 'storySummaryPanelConfig';
const iframePath = `${extensionFolderPath}/modules/story-summary/story-summary.html`;
const VALID_SECTIONS = ['keywords', 'events', 'characters', 'arcs'];
// ═══════════════════════════════════════════════════════════════════════════
// 状态变量
// ═══════════════════════════════════════════════════════════════════════════
let summaryGenerating = false;
let overlayCreated = false;
let frameReady = false;
let currentMesId = null;
let pendingFrameMessages = [];
let eventsRegistered = false;
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
const sleep = ms => new Promise(r => setTimeout(r, ms));
function getKeepVisibleCount() {
const store = getSummaryStore();
return store?.keepVisibleCount ?? 3;
}
function calcHideRange(lastSummarized) {
const keepCount = getKeepVisibleCount();
const hideEnd = lastSummarized - keepCount;
if (hideEnd < 0) return null;
return { start: 0, end: hideEnd };
}
function getSettings() {
const ext = extension_settings[EXT_ID] ||= {};
ext.storySummary ||= { enabled: true };
return ext;
}
function getSummaryStore() {
const { chatId } = getContext();
if (!chatId) return null;
chat_metadata.extensions ||= {};
chat_metadata.extensions[EXT_ID] ||= {};
chat_metadata.extensions[EXT_ID].storySummary ||= {};
return chat_metadata.extensions[EXT_ID].storySummary;
}
function saveSummaryStore() {
saveMetadataDebounced?.();
}
async function executeSlashCommand(command) {
try {
const executeCmd = window.executeSlashCommands
|| window.executeSlashCommandsOnChatInput
|| (typeof SillyTavern !== 'undefined' && SillyTavern.getContext()?.executeSlashCommands);
if (executeCmd) {
await executeCmd(command);
} else if (typeof window.STscript === 'function') {
await window.STscript(command);
}
} catch (e) {
xbLog.error(MODULE_ID, `执行命令失败: ${command}`, e);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 总结数据工具(保留在主模块,因为依赖 store 对象)
// ═══════════════════════════════════════════════════════════════════════════
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") || "(空白,这是首次总结)";
}
function getNextEventId(store) {
const events = store?.json?.events || [];
if (events.length === 0) 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;
}
// ═══════════════════════════════════════════════════════════════════════════
// 快照与数据合并
// ═══════════════════════════════════════════════════════════════════════════
function addSummarySnapshot(store, endMesId) {
store.summaryHistory ||= [];
store.summaryHistory.push({ endMesId });
}
function mergeNewData(oldJson, parsed, endMesId) {
const merged = structuredClone(oldJson || {});
merged.keywords ||= [];
merged.events ||= [];
merged.characters ||= {};
merged.characters.main ||= [];
merged.characters.relationships ||= [];
merged.arcs ||= [];
// 关键词:完全替换(全局关键词)
if (parsed.keywords?.length) {
merged.keywords = parsed.keywords.map(k => ({ ...k, _addedAt: endMesId }));
}
// 事件:追加
(parsed.events || []).forEach(e => {
e._addedAt = endMesId;
merged.events.push(e);
});
// 新角色:追加不重复
const existingMain = new Set(
(merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name)
);
(parsed.newCharacters || []).forEach(name => {
if (!existingMain.has(name)) {
merged.characters.main.push({ name, _addedAt: endMesId });
}
});
// 关系:更新或追加
const relMap = new Map(
(merged.characters.relationships || []).map(r => [`${r.from}->${r.to}`, r])
);
(parsed.newRelationships || []).forEach(r => {
const key = `${r.from}->${r.to}`;
const existing = relMap.get(key);
if (existing) {
existing.label = r.label;
existing.trend = r.trend;
} else {
r._addedAt = endMesId;
relMap.set(key, r);
}
});
merged.characters.relationships = Array.from(relMap.values());
// 弧光:更新或追加
const arcMap = new Map((merged.arcs || []).map(a => [a.name, a]));
(parsed.arcUpdates || []).forEach(update => {
const existing = arcMap.get(update.name);
if (existing) {
existing.trajectory = update.trajectory;
existing.progress = update.progress;
if (update.newMoment) {
existing.moments = existing.moments || [];
existing.moments.push({ text: update.newMoment, _addedAt: endMesId });
}
} else {
arcMap.set(update.name, {
name: update.name,
trajectory: update.trajectory,
progress: update.progress,
moments: update.newMoment ? [{ text: update.newMoment, _addedAt: endMesId }] : [],
_addedAt: endMesId,
});
}
});
merged.arcs = Array.from(arcMap.values());
return merged;
}
// ═══════════════════════════════════════════════════════════════════════════
// 回滚逻辑
// ═══════════════════════════════════════════════════════════════════════════
function rollbackSummaryIfNeeded() {
const { chat } = getContext();
const currentLength = Array.isArray(chat) ? chat.length : 0;
const store = getSummaryStore();
if (!store || store.lastSummarizedMesId == null || store.lastSummarizedMesId < 0) {
return false;
}
const lastSummarized = store.lastSummarizedMesId;
if (currentLength <= lastSummarized) {
const deletedCount = lastSummarized + 1 - currentLength;
if (deletedCount < 2) {
return false;
}
xbLog.warn(MODULE_ID, `删除已总结楼层 ${deletedCount} 条,当前${currentLength},原总结到${lastSummarized + 1},触发回滚`);
const history = store.summaryHistory || [];
let targetEndMesId = -1;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].endMesId < currentLength) {
targetEndMesId = history[i].endMesId;
break;
}
}
executeFilterRollback(store, targetEndMesId, currentLength);
return true;
}
return false;
}
function executeFilterRollback(store, targetEndMesId, currentLength) {
const oldLastSummarized = store.lastSummarizedMesId ?? -1;
const wasHidden = store.hideSummarizedHistory;
const oldHideRange = wasHidden ? calcHideRange(oldLastSummarized) : null;
if (targetEndMesId < 0) {
store.lastSummarizedMesId = -1;
store.json = null;
store.summaryHistory = [];
store.hideSummarizedHistory = false;
} else {
const json = store.json || {};
json.events = (json.events || []).filter(e => (e._addedAt ?? 0) <= targetEndMesId);
json.keywords = (json.keywords || []).filter(k => (k._addedAt ?? 0) <= targetEndMesId);
json.arcs = (json.arcs || []).filter(a => (a._addedAt ?? 0) <= targetEndMesId);
json.arcs.forEach(a => {
a.moments = (a.moments || []).filter(m =>
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
);
});
if (json.characters) {
json.characters.main = (json.characters.main || []).filter(m =>
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
);
json.characters.relationships = (json.characters.relationships || []).filter(r =>
(r._addedAt ?? 0) <= targetEndMesId
);
}
store.json = json;
store.lastSummarizedMesId = targetEndMesId;
store.summaryHistory = (store.summaryHistory || []).filter(h => h.endMesId <= targetEndMesId);
}
if (oldHideRange && oldHideRange.end >= 0) {
const newHideRange = (targetEndMesId >= 0 && store.hideSummarizedHistory)
? calcHideRange(targetEndMesId)
: null;
const unhideStart = newHideRange ? Math.min(newHideRange.end + 1, currentLength) : 0;
const unhideEnd = Math.min(oldHideRange.end, currentLength - 1);
if (unhideStart <= unhideEnd) {
executeSlashCommand(`/unhide ${unhideStart}-${unhideEnd}`);
}
}
store.updatedAt = Date.now();
saveSummaryStore();
updateSummaryExtensionPrompt();
notifyFrameAfterRollback(store);
}
function notifyFrameAfterRollback(store) {
const { chat } = getContext();
const totalFloors = Array.isArray(chat) ? chat.length : 0;
const lastSummarized = store.lastSummarizedMesId ?? -1;
if (store.json) {
postToFrame({
type: "SUMMARY_FULL_DATA",
payload: {
keywords: store.json.keywords || [],
events: store.json.events || [],
characters: store.json.characters || { main: [], relationships: [] },
arcs: store.json.arcs || [],
lastSummarizedMesId: lastSummarized,
},
});
} else {
postToFrame({ type: "SUMMARY_CLEARED", payload: { totalFloors } });
}
postToFrame({
type: "SUMMARY_BASE_DATA",
stats: {
totalFloors,
summarizedUpTo: lastSummarized + 1,
eventsCount: store.json?.events?.length || 0,
pendingFloors: totalFloors - lastSummarized - 1,
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 生成状态管理
// ═══════════════════════════════════════════════════════════════════════════
function setSummaryGenerating(flag) {
summaryGenerating = !!flag;
postToFrame({ type: "GENERATION_STATE", isGenerating: summaryGenerating });
}
function isSummaryGenerating() {
return summaryGenerating;
}
// ═══════════════════════════════════════════════════════════════════════════
// iframe 通讯
// ═══════════════════════════════════════════════════════════════════════════
function postToFrame(payload) {
const iframe = document.getElementById("xiaobaix-story-summary-iframe");
if (!iframe?.contentWindow || !frameReady) {
pendingFrameMessages.push(payload);
return;
}
postToIframe(iframe, payload, "LittleWhiteBox");
}
function flushPendingFrameMessages() {
if (!frameReady) return;
const iframe = document.getElementById("xiaobaix-story-summary-iframe");
if (!iframe?.contentWindow) return;
pendingFrameMessages.forEach(p => postToIframe(iframe, p, "LittleWhiteBox"));
pendingFrameMessages = [];
}
function handleFrameMessage(event) {
const iframe = document.getElementById("xiaobaix-story-summary-iframe");
if (!isTrustedMessage(event, iframe, "LittleWhiteBox-StoryFrame")) return;
const data = event.data;
switch (data.type) {
case "FRAME_READY":
frameReady = true;
flushPendingFrameMessages();
setSummaryGenerating(summaryGenerating);
sendSavedConfigToFrame();
break;
case "SETTINGS_OPENED":
case "FULLSCREEN_OPENED":
case "EDITOR_OPENED":
$(".xb-ss-close-btn").hide();
break;
case "SETTINGS_CLOSED":
case "FULLSCREEN_CLOSED":
case "EDITOR_CLOSED":
$(".xb-ss-close-btn").show();
break;
case "REQUEST_GENERATE": {
const ctx = getContext();
currentMesId = (ctx.chat?.length ?? 1) - 1;
runSummaryGeneration(currentMesId, data.config || {});
break;
}
case "REQUEST_CANCEL":
window.xiaobaixStreamingGeneration?.cancel?.(SUMMARY_SESSION_ID);
setSummaryGenerating(false);
postToFrame({ type: "SUMMARY_STATUS", statusText: "已停止" });
break;
case "REQUEST_CLEAR": {
const { chat } = getContext();
const store = getSummaryStore();
if (store) {
delete store.json;
store.lastSummarizedMesId = -1;
store.updatedAt = Date.now();
saveSummaryStore();
}
clearSummaryExtensionPrompt();
postToFrame({
type: "SUMMARY_CLEARED",
payload: { totalFloors: Array.isArray(chat) ? chat.length : 0 },
});
xbLog.info(MODULE_ID, '总结数据已清空');
break;
}
case "CLOSE_PANEL":
hideOverlay();
break;
case "UPDATE_SECTION": {
const store = getSummaryStore();
if (!store) break;
store.json ||= {};
if (VALID_SECTIONS.includes(data.section)) {
store.json[data.section] = data.data;
}
store.updatedAt = Date.now();
saveSummaryStore();
updateSummaryExtensionPrompt();
break;
}
case "TOGGLE_HIDE_SUMMARIZED": {
const store = getSummaryStore();
if (!store) break;
const lastSummarized = store.lastSummarizedMesId ?? -1;
if (lastSummarized < 0) break;
store.hideSummarizedHistory = !!data.enabled;
saveSummaryStore();
if (data.enabled) {
const range = calcHideRange(lastSummarized);
if (range) executeSlashCommand(`/hide ${range.start}-${range.end}`);
} else {
executeSlashCommand(`/unhide 0-${lastSummarized}`);
}
break;
}
case "UPDATE_KEEP_VISIBLE": {
const store = getSummaryStore();
if (!store) break;
const oldCount = store.keepVisibleCount ?? 3;
const newCount = Math.max(0, Math.min(50, parseInt(data.count) || 3));
if (newCount === oldCount) break;
store.keepVisibleCount = newCount;
saveSummaryStore();
const lastSummarized = store.lastSummarizedMesId ?? -1;
if (store.hideSummarizedHistory && lastSummarized >= 0) {
(async () => {
await executeSlashCommand(`/unhide 0-${lastSummarized}`);
const range = calcHideRange(lastSummarized);
if (range) {
await executeSlashCommand(`/hide ${range.start}-${range.end}`);
}
const { chat } = getContext();
sendFrameBaseData(store, Array.isArray(chat) ? chat.length : 0);
})();
} else {
const { chat } = getContext();
sendFrameBaseData(store, Array.isArray(chat) ? chat.length : 0);
}
break;
}
case "SAVE_PANEL_CONFIG":
if (data.config) {
CommonSettingStorage.set(SUMMARY_CONFIG_KEY, data.config);
xbLog.info(MODULE_ID, '面板配置已保存到服务器');
}
break;
case "REQUEST_PANEL_CONFIG":
sendSavedConfigToFrame();
break;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Overlay 面板
// ═══════════════════════════════════════════════════════════════════════════
function createOverlay() {
if (overlayCreated) return;
overlayCreated = true;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent);
const isNarrow = window.matchMedia?.('(max-width: 768px)').matches;
const overlayHeight = (isMobile || isNarrow) ? '92.5vh' : '100vh';
const $overlay = $(`
<div id="xiaobaix-story-summary-overlay" style="
position: fixed !important; inset: 0 !important;
width: 100vw !important; height: ${overlayHeight} !important;
z-index: 99999 !important; display: none; overflow: hidden !important;
">
<div class="xb-ss-backdrop" style="
position: absolute !important; inset: 0 !important;
background: rgba(0,0,0,.55) !important;
backdrop-filter: blur(4px) !important;
"></div>
<div class="xb-ss-frame-wrap" style="
position: absolute !important; inset: 12px !important; z-index: 1 !important;
">
<iframe id="xiaobaix-story-summary-iframe" class="xiaobaix-iframe"
src="${iframePath}"
style="width:100% !important; height:100% !important; border:none !important;
border-radius:12px !important; box-shadow:0 0 30px rgba(0,0,0,.4) !important;
background:#fafafa !important;">
</iframe>
</div>
<button class="xb-ss-close-btn" style="
position: absolute !important; top: 20px !important; right: 20px !important;
z-index: 2 !important; width: 36px !important; height: 36px !important;
border-radius: 50% !important; border: none !important;
background: rgba(0,0,0,.6) !important; color: #fff !important;
font-size: 20px !important; cursor: pointer !important;
display: flex !important; align-items: center !important;
justify-content: center !important;
">✕</button>
</div>
`);
$overlay.on("click", ".xb-ss-backdrop, .xb-ss-close-btn", hideOverlay);
document.body.appendChild($overlay[0]);
// eslint-disable-next-line no-restricted-syntax
window.addEventListener("message", handleFrameMessage);
}
function showOverlay() {
if (!overlayCreated) createOverlay();
$("#xiaobaix-story-summary-overlay").show();
}
function hideOverlay() {
$("#xiaobaix-story-summary-overlay").hide();
}
// ═══════════════════════════════════════════════════════════════════════════
// 楼层按钮
// ═══════════════════════════════════════════════════════════════════════════
function createSummaryBtn(mesId) {
const btn = document.createElement('div');
btn.className = 'mes_btn xiaobaix-story-summary-btn';
btn.title = '剧情总结';
btn.dataset.mesid = mesId;
btn.innerHTML = '<i class="fa-solid fa-chart-line"></i>';
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
if (!getSettings().storySummary?.enabled) return;
currentMesId = Number(mesId);
openPanelForMessage(currentMesId);
});
return btn;
}
function addSummaryBtnToMessage(mesId) {
if (!getSettings().storySummary?.enabled) return;
const msg = document.querySelector(`#chat .mes[mesid="${mesId}"]`);
if (!msg || msg.querySelector('.xiaobaix-story-summary-btn')) return;
const btn = createSummaryBtn(mesId);
if (window.registerButtonToSubContainer?.(mesId, btn)) return;
msg.querySelector('.flex-container.flex1.alignitemscenter')?.appendChild(btn);
}
function initButtonsForAll() {
if (!getSettings().storySummary?.enabled) return;
$("#chat .mes").each((_, el) => {
const mesId = el.getAttribute("mesid");
if (mesId != null) addSummaryBtnToMessage(mesId);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 打开面板与数据发送
// ═══════════════════════════════════════════════════════════════════════════
async function sendSavedConfigToFrame() {
try {
const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
if (savedConfig) {
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
xbLog.info(MODULE_ID, '已从服务器加载面板配置');
}
} catch (e) {
xbLog.warn(MODULE_ID, '加载面板配置失败', e);
}
}
function sendFrameBaseData(store, totalFloors) {
const lastSummarized = store?.lastSummarizedMesId ?? -1;
const range = calcHideRange(lastSummarized);
const hiddenCount = range ? range.end + 1 : 0;
postToFrame({
type: "SUMMARY_BASE_DATA",
stats: {
totalFloors,
summarizedUpTo: lastSummarized + 1,
eventsCount: store?.json?.events?.length || 0,
pendingFloors: totalFloors - lastSummarized - 1,
hiddenCount,
},
hideSummarized: store?.hideSummarizedHistory || false,
keepVisibleCount: store?.keepVisibleCount ?? 3,
});
}
function sendFrameFullData(store, totalFloors) {
const lastSummarized = store?.lastSummarizedMesId ?? -1;
if (store?.json) {
postToFrame({
type: "SUMMARY_FULL_DATA",
payload: {
keywords: store.json.keywords || [],
events: store.json.events || [],
characters: store.json.characters || { main: [], relationships: [] },
arcs: store.json.arcs || [],
lastSummarizedMesId: lastSummarized,
},
});
} else {
postToFrame({ type: "SUMMARY_CLEARED", payload: { totalFloors } });
}
}
function openPanelForMessage(mesId) {
createOverlay();
showOverlay();
const { chat } = getContext();
const store = getSummaryStore();
const totalFloors = chat.length;
sendFrameBaseData(store, totalFloors);
sendFrameFullData(store, totalFloors);
setSummaryGenerating(summaryGenerating);
}
// ═══════════════════════════════════════════════════════════════════════════
// 增量总结生成
// ═══════════════════════════════════════════════════════════════════════════
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 };
}
function getSummaryPanelConfig() {
const defaults = {
api: { provider: 'st', url: '', key: '', model: '', modelCache: [] },
gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null },
trigger: { enabled: false, interval: 20, timing: 'after_ai', useStream: true, maxPerRun: 100 },
};
try {
const raw = localStorage.getItem('summary_panel_config');
if (!raw) return defaults;
const parsed = JSON.parse(raw);
const result = {
api: { ...defaults.api, ...(parsed.api || {}) },
gen: { ...defaults.gen, ...(parsed.gen || {}) },
trigger: { ...defaults.trigger, ...(parsed.trigger || {}) },
};
if (result.trigger.timing === 'manual') result.trigger.enabled = false;
if (result.trigger.useStream === undefined) result.trigger.useStream = true;
return result;
} catch {
return defaults;
}
}
async function runSummaryGeneration(mesId, configFromFrame) {
if (isSummaryGenerating()) {
postToFrame({ type: "SUMMARY_STATUS", statusText: "上一轮总结仍在进行中..." });
return false;
}
setSummaryGenerating(true);
xbLog.info(MODULE_ID, `开始总结 mesId=${mesId}`);
const cfg = configFromFrame || {};
const store = getSummaryStore();
const lastSummarized = store?.lastSummarizedMesId ?? -1;
const maxPerRun = cfg.trigger?.maxPerRun || 100;
const slice = buildIncrementalSlice(mesId, lastSummarized, maxPerRun);
if (slice.count === 0) {
postToFrame({ type: "SUMMARY_STATUS", statusText: "没有新的对话需要总结" });
setSummaryGenerating(false);
return true;
}
postToFrame({ type: "SUMMARY_STATUS", statusText: `正在总结 ${slice.range}${slice.count}楼新内容)...` });
const existingSummary = formatExistingSummaryForAI(store);
const nextEventId = getNextEventId(store);
const existingEventCount = store?.json?.events?.length || 0;
const useStream = cfg.trigger?.useStream !== false;
const apiCfg = cfg.api || {};
const genCfg = cfg.gen || {};
let raw;
try {
raw = await generateSummary({
existingSummary,
newHistoryText: slice.text,
historyRange: slice.range,
nextEventId,
existingEventCount,
llmApi: {
provider: apiCfg.provider,
url: apiCfg.url,
key: apiCfg.key,
model: apiCfg.model,
},
genParams: genCfg,
useStream,
timeout: 120000,
sessionId: SUMMARY_SESSION_ID,
});
} catch (err) {
xbLog.error(MODULE_ID, '生成失败', err);
postToFrame({ type: "SUMMARY_ERROR", message: err?.message || "生成失败" });
setSummaryGenerating(false);
return false;
}
if (!raw?.trim()) {
xbLog.error(MODULE_ID, 'AI返回为空');
postToFrame({ type: "SUMMARY_ERROR", message: "AI返回为空" });
setSummaryGenerating(false);
return false;
}
const parsed = parseSummaryJson(raw);
if (!parsed) {
xbLog.error(MODULE_ID, 'JSON解析失败');
postToFrame({ type: "SUMMARY_ERROR", message: "AI未返回有效JSON" });
setSummaryGenerating(false);
return false;
}
const oldJson = store?.json || {};
const merged = mergeNewData(oldJson, parsed, slice.endMesId);
store.lastSummarizedMesId = slice.endMesId;
store.json = merged;
store.updatedAt = Date.now();
addSummarySnapshot(store, slice.endMesId);
saveSummaryStore();
postToFrame({
type: "SUMMARY_FULL_DATA",
payload: {
keywords: merged.keywords || [],
events: merged.events || [],
characters: merged.characters || { main: [], relationships: [] },
arcs: merged.arcs || [],
lastSummarizedMesId: slice.endMesId,
},
});
postToFrame({
type: "SUMMARY_STATUS",
statusText: `已更新至 ${slice.endMesId + 1} 楼 · ${merged.events?.length || 0} 个事件`,
});
const { chat } = getContext();
const totalFloors = Array.isArray(chat) ? chat.length : 0;
const newHideRange = calcHideRange(slice.endMesId);
let actualHiddenCount = 0;
if (store.hideSummarizedHistory && newHideRange) {
const oldHideRange = calcHideRange(lastSummarized);
const newHideStart = oldHideRange ? oldHideRange.end + 1 : 0;
if (newHideStart <= newHideRange.end) {
executeSlashCommand(`/hide ${newHideStart}-${newHideRange.end}`);
}
actualHiddenCount = newHideRange.end + 1;
}
postToFrame({
type: "SUMMARY_BASE_DATA",
stats: {
totalFloors,
summarizedUpTo: slice.endMesId + 1,
eventsCount: merged.events?.length || 0,
pendingFloors: totalFloors - slice.endMesId - 1,
hiddenCount: actualHiddenCount,
},
});
updateSummaryExtensionPrompt();
setSummaryGenerating(false);
xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1} 楼,共 ${merged.events?.length || 0} 个事件`);
return true;
}
// ═══════════════════════════════════════════════════════════════════════════
// 自动触发总结
// ═══════════════════════════════════════════════════════════════════════════
async function maybeAutoRunSummary(reason) {
const { chatId, chat } = getContext();
if (!chatId || !Array.isArray(chat)) return;
if (!getSettings().storySummary?.enabled) return;
const cfgAll = getSummaryPanelConfig();
const trig = cfgAll.trigger || {};
if (trig.timing === 'manual') return;
if (!trig.enabled) return;
if (trig.timing === 'after_ai' && reason !== 'after_ai') return;
if (trig.timing === 'before_user' && reason !== 'before_user') return;
if (isSummaryGenerating()) return;
const store = getSummaryStore();
const lastSummarized = store?.lastSummarizedMesId ?? -1;
const pending = chat.length - lastSummarized - 1;
if (pending < (trig.interval || 1)) return;
xbLog.info(MODULE_ID, `自动触发剧情总结: reason=${reason}, pending=${pending}`);
await autoRunSummaryWithRetry(chat.length - 1, { api: cfgAll.api, gen: cfgAll.gen, trigger: trig });
}
async function autoRunSummaryWithRetry(targetMesId, configForRun) {
for (let attempt = 1; attempt <= 3; attempt++) {
if (await runSummaryGeneration(targetMesId, configForRun)) return;
if (attempt < 3) await sleep(1000);
}
xbLog.error(MODULE_ID, '自动总结失败已重试3次');
await executeSlashCommand('/echo severity=error 剧情总结失败(已自动重试 3 次)。请稍后再试。');
}
// ═══════════════════════════════════════════════════════════════════════════
// extension_prompts 注入
// ═══════════════════════════════════════════════════════════════════════════
function formatSummaryForPrompt(store) {
const data = store.json || {};
const parts = [];
parts.push("【此处是对以上可见历史,及因上下文限制被省略历史的所有总结。请严格依据此总结理解剧情背景。】");
if (data.keywords?.length) {
parts.push(`关键词:${data.keywords.map(k => k.text).join(" / ")}`);
}
if (data.events?.length) {
const lines = data.events.map(ev => `- [${ev.timeLabel}] ${ev.title}${ev.summary}`).join("\n");
parts.push(`事件:\n${lines}`);
}
if (data.arcs?.length) {
const lines = data.arcs.map(a => {
const moments = (a.moments || []).map(m => typeof m === 'string' ? m : m.text);
if (!moments.length) return `- ${a.name}${a.trajectory}`;
return `- ${a.name}${moments.join(" → ")}(当前:${a.trajectory}`;
}).join("\n");
parts.push(`角色弧光:\n${lines}`);
}
return `<剧情总结>\n${parts.join("\n\n")}\n</剧情总结>\n以下是总结后新发生的情节:`;
}
function updateSummaryExtensionPrompt() {
if (!getSettings().storySummary?.enabled) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const { chat } = getContext();
const store = getSummaryStore();
if (!store?.json) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const text = formatSummaryForPrompt(store);
if (!text.trim()) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const lastIdx = store.lastSummarizedMesId ?? 0;
const length = Array.isArray(chat) ? chat.length : 0;
if (lastIdx >= length) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
let depth = length - lastIdx - 1;
if (depth < 0) depth = 0;
extension_prompts[SUMMARY_PROMPT_KEY] = {
value: text,
position: extension_prompt_types.IN_CHAT,
depth,
role: extension_prompt_roles.ASSISTANT,
};
}
function clearSummaryExtensionPrompt() {
delete extension_prompts[SUMMARY_PROMPT_KEY];
}
// ═══════════════════════════════════════════════════════════════════════════
// 事件处理器
// ═══════════════════════════════════════════════════════════════════════════
function handleChatChanged() {
const { chat } = getContext();
const newLength = Array.isArray(chat) ? chat.length : 0;
rollbackSummaryIfNeeded();
initButtonsForAll();
updateSummaryExtensionPrompt();
const store = getSummaryStore();
const lastSummarized = store?.lastSummarizedMesId ?? -1;
if (lastSummarized >= 0 && store?.hideSummarizedHistory === true) {
const range = calcHideRange(lastSummarized);
if (range) executeSlashCommand(`/hide ${range.start}-${range.end}`);
}
if (frameReady) {
sendFrameBaseData(store, newLength);
sendFrameFullData(store, newLength);
}
}
function handleMessageDeleted() {
rollbackSummaryIfNeeded();
updateSummaryExtensionPrompt();
}
function handleMessageReceived() {
updateSummaryExtensionPrompt();
initButtonsForAll();
setTimeout(() => maybeAutoRunSummary('after_ai'), 1000);
}
function handleMessageSent() {
updateSummaryExtensionPrompt();
initButtonsForAll();
setTimeout(() => maybeAutoRunSummary('before_user'), 1000);
}
function handleMessageUpdated() {
rollbackSummaryIfNeeded();
updateSummaryExtensionPrompt();
initButtonsForAll();
}
function handleMessageRendered(data) {
const mesId = data?.element ? $(data.element).attr("mesid") : data?.messageId;
if (mesId != null) {
addSummaryBtnToMessage(mesId);
} else {
initButtonsForAll();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 事件注册
// ═══════════════════════════════════════════════════════════════════════════
function registerEvents() {
if (eventsRegistered) return;
eventsRegistered = true;
xbLog.info(MODULE_ID, '模块初始化');
CacheRegistry.register(MODULE_ID, {
name: '待发送消息队列',
getSize: () => pendingFrameMessages.length,
getBytes: () => {
try { return JSON.stringify(pendingFrameMessages || []).length * 2; }
catch { return 0; }
},
clear: () => {
pendingFrameMessages = [];
frameReady = false;
},
});
initButtonsForAll();
events.on(event_types.CHAT_CHANGED, () => setTimeout(handleChatChanged, 80));
events.on(event_types.MESSAGE_DELETED, () => setTimeout(handleMessageDeleted, 50));
events.on(event_types.MESSAGE_RECEIVED, () => setTimeout(handleMessageReceived, 150));
events.on(event_types.MESSAGE_SENT, () => setTimeout(handleMessageSent, 150));
events.on(event_types.MESSAGE_SWIPED, () => setTimeout(handleMessageUpdated, 100));
events.on(event_types.MESSAGE_UPDATED, () => setTimeout(handleMessageUpdated, 100));
events.on(event_types.MESSAGE_EDITED, () => setTimeout(handleMessageUpdated, 100));
events.on(event_types.USER_MESSAGE_RENDERED, data => setTimeout(() => handleMessageRendered(data), 50));
events.on(event_types.CHARACTER_MESSAGE_RENDERED, data => setTimeout(() => handleMessageRendered(data), 50));
}
function unregisterEvents() {
xbLog.info(MODULE_ID, '模块清理');
events.cleanup();
CacheRegistry.unregister(MODULE_ID);
eventsRegistered = false;
$(".xiaobaix-story-summary-btn").remove();
hideOverlay();
clearSummaryExtensionPrompt();
}
// ═══════════════════════════════════════════════════════════════════════════
// Toggle 监听
// ═══════════════════════════════════════════════════════════════════════════
$(document).on("xiaobaix:storySummary:toggle", (_e, enabled) => {
if (enabled) {
registerEvents();
initButtonsForAll();
updateSummaryExtensionPrompt();
} else {
unregisterEvents();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════════════════════
jQuery(() => {
if (!getSettings().storySummary?.enabled) {
clearSummaryExtensionPrompt();
return;
}
registerEvents();
updateSummaryExtensionPrompt();
});