Files
LittleWhiteBox/modules/story-summary/story-summary.js
2026-01-26 01:16:35 +08:00

1196 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 - 主入口
// UI 交互、事件监听、iframe 通讯
// ═══════════════════════════════════════════════════════════════════════════
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";
// 拆分模块
import { getSettings, getSummaryPanelConfig, getVectorConfig, saveVectorConfig } from "./data/config.js";
import {
getSummaryStore,
saveSummaryStore,
calcHideRange,
rollbackSummaryIfNeeded,
clearSummaryData,
} from "./data/store.js";
import {
recallAndInjectPrompt,
updateSummaryExtensionPrompt,
clearSummaryExtensionPrompt,
} from "./generate/prompt.js";
import { runSummaryGeneration } from "./generate/generator.js";
// 向量服务
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;
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
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 = [];
}
// ═══════════════════════════════════════════════════════════════════════════
// 向量功能
// ═══════════════════════════════════════════════════════════════════════════
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 autoVectorizeNewEvents(newEventIds) {
const cfg = getVectorConfig();
if (!cfg?.enabled || !newEventIds?.length) return;
await sleep(3000);
const { chatId } = getContext();
if (!chatId) return;
const store = getSummaryStore();
const events = store?.json?.events || [];
const fingerprint = getEngineFingerprint(cfg);
const meta = await getMeta(chatId);
if (meta.fingerprint && meta.fingerprint !== fingerprint) {
xbLog.warn(MODULE_ID, '引擎不匹配,跳过自动向量化');
return;
}
if (cfg.engine === 'local') {
const modelId = cfg.local?.modelId || DEFAULT_LOCAL_MODEL;
if (!isLocalModelLoaded(modelId)) {
xbLog.warn(MODULE_ID, '本地模型未加载,跳过自动向量化');
return;
}
}
const toVectorize = newEventIds.filter(id => events.some(e => e.id === id));
if (toVectorize.length === 0) return;
const texts = toVectorize.map(id => {
const event = events.find(e => e.id === id);
return event ? `${event.title || ''} ${event.summary || ''}`.trim() : '';
}).filter(t => t);
if (!texts.length) return;
try {
const vectors = await embed(texts, cfg);
const newVectorItems = [];
for (let i = 0; i < toVectorize.length && i < vectors.length; i++) {
newVectorItems.push({
eventId: toVectorize[i],
vector: vectors[i],
});
}
if (newVectorItems.length > 0) {
await saveEventVectorsToDb(chatId, newVectorItems, fingerprint);
}
xbLog.info(MODULE_ID, `自动向量化 ${toVectorize.length} 个新事件`);
} catch (e) {
xbLog.error(MODULE_ID, '自动向量化失败', 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);
}
}
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 || [],
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);
sendFrameFullData(store, totalFloors);
setSummaryGenerating(summaryGenerating);
sendVectorConfigToFrame();
sendVectorStatsToFrame();
const cfg = getVectorConfig();
const modelId = cfg?.local?.modelId || DEFAULT_LOCAL_MODEL;
sendLocalModelStatusToFrame(modelId);
}
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 || [],
world: store.json.world || [],
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,
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 自动触发总结
// ═══════════════════════════════════════════════════════════════════════════
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 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);
}
}
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, newEventIds }) => {
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);
updateSummaryExtensionPrompt();
autoVectorizeNewEvents(newEventIds);
},
});
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;
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);
handleGenerateVectors(data.config);
break;
case "VECTOR_CLEAR":
handleClearVectors();
break;
case "VECTOR_CANCEL_GENERATE":
vectorCancelled = true;
break;
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();
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;
}
}
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, newEventIds }) => {
const store = getSummaryStore();
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} 个事件`,
});
// 处理隐藏逻辑
const lastSummarized = store?.lastSummarizedMesId ?? -1;
if (store?.hideSummarizedHistory && lastSummarized >= 0) {
const range = calcHideRange(lastSummarized);
if (range) {
executeSlashCommand(`/hide ${range.start}-${range.end}`);
}
}
updateFrameStatsAfterSummary(endMesId, merged);
updateSummaryExtensionPrompt();
autoVectorizeNewEvents(newEventIds);
},
});
setSummaryGenerating(false);
}
// ═══════════════════════════════════════════════════════════════════════════
// 事件处理器
// ═══════════════════════════════════════════════════════════════════════════
async function handleChatChanged() {
const { chat } = getContext();
const newLength = Array.isArray(chat) ? chat.length : 0;
await 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);
notifyFrameAfterRollback(store);
}
}
async function handleMessageDeleted() {
const { chat, chatId } = getContext();
const newLength = chat?.length || 0;
await rollbackSummaryIfNeeded();
updateSummaryExtensionPrompt();
// L1 同步
await syncOnMessageDeleted(chatId, newLength);
}
async function handleMessageSwiped() {
const { chat, chatId } = getContext();
const lastFloor = (chat?.length || 1) - 1;
// L1 同步
await syncOnMessageSwiped(chatId, lastFloor);
updateSummaryExtensionPrompt();
initButtonsForAll();
}
async function handleMessageReceived() {
const { chat, chatId } = getContext();
const lastFloor = (chat?.length || 1) - 1;
const message = chat?.[lastFloor];
const vectorConfig = getVectorConfig();
updateSummaryExtensionPrompt();
initButtonsForAll();
// L1 同步
await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig);
await maybeAutoBuildChunks();
setTimeout(() => maybeAutoRunSummary('after_ai'), 1000);
}
function handleMessageSent() {
updateSummaryExtensionPrompt();
initButtonsForAll();
setTimeout(() => maybeAutoRunSummary('before_user'), 1000);
}
async function handleMessageUpdated() {
await rollbackSummaryIfNeeded();
updateSummaryExtensionPrompt();
initButtonsForAll();
}
function handleMessageRendered(data) {
const mesId = data?.element ? $(data.element).attr("mesid") : data?.messageId;
if (mesId != null) {
addSummaryBtnToMessage(mesId);
} else {
initButtonsForAll();
}
}
async function handleGenerationStarted(type, _params, isDryRun) {
if (isDryRun) return;
const excludeLastAi = type === 'swipe' || type === 'regenerate';
await recallAndInjectPrompt(excludeLastAi, 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();
updateSummaryExtensionPrompt();
} else {
unregisterEvents();
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════════════════════
jQuery(() => {
if (!getSettings().storySummary?.enabled) {
clearSummaryExtensionPrompt();
return;
}
registerEvents();
updateSummaryExtensionPrompt();
});