// ═══════════════════════════════════════════════════════════════════════════ // 导入 // ═══════════════════════════════════════════════════════════════════════════ 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 = $(` `); $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 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(); });