上傳檔案到「modules/story-summary」

This commit is contained in:
X
2026-03-31 14:24:50 +00:00
parent 9797855cae
commit bc9fd0af38
5 changed files with 1088 additions and 34 deletions

View File

@@ -89,7 +89,7 @@ import {
} from "./vector/storage/state-store.js";
// vector io
import { exportVectors, importVectors } from "./vector/storage/vector-io.js";
import { exportVectors, importVectors, backupToServer, restoreFromServer, fetchManifest, deleteServerBackup, isDeleteUnsupportedError, getBackupFilename } from "./vector/storage/vector-io.js";
import { invalidateLexicalIndex, warmupIndex, addDocumentsForFloor, removeDocumentsByFloor, addEventDocuments } from "./vector/retrieval/lexical-index.js";
@@ -182,6 +182,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 向量提醒节流
let lastVectorWarningAt = 0;
const VECTOR_WARNING_COOLDOWN_MS = 120000; // 2分钟内不重复提醒
let backupDeleteSupported = true;
let backupDeleteUnsupportedReason = '';
const EXT_PROMPT_KEY = "LittleWhiteBox_StorySummary";
const MIN_INJECTION_DEPTH = 2;
@@ -942,10 +944,8 @@ function initButtonsForAll() {
async function sendSavedConfigToFrame() {
try {
const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
if (savedConfig) {
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
}
const savedConfig = getSummaryPanelConfig();
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
} catch (e) {
xbLog.warn(MODULE_ID, "加载面板配置失败", e);
}
@@ -1029,6 +1029,270 @@ function buildFramePayload(store) {
};
}
async function copyTextToClipboard(text) {
const value = String(text ?? "");
if (!value) {
throw new Error("没有可复制的内容");
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const ta = document.createElement("textarea");
ta.value = value;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand?.("copy");
ta.remove();
if (!ok) {
throw new Error("浏览器不支持自动复制");
}
}
function stripFloorMarker(summary) {
return String(summary || "")
.replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "")
.trim();
}
function normalizeInternalFact(item) {
const fact = item && typeof item === "object" ? item : {};
const base = {
id: String(fact?.id || "").trim(),
s: String(fact?.s ?? "").trim(),
p: String(fact?.p ?? "").trim(),
o: String(fact?.o ?? "").trim(),
};
const stateValue = fact?._isState ?? fact?.isState;
if (stateValue != null) {
base._isState = !!stateValue;
}
const trendValue = String(fact?.trend ?? "").trim();
if (trendValue) {
base.trend = trendValue;
}
return base;
}
function normalizePortableFact(item) {
const fact = item && typeof item === "object" ? item : {};
const base = {
id: String(fact?.id || "").trim(),
s: String(fact?.人物名字 ?? "").trim(),
p: String(fact?.种类 ?? "").trim(),
o: String(fact?.描述 ?? "").trim(),
};
const stateValue = fact?._isState ?? fact?.isState ?? fact?.核心事实;
if (stateValue != null) {
base._isState = !!stateValue;
}
const trendValue = String(fact?.trend ?? fact?.趋势 ?? "").trim();
if (trendValue) {
base.trend = trendValue;
}
return base;
}
function serializePortableFact(fact) {
const out = {
人物名字: String(fact?.s || "").trim(),
种类: String(fact?.p || "").trim(),
描述: String(fact?.o || "").trim(),
};
if (fact?._isState != null) {
out.核心事实 = !!fact._isState;
}
if (fact?.trend) {
out.趋势 = String(fact.trend).trim();
}
return out;
}
function cloneSummaryJsonForPortability(json) {
const src = json && typeof json === "object" ? json : {};
const characters = src.characters && typeof src.characters === "object" ? src.characters : {};
return {
keywords: Array.isArray(src.keywords)
? src.keywords.map((item) => ({
text: String(item?.text || "").trim(),
weight: String(item?.weight || "").trim(),
})).filter((item) => item.text)
: [],
events: Array.isArray(src.events)
? src.events.map((item) => ({
id: String(item?.id || "").trim(),
title: String(item?.title || "").trim(),
timeLabel: String(item?.timeLabel || "").trim(),
summary: stripFloorMarker(item?.summary),
participants: Array.isArray(item?.participants)
? item.participants.map((name) => String(name || "").trim()).filter(Boolean)
: [],
type: String(item?.type || "").trim(),
weight: String(item?.weight || "").trim(),
causedBy: Array.isArray(item?.causedBy)
? item.causedBy.map((id) => String(id || "").trim()).filter(Boolean)
: [],
})).filter((item) => item.id || item.title || item.summary)
: [],
characters: {
main: Array.isArray(characters.main)
? characters.main
.map((item) => typeof item === "string"
? { name: String(item).trim() }
: { name: String(item?.name || "").trim() })
.filter((item) => item.name)
: (Array.isArray(characters)
? characters
.map((item) => typeof item === "string"
? { name: String(item).trim() }
: { name: String(item?.name || "").trim() })
.filter((item) => item.name)
: []),
},
arcs: Array.isArray(src.arcs)
? src.arcs.map((item) => ({
name: String(item?.name || "").trim(),
trajectory: String(item?.trajectory || "").trim(),
progress: Number.isFinite(Number(item?.progress)) ? Number(item.progress) : 0,
moments: Array.isArray(item?.moments)
? item.moments
.map((moment) => typeof moment === "string"
? { text: String(moment).trim() }
: { text: String(moment?.text || "").trim() })
.filter((moment) => moment.text)
: [],
})).filter((item) => item.name)
: [],
facts: Array.isArray(src.facts)
? src.facts.map(normalizeInternalFact).filter((item) => item.s && item.p && item.o)
: [],
};
}
function extractSummaryImportJson(raw) {
if (!raw || typeof raw !== "object") {
throw new Error("文件内容不是有效 JSON 对象");
}
const candidate =
(raw.type === "LittleWhiteBoxStorySummaryMemory" && raw.data && typeof raw.data === "object" ? raw.data : null) ||
(raw.storySummary?.json && typeof raw.storySummary.json === "object" ? raw.storySummary.json : null) ||
(raw.json && typeof raw.json === "object" ? raw.json : null) ||
raw;
const hasSummaryShape =
Array.isArray(candidate.keywords) ||
Array.isArray(candidate.events) ||
Array.isArray(candidate.arcs) ||
Array.isArray(candidate.facts) ||
(candidate.characters && typeof candidate.characters === "object");
if (!hasSummaryShape) {
throw new Error("未识别到可导入的总结数据");
}
const json = cloneSummaryJsonForPortability(candidate);
json.facts = Array.isArray(candidate.facts)
? candidate.facts.map(normalizePortableFact).filter((item) => item.s && item.p && item.o)
: [];
return json;
}
function buildSummaryExportPackage(store) {
const json = cloneSummaryJsonForPortability(store?.json || {});
const data = {
...json,
facts: json.facts.map(serializePortableFact),
};
return {
type: "LittleWhiteBoxStorySummaryMemory",
version: 1,
exportedAt: new Date().toISOString(),
data,
counts: {
keywords: json.keywords.length,
events: json.events.length,
characters: json.characters.main.length,
arcs: json.arcs.length,
facts: json.facts.length,
},
};
}
async function importSummaryMemoryPackage(rawText) {
if (!String(rawText || "").trim()) {
throw new Error("记忆包内容为空");
}
let parsed;
try {
parsed = JSON.parse(String(rawText));
} catch {
throw new Error("JSON 解析失败");
}
const importedJson = extractSummaryImportJson(parsed);
const { chatId, chat } = getContext();
if (!chatId) {
throw new Error("当前没有打开聊天");
}
await clearAllAtomsAndVectors(chatId);
await clearAllChunks(chatId);
await clearEventVectors(chatId);
await clearStateVectors(chatId);
await updateMeta(chatId, { lastChunkFloor: -1, fingerprint: null });
invalidateLexicalIndex();
const store = getSummaryStore();
if (!store) {
throw new Error("无法读取当前聊天的总结存储");
}
store.json = importedJson;
store.lastSummarizedMesId = -1;
store.summaryHistory = [];
store.updatedAt = Date.now();
saveSummaryStore();
_lastBuiltPromptText = "";
refreshEntityLexiconAndWarmup();
scheduleLexicalWarmup();
await clearHideState();
const totalFloors = Array.isArray(chat) ? chat.length : 0;
await sendFrameBaseData(store, totalFloors);
sendFrameFullData(store, totalFloors);
await sendAnchorStatsToFrame();
await sendVectorStatsToFrame();
return {
counts: {
keywords: importedJson.keywords.length,
events: importedJson.events.length,
characters: importedJson.characters.main.length,
arcs: importedJson.arcs.length,
facts: importedJson.facts.length,
},
};
}
// Compatibility export for ena-planner.
// Returns a compact plain-text snapshot of story-summary memory.
export function getStorySummaryForEna() {
@@ -1424,6 +1688,43 @@ async function handleFrameMessage(event) {
})();
break;
case "SUMMARY_COPY":
(async () => {
try {
const store = getSummaryStore();
const payload = buildSummaryExportPackage(store);
await copyTextToClipboard(JSON.stringify(payload, null, 2));
postToFrame({
type: "SUMMARY_COPY_RESULT",
success: true,
events: payload.counts.events,
facts: payload.counts.facts,
});
} catch (e) {
postToFrame({ type: "SUMMARY_COPY_RESULT", success: false, error: e.message });
}
})();
break;
case "SUMMARY_IMPORT_TEXT":
if (guard.isAnyRunning('summary', 'vector', 'anchor')) {
postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: "请等待当前总结/向量任务结束" });
break;
}
(async () => {
try {
const result = await importSummaryMemoryPackage(data.text || "");
postToFrame({
type: "SUMMARY_IMPORT_RESULT",
success: true,
counts: result.counts,
});
} catch (e) {
postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: e.message });
}
})();
break;
case "VECTOR_IMPORT_PICK":
// 在 parent 创建 file picker避免 iframe 传大文件
(async () => {
@@ -1459,6 +1760,56 @@ async function handleFrameMessage(event) {
input.click();
})();
break;
case "VECTOR_BACKUP_SERVER":
(async () => {
try {
const result = await backupToServer((status) => {
postToFrame({ type: "VECTOR_IO_STATUS", status });
});
postToFrame({
type: "VECTOR_BACKUP_RESULT",
success: true,
size: result.size,
chunkCount: result.chunkCount,
eventCount: result.eventCount,
});
} catch (e) {
postToFrame({ type: "VECTOR_BACKUP_RESULT", success: false, error: e.message });
}
})();
break;
case "VECTOR_RESTORE_SERVER":
(async () => {
try {
const result = await restoreFromServer((status) => {
postToFrame({ type: "VECTOR_IO_STATUS", status });
});
postToFrame({
type: "VECTOR_RESTORE_RESULT",
success: true,
chunkCount: result.chunkCount,
eventCount: result.eventCount,
warnings: result.warnings,
fingerprintMismatch: result.fingerprintMismatch,
});
await sendVectorStatsToFrame();
} catch (e) {
postToFrame({ type: "VECTOR_RESTORE_RESULT", success: false, error: e.message });
}
})();
break;
case "VECTOR_LIST_BACKUPS":
(async () => {
try {
const files = await fetchManifest();
showBackupManagerModal(files);
} catch (e) {
showBackupManagerModal([]);
}
})();
break;
case "REQUEST_VECTOR_STATS":
sendVectorStatsToFrame();
@@ -1600,6 +1951,7 @@ async function handleManualGenerate(mesId, config) {
async function handleChatChanged() {
if (!events) return;
_lastBuiltPromptText = ""; // ← 加这一行,切聊天时清掉旧 summary
const { chat } = getContext();
activeChatId = getContext().chatId || null;
const newLength = Array.isArray(chat) ? chat.length : 0;
@@ -1895,6 +2247,10 @@ function registerEvents() {
events.on(event_types.GENERATION_STARTED, handleGenerationStarted);
events.on(event_types.GENERATION_STOPPED, clearExtensionPrompt);
events.on(event_types.GENERATION_ENDED, clearExtensionPrompt);
// 聊天删除时清理对应的服务器向量备份
events.on(event_types.CHAT_DELETED, handleChatDeleted);
events.on(event_types.GROUP_CHAT_DELETED, handleChatDeleted);
}
function unregisterEvents() {
@@ -1915,6 +2271,169 @@ function unregisterEvents() {
document.removeEventListener("keydown", onSendKeydown, true);
}
// ═══════════════════════════════════════════════════════════════════════════
// 聊天删除时自动清理服务器向量备份
// ═══════════════════════════════════════════════════════════════════════════
async function handleChatDeleted(chatId) {
try {
const filename = getBackupFilename(chatId);
await deleteServerBackup(filename, null);
xbLog.info(MODULE_ID, `聊天删除,已清理服务器备份: ${filename}`);
} catch (_) {
// 文件不存在或宿主不支持删除,静默处理
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 备份管理 Modal渲染在父窗口确保层级在 settings modal 之上)
// ═══════════════════════════════════════════════════════════════════════════
function showBackupManagerModal(initialFiles) {
document.getElementById('lwb-backup-manager-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'lwb-backup-manager-modal';
overlay.style.cssText = [
'position:fixed', 'inset:0', 'background:rgba(0,0,0,.55)',
'z-index:100000', 'display:flex', 'align-items:center', 'justify-content:center',
].join(';');
const box = document.createElement('div');
box.style.cssText = [
'background:#fff', 'color:#222', 'border-radius:8px',
'width:min(520px,92vw)', 'padding:18px',
'max-height:80vh', 'display:flex', 'flex-direction:column',
'box-shadow:0 8px 32px rgba(0,0,0,.35)', 'font-size:14px',
].join(';');
// Header
const header = document.createElement('div');
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px';
const title = document.createElement('span');
title.style.cssText = 'font-weight:700;font-size:15px';
title.textContent = '服务器向量备份';
const badge = document.createElement('span');
badge.id = 'lwb-backup-badge';
badge.style.cssText = 'opacity:0.5;font-size:0.85em;margin-left:4px';
title.appendChild(badge);
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:6px';
const btnRefresh = document.createElement('button');
btnRefresh.className = 'btn btn-sm';
btnRefresh.textContent = '刷新';
const btnClose = document.createElement('button');
btnClose.className = 'btn btn-sm';
btnClose.textContent = '✕';
btnClose.onclick = () => overlay.remove();
btnRow.append(btnRefresh, btnClose);
header.append(title, btnRow);
// List area
const listEl = document.createElement('div');
listEl.id = 'lwb-backup-list';
listEl.style.cssText = 'overflow-y:auto;flex:1;min-height:60px';
// Status bar
const statusEl = document.createElement('div');
statusEl.id = 'lwb-backup-status';
statusEl.style.cssText = 'margin-top:8px;font-size:0.82em;color:#666;min-height:1em';
box.append(header, listEl, statusEl);
overlay.appendChild(box);
document.body.appendChild(overlay);
// Close on backdrop click
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
function setStatus(text, isError) {
statusEl.textContent = text;
statusEl.style.color = isError ? '#c00' : '#666';
}
function renderList(files) {
badge.textContent = `(${files.length})`;
if (!files.length) {
listEl.innerHTML = '<div style="padding:12px;opacity:0.5;text-align:center">暂无备份记录</div>';
return;
}
const sorted = [...files].sort((a, b) => new Date(b.backupTime) - new Date(a.backupTime));
listEl.replaceChildren();
sorted.forEach(f => {
const row = document.createElement('div');
row.style.cssText = [
'display:flex', 'gap:8px', 'align-items:center', 'padding:6px 2px',
'border-bottom:1px solid #e8e8e8', 'font-size:0.82em',
].join(';');
const label = document.createElement('span');
label.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#333';
label.title = f.chatId || f.filename;
label.textContent = f.chatId || f.filename;
const size = document.createElement('span');
size.style.cssText = 'white-space:nowrap;color:#555';
size.textContent = f.size ? (f.size / 1024 / 1024).toFixed(2) + 'MB' : '?';
const time = document.createElement('span');
time.style.cssText = 'white-space:nowrap;color:#888';
time.textContent = f.backupTime ? new Date(f.backupTime).toLocaleString() : '?';
const btnDel = document.createElement('button');
btnDel.className = 'btn btn-sm';
btnDel.style.cssText = 'padding:1px 10px;flex-shrink:0;color:#c00;border-color:#c00';
btnDel.textContent = '删';
btnDel.onclick = async () => {
if (!confirm(`确认删除此备份?\n${f.filename}`)) return;
setStatus('删除中...');
btnDel.disabled = true;
try {
await deleteServerBackup(f.filename, f.serverPath);
setStatus('已删除');
const updated = await fetchManifest();
renderList(updated);
} catch (e) {
if (isDeleteUnsupportedError(e)) {
backupDeleteSupported = false;
backupDeleteUnsupportedReason = e.message || '宿主不支持删除接口';
setStatus('⚠️ 只读模式:' + backupDeleteUnsupportedReason, true);
// 禁用所有删除按钮
listEl.querySelectorAll('button').forEach(b => { b.disabled = true; });
} else {
setStatus('删除失败: ' + (e.message || '未知'), true);
btnDel.disabled = false;
}
}
};
row.append(label, size, time, btnDel);
listEl.appendChild(row);
});
if (!backupDeleteSupported) {
setStatus('⚠️ 只读模式:' + backupDeleteUnsupportedReason, true);
listEl.querySelectorAll('button').forEach(b => { b.disabled = true; });
}
}
btnRefresh.onclick = async () => {
setStatus('加载中...');
try {
const files = await fetchManifest();
renderList(files);
setStatus('');
} catch (e) {
setStatus('加载失败: ' + e.message, true);
}
};
renderList(initialFiles);
}
// ═══════════════════════════════════════════════════════════════════════════
// Toggle 监听
// ═══════════════════════════════════════════════════════════════════════════