// ═══════════════════════════════════════════════════════════════════════════ // Story Summary - 主入口(干净版) // - 注入只在 GENERATION_STARTED 发生 // - 向量关闭:注入全量总结(L3+L2+Arcs) // - 向量开启:召回 + 1万预算装配注入 // - 删除所有 updateSummaryExtensionPrompt() 调用,避免覆盖/残留/竞态 // ═══════════════════════════════════════════════════════════════════════════ import { getContext } from "../../../../../extensions.js"; import { eventSource, event_types } from "../../../../../../script.js"; import { extensionFolderPath } from "../../core/constants.js"; import { xbLog, CacheRegistry } from "../../core/debug-core.js"; import { postToIframe, isTrustedMessage } from "../../core/iframe-messaging.js"; import { CommonSettingStorage } from "../../core/server-storage.js"; // config/store import { getSettings, getSummaryPanelConfig, getVectorConfig, saveVectorConfig } from "./data/config.js"; import { getSummaryStore, saveSummaryStore, calcHideRange, rollbackSummaryIfNeeded, clearSummaryData, } from "./data/store.js"; // prompt injection (ONLY on generation started) import { recallAndInjectPrompt, clearSummaryExtensionPrompt, injectNonVectorPrompt, } from "./generate/prompt.js"; // summary generation import { runSummaryGeneration } from "./generate/generator.js"; // vector service import { embed, getEngineFingerprint, checkLocalModelStatus, downloadLocalModel, cancelDownload, deleteLocalModelCache, testOnlineService, fetchOnlineModels, isLocalModelLoaded, DEFAULT_LOCAL_MODEL, } from "./vector/embedder.js"; import { getMeta, updateMeta, getAllEventVectors, saveEventVectors as saveEventVectorsToDb, clearEventVectors, clearAllChunks, saveChunks, saveChunkVectors, getStorageStats, ensureFingerprintMatch, } from "./vector/chunk-store.js"; import { buildIncrementalChunks, getChunkBuildStatus, chunkMessage, syncOnMessageDeleted, syncOnMessageSwiped, syncOnMessageReceived, } from "./vector/chunk-builder.js"; // ═══════════════════════════════════════════════════════════════════════════ // 常量 // ═══════════════════════════════════════════════════════════════════════════ const MODULE_ID = "storySummary"; const SUMMARY_CONFIG_KEY = "storySummaryPanelConfig"; const iframePath = `${extensionFolderPath}/modules/story-summary/story-summary.html`; const VALID_SECTIONS = ["keywords", "events", "characters", "arcs", "world"]; const MESSAGE_EVENT = "message"; // ═══════════════════════════════════════════════════════════════════════════ // 状态变量 // ═══════════════════════════════════════════════════════════════════════════ let summaryGenerating = false; let overlayCreated = false; let frameReady = false; let currentMesId = null; let pendingFrameMessages = []; let eventsRegistered = false; let vectorGenerating = false; let vectorCancelled = false; let hideApplyTimer = null; const HIDE_APPLY_DEBOUNCE_MS = 250; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ═══════════════════════════════════════════════════════════════════════════ // 工具:执行斜杠命令 // ═══════════════════════════════════════════════════════════════════════════ 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); } } // ═══════════════════════════════════════════════════════════════════════════ // 生成状态管理 // ═══════════════════════════════════════════════════════════════════════════ 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 = []; } // ═══════════════════════════════════════════════════════════════════════════ // 向量功能:UI 交互/状态 // ═══════════════════════════════════════════════════════════════════════════ function sendVectorConfigToFrame() { const cfg = getVectorConfig(); postToFrame({ type: "VECTOR_CONFIG", config: cfg }); } async function sendVectorStatsToFrame() { const { chatId, chat } = getContext(); if (!chatId) return; const store = getSummaryStore(); const eventCount = store?.json?.events?.length || 0; const stats = await getStorageStats(chatId); const chunkStatus = await getChunkBuildStatus(); const totalMessages = chat?.length || 0; const cfg = getVectorConfig(); let mismatch = false; if (cfg?.enabled && (stats.eventVectors > 0 || stats.chunks > 0)) { const fingerprint = getEngineFingerprint(cfg); const meta = await getMeta(chatId); mismatch = meta.fingerprint && meta.fingerprint !== fingerprint; } postToFrame({ type: "VECTOR_STATS", stats: { eventCount, eventVectors: stats.eventVectors, chunkCount: stats.chunkVectors, builtFloors: chunkStatus.builtFloors, totalFloors: chunkStatus.totalFloors, totalMessages, }, mismatch, }); } async function sendLocalModelStatusToFrame(modelId) { if (!modelId) { const cfg = getVectorConfig(); modelId = cfg?.local?.modelId || DEFAULT_LOCAL_MODEL; } const status = await checkLocalModelStatus(modelId); postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: status.status, message: status.message, }); } async function handleDownloadLocalModel(modelId) { try { postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "downloading", message: "下载中..." }); await downloadLocalModel(modelId, (percent) => { postToFrame({ type: "VECTOR_LOCAL_MODEL_PROGRESS", percent }); }); postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "ready", message: "已就绪" }); } catch (e) { if (e.message === "下载已取消") { postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "not_downloaded", message: "已取消" }); } else { postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "error", message: e.message }); } } } function handleCancelDownload() { cancelDownload(); postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "not_downloaded", message: "已取消" }); } async function handleDeleteLocalModel(modelId) { try { await deleteLocalModelCache(modelId); postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "not_downloaded", message: "未下载" }); } catch (e) { postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "error", message: e.message }); } } async function handleTestOnlineService(provider, config) { try { postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "downloading", message: "连接中..." }); const result = await testOnlineService(provider, config); postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "success", message: `连接成功 (${result.dims}维)`, }); } catch (e) { postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "error", message: e.message }); } } async function handleFetchOnlineModels(config) { try { postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "downloading", message: "拉取中..." }); const models = await fetchOnlineModels(config); postToFrame({ type: "VECTOR_ONLINE_MODELS", models }); postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "success", message: `找到 ${models.length} 个模型` }); } catch (e) { postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "error", message: e.message }); } } async function handleGenerateVectors(vectorCfg) { if (vectorGenerating) return; if (!vectorCfg?.enabled) { postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: -1, total: 0 }); postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: -1, total: 0 }); return; } const { chatId, chat } = getContext(); if (!chatId || !chat?.length) return; if (vectorCfg.engine === "online") { if (!vectorCfg.online?.key || !vectorCfg.online?.model) { postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "error", message: "请配置在线服务 API" }); return; } } if (vectorCfg.engine === "local") { const modelId = vectorCfg.local?.modelId || DEFAULT_LOCAL_MODEL; const status = await checkLocalModelStatus(modelId); if (status.status !== "ready") { postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "downloading", message: "正在加载模型..." }); try { await downloadLocalModel(modelId, (percent) => { postToFrame({ type: "VECTOR_LOCAL_MODEL_PROGRESS", percent }); }); postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "ready", message: "已就绪" }); } catch (e) { xbLog.error(MODULE_ID, "模型加载失败", e); postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "error", message: e.message }); return; } } } vectorGenerating = true; vectorCancelled = false; const fingerprint = getEngineFingerprint(vectorCfg); const isLocal = vectorCfg.engine === "local"; const batchSize = isLocal ? 5 : 20; const concurrency = isLocal ? 1 : 2; await clearAllChunks(chatId); await updateMeta(chatId, { lastChunkFloor: -1, fingerprint }); const allChunks = []; for (let floor = 0; floor < chat.length; floor++) { const chunks = chunkMessage(floor, chat[floor]); allChunks.push(...chunks); } if (allChunks.length > 0) { await saveChunks(chatId, allChunks); } const l1Texts = allChunks.map((c) => c.text); const l1Batches = []; for (let i = 0; i < l1Texts.length; i += batchSize) { l1Batches.push({ phase: "L1", texts: l1Texts.slice(i, i + batchSize), startIdx: i, }); } const store = getSummaryStore(); const events = store?.json?.events || []; await ensureFingerprintMatch(chatId, fingerprint); const existingVectors = await getAllEventVectors(chatId); const existingIds = new Set(existingVectors.map((v) => v.eventId)); const l2Pairs = events .filter((e) => !existingIds.has(e.id)) .map((e) => ({ id: e.id, text: `${e.title || ""} ${e.summary || ""}`.trim() })) .filter((p) => p.text); const l2Batches = []; for (let i = 0; i < l2Pairs.length; i += batchSize) { const batch = l2Pairs.slice(i, i + batchSize); l2Batches.push({ phase: "L2", texts: batch.map((p) => p.text), ids: batch.map((p) => p.id), startIdx: i, }); } const l1Total = allChunks.length; const l2Total = events.length; let l1Completed = 0; let l2Completed = existingIds.size; postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: 0, total: l1Total }); postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: l2Completed, total: l2Total }); const allTasks = [...l1Batches, ...l2Batches]; const l1Vectors = new Array(l1Texts.length); const l2VectorItems = []; let taskIndex = 0; async function worker() { while (taskIndex < allTasks.length) { if (vectorCancelled) break; const i = taskIndex++; if (i >= allTasks.length) break; const task = allTasks[i]; try { const vectors = await embed(task.texts, vectorCfg); if (task.phase === "L1") { for (let j = 0; j < vectors.length; j++) { l1Vectors[task.startIdx + j] = vectors[j]; } l1Completed += task.texts.length; postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: Math.min(l1Completed, l1Total), total: l1Total, }); } else { for (let j = 0; j < vectors.length; j++) { l2VectorItems.push({ eventId: task.ids[j], vector: vectors[j] }); } l2Completed += task.texts.length; postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: Math.min(l2Completed, l2Total), total: l2Total, }); } } catch (e) { xbLog.error(MODULE_ID, `${task.phase} batch 向量化失败`, e); } } } await Promise.all( Array(Math.min(concurrency, allTasks.length)) .fill(null) .map(() => worker()) ); if (allChunks.length > 0 && l1Vectors.filter(Boolean).length > 0) { const chunkVectorItems = allChunks .map((chunk, idx) => (l1Vectors[idx] ? { chunkId: chunk.chunkId, vector: l1Vectors[idx] } : null)) .filter(Boolean); await saveChunkVectors(chatId, chunkVectorItems, fingerprint); await updateMeta(chatId, { lastChunkFloor: chat.length - 1 }); } if (l2VectorItems.length > 0) { await saveEventVectorsToDb(chatId, l2VectorItems, fingerprint); } postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: -1, total: 0 }); postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: -1, total: 0 }); await sendVectorStatsToFrame(); vectorGenerating = false; vectorCancelled = false; xbLog.info(MODULE_ID, `向量生成完成: L1=${l1Vectors.filter(Boolean).length}, L2=${l2VectorItems.length}`); } async function handleClearVectors() { const { chatId } = getContext(); if (!chatId) return; await clearEventVectors(chatId); await clearAllChunks(chatId); await updateMeta(chatId, { lastChunkFloor: -1 }); await sendVectorStatsToFrame(); xbLog.info(MODULE_ID, "向量数据已清除"); } async function maybeAutoBuildChunks() { const cfg = getVectorConfig(); if (!cfg?.enabled) return; const { chat, chatId } = getContext(); if (!chatId || !chat?.length) return; const status = await getChunkBuildStatus(); if (status.pending <= 0) return; if (cfg.engine === "local") { const modelId = cfg.local?.modelId || DEFAULT_LOCAL_MODEL; if (!isLocalModelLoaded(modelId)) return; } xbLog.info(MODULE_ID, `auto L1 chunks: pending=${status.pending}`); try { await buildIncrementalChunks({ vectorConfig: cfg }); } catch (e) { xbLog.error(MODULE_ID, "自动 L1 构建失败", e); } } // ═══════════════════════════════════════════════════════════════════════════ // 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]); window.addEventListener(MESSAGE_EVENT, 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); } } async function sendFrameBaseData(store, totalFloors) { const boundary = await getHideBoundaryFloor(store); const range = calcHideRange(boundary); const hiddenCount = (store?.hideSummarizedHistory && range) ? (range.end + 1) : 0; const lastSummarized = store?.lastSummarizedMesId ?? -1; 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 || [], world: store.json.world || [], 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); // 不需要 await,fire-and-forget sendFrameFullData(store, totalFloors); setSummaryGenerating(summaryGenerating); sendVectorConfigToFrame(); sendVectorStatsToFrame(); const cfg = getVectorConfig(); const modelId = cfg?.local?.modelId || DEFAULT_LOCAL_MODEL; sendLocalModelStatusToFrame(modelId); } // ═══════════════════════════════════════════════════════════════════════════ // Hide/Unhide:向量模式联动("已总结"的定义切换) // - 非向量:boundary = lastSummarizedMesId(LLM总结边界) // - 向量:boundary = meta.lastChunkFloor(已向量化) // ═══════════════════════════════════════════════════════════════════════════ async function getHideBoundaryFloor(store) { const vectorCfg = getVectorConfig(); if (!vectorCfg?.enabled) { return store?.lastSummarizedMesId ?? -1; } const { chatId } = getContext(); if (!chatId) return -1; const meta = await getMeta(chatId); return meta?.lastChunkFloor ?? -1; } async function applyHideState() { const store = getSummaryStore(); if (!store?.hideSummarizedHistory) return; const boundary = await getHideBoundaryFloor(store); if (boundary < 0) return; const range = calcHideRange(boundary); if (!range) return; await executeSlashCommand(`/hide ${range.start}-${range.end}`); } function applyHideStateDebounced() { clearTimeout(hideApplyTimer); hideApplyTimer = setTimeout(() => { applyHideState().catch((e) => xbLog.warn(MODULE_ID, "applyHideState failed", e)); }, HIDE_APPLY_DEBOUNCE_MS); } async function clearHideState() { const store = getSummaryStore(); const boundary = await getHideBoundaryFloor(store); if (boundary < 0) return; await executeSlashCommand(`/unhide 0-${boundary}`); } // ═══════════════════════════════════════════════════════════════════════════ // 自动总结(保持原逻辑;不做 prompt 注入) // ═══════════════════════════════════════════════════════════════════════════ 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) { setSummaryGenerating(true); for (let attempt = 1; attempt <= 3; attempt++) { const result = await runSummaryGeneration(targetMesId, configForRun, { onStatus: (text) => postToFrame({ type: "SUMMARY_STATUS", statusText: text }), onError: (msg) => postToFrame({ type: "SUMMARY_ERROR", message: msg }), onComplete: ({ merged, endMesId }) => { postToFrame({ type: "SUMMARY_FULL_DATA", payload: { keywords: merged.keywords || [], events: merged.events || [], characters: merged.characters || { main: [], relationships: [] }, arcs: merged.arcs || [], world: merged.world || [], lastSummarizedMesId: endMesId, }, }); postToFrame({ type: "SUMMARY_STATUS", statusText: `已更新至 ${endMesId + 1} 楼 · ${merged.events?.length || 0} 个事件`, }); updateFrameStatsAfterSummary(endMesId, merged); }, }); if (result.success) { setSummaryGenerating(false); return; } if (attempt < 3) await sleep(1000); } setSummaryGenerating(false); xbLog.error(MODULE_ID, "自动总结失败(已重试3次)"); await executeSlashCommand("/echo severity=error 剧情总结失败(已自动重试 3 次)。请稍后再试。"); } function updateFrameStatsAfterSummary(endMesId, merged) { const { chat } = getContext(); const totalFloors = Array.isArray(chat) ? chat.length : 0; const store = getSummaryStore(); const range = calcHideRange(endMesId); const hiddenCount = store?.hideSummarizedHistory && range ? range.end + 1 : 0; postToFrame({ type: "SUMMARY_BASE_DATA", stats: { totalFloors, summarizedUpTo: endMesId + 1, eventsCount: merged.events?.length || 0, pendingFloors: totalFloors - endMesId - 1, hiddenCount, }, }); } // ═══════════════════════════════════════════════════════════════════════════ // iframe 消息处理 // ═══════════════════════════════════════════════════════════════════════════ 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(); sendVectorConfigToFrame(); sendVectorStatsToFrame(); const cfg = getVectorConfig(); const modelId = cfg?.local?.modelId || DEFAULT_LOCAL_MODEL; sendLocalModelStatusToFrame(modelId); 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; handleManualGenerate(currentMesId, data.config || {}); break; } case "REQUEST_CANCEL": window.xiaobaixStreamingGeneration?.cancel?.("xb9"); setSummaryGenerating(false); postToFrame({ type: "SUMMARY_STATUS", statusText: "已停止" }); break; // vector UI case "VECTOR_DOWNLOAD_MODEL": handleDownloadLocalModel(data.modelId); break; case "VECTOR_CANCEL_DOWNLOAD": handleCancelDownload(); break; case "VECTOR_DELETE_MODEL": handleDeleteLocalModel(data.modelId); break; case "VECTOR_CHECK_LOCAL_MODEL": sendLocalModelStatusToFrame(data.modelId); break; case "VECTOR_TEST_ONLINE": handleTestOnlineService(data.provider, data.config); break; case "VECTOR_FETCH_MODELS": handleFetchOnlineModels(data.config); break; case "VECTOR_GENERATE": if (data.config) saveVectorConfig(data.config); clearSummaryExtensionPrompt(); // 防残留 handleGenerateVectors(data.config); break; case "VECTOR_CLEAR": clearSummaryExtensionPrompt(); // 防残留 handleClearVectors(); break; case "VECTOR_CANCEL_GENERATE": vectorCancelled = true; break; // summary actions case "REQUEST_CLEAR": { const { chat, chatId } = getContext(); clearSummaryData(chatId); clearSummaryExtensionPrompt(); // 防残留 postToFrame({ type: "SUMMARY_CLEARED", payload: { totalFloors: Array.isArray(chat) ? chat.length : 0 }, }); 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(); break; } case "TOGGLE_HIDE_SUMMARIZED": { const store = getSummaryStore(); if (!store) break; store.hideSummarizedHistory = !!data.enabled; saveSummaryStore(); (async () => { if (data.enabled) { await applyHideState(); } else { await clearHideState(); } })(); 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(); (async () => { // 先清掉原隐藏,再按新 keepCount 重算隐藏 if (store.hideSummarizedHistory) { await clearHideState(); await applyHideState(); } const { chat } = getContext(); await sendFrameBaseData(store, Array.isArray(chat) ? chat.length : 0); })(); break; } case "SAVE_PANEL_CONFIG": if (data.config) { CommonSettingStorage.set(SUMMARY_CONFIG_KEY, data.config); clearSummaryExtensionPrompt(); // 配置变化立即清除注入,避免残留 xbLog.info(MODULE_ID, "面板配置已保存到服务器"); } break; case "REQUEST_PANEL_CONFIG": sendSavedConfigToFrame(); break; } } // ═══════════════════════════════════════════════════════════════════════════ // 手动总结 // ═══════════════════════════════════════════════════════════════════════════ async function handleManualGenerate(mesId, config) { if (isSummaryGenerating()) { postToFrame({ type: "SUMMARY_STATUS", statusText: "上一轮总结仍在进行中..." }); return; } setSummaryGenerating(true); xbLog.info(MODULE_ID, `开始手动总结 mesId=${mesId}`); await runSummaryGeneration(mesId, config, { onStatus: (text) => postToFrame({ type: "SUMMARY_STATUS", statusText: text }), onError: (msg) => postToFrame({ type: "SUMMARY_ERROR", message: msg }), onComplete: ({ merged, endMesId }) => { postToFrame({ type: "SUMMARY_FULL_DATA", payload: { keywords: merged.keywords || [], events: merged.events || [], characters: merged.characters || { main: [], relationships: [] }, arcs: merged.arcs || [], world: merged.world || [], lastSummarizedMesId: endMesId, }, }); postToFrame({ type: "SUMMARY_STATUS", statusText: `已更新至 ${endMesId + 1} 楼 · ${merged.events?.length || 0} 个事件`, }); // 隐藏逻辑:统一走 boundary(vector on/off 自动切换定义) applyHideStateDebounced(); updateFrameStatsAfterSummary(endMesId, merged); }, }); setSummaryGenerating(false); } // ═══════════════════════════════════════════════════════════════════════════ // 事件处理器(不做 prompt 注入) // ═══════════════════════════════════════════════════════════════════════════ async function handleChatChanged() { const { chat } = getContext(); const newLength = Array.isArray(chat) ? chat.length : 0; await rollbackSummaryIfNeeded(); initButtonsForAll(); const store = getSummaryStore(); applyHideStateDebounced(); if (frameReady) { await sendFrameBaseData(store, newLength); sendFrameFullData(store, newLength); } } async function handleMessageDeleted() { const { chat, chatId } = getContext(); const newLength = chat?.length || 0; await rollbackSummaryIfNeeded(); await syncOnMessageDeleted(chatId, newLength); } async function handleMessageSwiped() { const { chat, chatId } = getContext(); const lastFloor = (chat?.length || 1) - 1; await syncOnMessageSwiped(chatId, lastFloor); initButtonsForAll(); } async function handleMessageReceived() { const { chat, chatId } = getContext(); const lastFloor = (chat?.length || 1) - 1; const message = chat?.[lastFloor]; const vectorConfig = getVectorConfig(); initButtonsForAll(); await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig); await maybeAutoBuildChunks(); // 向量模式下,lastChunkFloor 会持续推进;如果勾选隐藏,自动扩展隐藏范围 applyHideStateDebounced(); setTimeout(() => maybeAutoRunSummary("after_ai"), 1000); } function handleMessageSent() { initButtonsForAll(); setTimeout(() => maybeAutoRunSummary("before_user"), 1000); } async function handleMessageUpdated() { await rollbackSummaryIfNeeded(); initButtonsForAll(); } function handleMessageRendered(data) { const mesId = data?.element ? $(data.element).attr("mesid") : data?.messageId; if (mesId != null) addSummaryBtnToMessage(mesId); else initButtonsForAll(); } // ═══════════════════════════════════════════════════════════════════════════ // ✅ 唯一注入入口:GENERATION_STARTED // ═══════════════════════════════════════════════════════════════════════════ async function handleGenerationStarted(type, _params, isDryRun) { if (isDryRun) return; if (!getSettings().storySummary?.enabled) return; const excludeLastAi = type === "swipe" || type === "regenerate"; const vectorCfg = getVectorConfig(); // 向量模式:召回 + 预算装配 if (vectorCfg?.enabled) { await recallAndInjectPrompt(excludeLastAi, { postToFrame, echo: executeSlashCommand, // recall failure notification }); return; } // 非向量模式:全量总结注入(不召回) await injectNonVectorPrompt(postToFrame); } // ═══════════════════════════════════════════════════════════════════════════ // 事件注册 // ═══════════════════════════════════════════════════════════════════════════ 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(); eventSource.on(event_types.CHAT_CHANGED, () => setTimeout(handleChatChanged, 80)); eventSource.on(event_types.MESSAGE_DELETED, () => setTimeout(handleMessageDeleted, 50)); eventSource.on(event_types.MESSAGE_RECEIVED, () => setTimeout(handleMessageReceived, 150)); eventSource.on(event_types.MESSAGE_SENT, () => setTimeout(handleMessageSent, 150)); eventSource.on(event_types.MESSAGE_SWIPED, () => setTimeout(handleMessageSwiped, 100)); eventSource.on(event_types.MESSAGE_UPDATED, () => setTimeout(handleMessageUpdated, 100)); eventSource.on(event_types.MESSAGE_EDITED, () => setTimeout(handleMessageUpdated, 100)); eventSource.on(event_types.USER_MESSAGE_RENDERED, (data) => setTimeout(() => handleMessageRendered(data), 50)); eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => setTimeout(() => handleMessageRendered(data), 50)); // ✅ 只在生成开始时注入 eventSource.on(event_types.GENERATION_STARTED, handleGenerationStarted); } function unregisterEvents() { xbLog.info(MODULE_ID, "模块清理"); 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(); // 开启时清一次,防止旧注入残留 clearSummaryExtensionPrompt(); } else { unregisterEvents(); } }); // ═══════════════════════════════════════════════════════════════════════════ // 初始化 // ═══════════════════════════════════════════════════════════════════════════ jQuery(() => { if (!getSettings().storySummary?.enabled) { clearSummaryExtensionPrompt(); return; } registerEvents(); // 初始化也清一次,保证干净(注入只在生成开始发生) clearSummaryExtensionPrompt(); });