Files
LittleWhiteBox/modules/story-summary/story-summary.js

1176 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════════════════════
// 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 = $(`
<div id="xiaobaix-story-summary-overlay" style="
position: fixed !important; inset: 0 !important;
width: 100vw !important; height: ${overlayHeight} !important;
z-index: 99999 !important; display: none; overflow: hidden !important;
">
<div class="xb-ss-backdrop" style="
position: absolute !important; inset: 0 !important;
background: rgba(0,0,0,.55) !important;
backdrop-filter: blur(4px) !important;
"></div>
<div class="xb-ss-frame-wrap" style="
position: absolute !important; inset: 12px !important; z-index: 1 !important;
">
<iframe id="xiaobaix-story-summary-iframe" class="xiaobaix-iframe"
src="${iframePath}"
style="width:100% !important; height:100% !important; border:none !important;
border-radius:12px !important; box-shadow:0 0 30px rgba(0,0,0,.4) !important;
background:#fafafa !important;">
</iframe>
</div>
<button class="xb-ss-close-btn" style="
position: absolute !important; top: 20px !important; right: 20px !important;
z-index: 2 !important; width: 36px !important; height: 36px !important;
border-radius: 50% !important; border: none !important;
background: rgba(0,0,0,.6) !important; color: #fff !important;
font-size: 20px !important; cursor: pointer !important;
display: flex !important; align-items: center !important;
justify-content: center !important;
">✕</button>
</div>
`);
$overlay.on("click", ".xb-ss-backdrop, .xb-ss-close-btn", hideOverlay);
document.body.appendChild($overlay[0]);
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 = '<i class="fa-solid fa-chart-line"></i>';
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (!getSettings().storySummary?.enabled) return;
currentMesId = Number(mesId);
openPanelForMessage(currentMesId);
});
return btn;
}
function addSummaryBtnToMessage(mesId) {
if (!getSettings().storySummary?.enabled) return;
const msg = document.querySelector(`#chat .mes[mesid="${mesId}"]`);
if (!msg || msg.querySelector(".xiaobaix-story-summary-btn")) return;
const btn = createSummaryBtn(mesId);
if (window.registerButtonToSubContainer?.(mesId, btn)) return;
msg.querySelector(".flex-container.flex1.alignitemscenter")?.appendChild(btn);
}
function initButtonsForAll() {
if (!getSettings().storySummary?.enabled) return;
$("#chat .mes").each((_, el) => {
const mesId = el.getAttribute("mesid");
if (mesId != null) addSummaryBtnToMessage(mesId);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 面板数据发送
// ═══════════════════════════════════════════════════════════════════════════
async function sendSavedConfigToFrame() {
try {
const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
if (savedConfig) {
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
xbLog.info(MODULE_ID, "已从服务器加载面板配置");
}
} catch (e) {
xbLog.warn(MODULE_ID, "加载面板配置失败", e);
}
}
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); // 不需要 awaitfire-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 = lastSummarizedMesIdLLM总结边界
// - 向量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} 个事件`,
});
// 隐藏逻辑:统一走 boundaryvector 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();
});