From ecc92c6f63337de14c3f6e3d471311c48a4069c9 Mon Sep 17 00:00:00 2001 From: RT15548 Date: Wed, 21 Jan 2026 11:59:54 +0800 Subject: [PATCH] Add files via upload --- modules/story-summary.js | 1100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1100 insertions(+) create mode 100644 modules/story-summary.js diff --git a/modules/story-summary.js b/modules/story-summary.js new file mode 100644 index 0000000..e18dc3e --- /dev/null +++ b/modules/story-summary.js @@ -0,0 +1,1100 @@ +// ═══════════════════════════════════════════════════════════════════════════ +// 导入 +// ═══════════════════════════════════════════════════════════════════════════ + +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; + // 无论是否有总结,都保存用户的偏好设置 + store.hideSummarizedHistory = !!data.enabled; + saveSummaryStore(); + // 只有有总结时才执行隐藏/显示命令 + if (lastSummarized >= 0) { + 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 = $(` + + `); + + $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 = ''; + 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 cfg = getSummaryPanelConfig(); + let text = formatSummaryForPrompt(store); + + if (cfg.trigger?.wrapperHead) { + text = cfg.trigger.wrapperHead + '\n' + text; + } + if (cfg.trigger?.wrapperTail) { + text = text + '\n' + cfg.trigger.wrapperTail; + } + 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; + + if (cfg.trigger?.forceInsertAtEnd) { + depth = 10000; + } + 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(); +});