纳入小白板内容+世界书读取逻辑修正 (#23) (#25)

* Strip everything before and including </think> (handles unclosed think blocks)

* Log 样式优化

* Log样式优化

* 小白板内容曝露给ena-planner

* 小白板内容曝露给ena-planner

* 修正世界书宏读取问题

* 修正summary触发绿灯的问题

* 向量存储到ST端

* 向量存储到ST端

* 向量到ST服务器

* 向量存储到ST端

* backup file名称修正

* 存取向量逻辑修正

* 切聊天时清掉旧 summary

* 新增向量备份管理 UI(清单 + Modal)

- vector-io.js:新增 fetchManifest / upsertManifestEntry / deleteServerBackup 等清单管理函数;backupToServer 成功后自动写入 LWB_BackupManifest.json
- story-summary.html:在服务器 IO 区域新增「管理」按钮及独立 Modal 弹窗
- story-summary-ui.js:新增备份列表渲染、删除确认、只读模式降级逻辑
- story-summary.js:新增 VECTOR_LIST_BACKUPS / VECTOR_DELETE_BACKUP 消息处理



* 备份管理 Modal 移至父窗口,修复层级与配色问题

- Modal 从 iframe 移到父窗口 DOM(z-index:100000),不再被 settings modal 遮挡
- 改为白底深色文字,配色清晰可读
- 删除逻辑直接在父窗口调用,无需跨帧消息
- 简化 story-summary-ui.js,移除 modal 相关代码



* 删除聊天时自动清理服务器向量备份

- vector-io.js:导出 getBackupFilename
- story-summary.js:监听 CHAT_DELETED / GROUP_CHAT_DELETED,静默删除对应 zip 和清单条目



* 修复 serverPath 含前导斜杠导致删除失败的问题

buildSafeServerPath 比较前 strip 前导 /,upsertManifestEntry 写入前同样 normalize,
确保清单和校验逻辑使用统一格式



* normalizeManifestEntry 读取时同步 strip serverPath 前导斜杠

补全斜杠 normalize 的覆盖点:写入(upsertManifestEntry)、校验(buildSafeServerPath)、
读取(normalizeManifestEntry)三处统一,旧清单条目自动修正



* 重要NPC生成路径:拆分添加按钮 + 完整角色档案模板

- 陌路人卡片"添加"按钮拆为"重要"(importantNpc)和"背景板"(npc)两个
- 新增 importantNpc 生成路径,传递 npcType 贯穿 genAddCt → CHECK_STRANGER_WORLDBOOK_RESULT → GENERATE_NPC_RESULT
- 新增 importantNpc JSON 模板:白描外貌、世界观适配、性格调色盘+衍生、台词示例、结构化二次解释
- 新增 importantNpc UAUA 提示词:内嵌白描规则+正反示范、调色盘衍生写法指导



* 高级设置模板编辑器加注授权声明



* 授权声明仅在重要NPC生成模板下显示



---------

Co-authored-by: Hao19911125 <99091644+Hao19911125@users.noreply.github.com>
Co-authored-by: LittleWhiteBox Dev <dev@littlewhitebox.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
RT15548
2026-03-19 00:50:14 +08:00
committed by GitHub
parent 80f7e37843
commit 11e48f8dc5
8 changed files with 861 additions and 7 deletions

View File

@@ -424,6 +424,19 @@
$('vector-io-status').textContent = '导入中...';
postMsg('VECTOR_IMPORT_PICK');
};
$('btn-backup-server').onclick = () => {
$('btn-backup-server').disabled = true;
$('server-io-status').textContent = '备份中...';
postMsg('VECTOR_BACKUP_SERVER');
};
$('btn-restore-server').onclick = () => {
$('btn-restore-server').disabled = true;
$('server-io-status').textContent = '恢复中...';
postMsg('VECTOR_RESTORE_SERVER');
};
$('btn-manage-backups').onclick = () => postMsg('VECTOR_LIST_BACKUPS');
initAnchorUI();
postMsg('REQUEST_ANCHOR_STATS');
@@ -1500,6 +1513,28 @@
$('vector-io-status').textContent = '导入失败: ' + (d.error || '未知错误');
}
break;
case 'VECTOR_BACKUP_RESULT':
$('btn-backup-server').disabled = false;
if (d.success) {
$('server-io-status').textContent = `☁️ 备份成功: ${(d.size / 1024 / 1024).toFixed(2)}MB (${d.chunkCount} 片段, ${d.eventCount} 事件)`;
} else {
$('server-io-status').textContent = '备份失败: ' + (d.error || '未知错误');
}
break;
case 'VECTOR_RESTORE_RESULT':
$('btn-restore-server').disabled = false;
if (d.success) {
let msg = `☁️ 恢复成功: ${d.chunkCount} 片段, ${d.eventCount} 事件`;
if (d.warnings?.length) {
msg += '\n⚠ ' + d.warnings.join('\n⚠ ');
}
$('server-io-status').textContent = msg;
postMsg('REQUEST_VECTOR_STATS');
} else {
$('server-io-status').textContent = '恢复失败: ' + (d.error || '未知错误');
}
break;
case 'RECALL_LOG':
setRecallLog(d.text || '');
@@ -1777,4 +1812,5 @@
setHtml(container, html);
}
})();

View File

@@ -561,6 +561,18 @@
style="flex:1">导入向量数据</button>
</div>
<div class="settings-hint" id="vector-io-status"></div>
<div class="settings-btn-row" style="margin-top:6px">
<button class="btn btn-sm" id="btn-backup-server"
style="flex:1">☁️ 备份向量到服务器</button>
<button class="btn btn-sm" id="btn-restore-server"
style="flex:1">☁️ 从服务器恢复向量</button>
</div>
<div class="settings-hint" id="server-io-status"></div>
<div style="margin-top:6px">
<button class="btn btn-sm" id="btn-manage-backups" style="width:100%">
☁️ 管理服务器向量备份
</button>
</div>
</div>
</div>
</div>
@@ -854,6 +866,7 @@
</div>
</div>
</div>
</body>
</html>

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;
@@ -1459,6 +1461,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 +1652,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 +1948,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 +1972,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 监听
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -5,6 +5,7 @@
import { zipSync, unzipSync, strToU8, strFromU8 } from '../../../../libs/fflate.mjs';
import { getContext } from '../../../../../../../extensions.js';
import { getRequestHeaders } from '../../../../../../../../script.js';
import { xbLog } from '../../../../core/debug-core.js';
import {
getMeta,
@@ -72,6 +73,37 @@ function downloadBlob(blob, filename) {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// 二进制 Uint8Array → base64分块处理避免 btoa 栈溢出)
function uint8ToBase64(uint8) {
const CHUNK = 0x8000;
let result = '';
for (let i = 0; i < uint8.length; i += CHUNK) {
result += String.fromCharCode.apply(null, uint8.subarray(i, i + CHUNK));
}
return btoa(result);
}
// base64 → Uint8Array
function base64ToUint8(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// 服务器备份文件名
function getBackupFilename(chatId) {
// chatId 可能含中文/特殊字符ST 只接受 [a-zA-Z0-9_-]
// 用简单 hash 生成安全文件名
let hash = 0;
for (let i = 0; i < chatId.length; i++) {
hash = ((hash << 5) - hash + chatId.charCodeAt(i)) | 0;
}
const safe = (hash >>> 0).toString(36);
return `LWB_VectorBackup_${safe}.zip`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 导出
@@ -383,3 +415,465 @@ export async function importVectors(file, onProgress) {
fingerprintMismatch,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 备份到服务器
// ═══════════════════════════════════════════════════════════════════════════
export async function backupToServer(onProgress) {
const { chatId } = getContext();
if (!chatId) {
throw new Error('未打开聊天');
}
onProgress?.('读取数据...');
const meta = await getMeta(chatId);
const chunks = await getAllChunks(chatId);
const chunkVectors = await getAllChunkVectors(chatId);
const eventVectors = await getAllEventVectors(chatId);
const stateAtoms = getStateAtoms();
const stateVectors = await getAllStateVectors(chatId);
if (chunkVectors.length === 0 && eventVectors.length === 0 && stateVectors.length === 0) {
throw new Error('没有可备份的向量数据');
}
const dims = chunkVectors[0]?.vector?.length
|| eventVectors[0]?.vector?.length
|| stateVectors[0]?.vector?.length
|| 0;
if (dims === 0) {
throw new Error('无法确定向量维度');
}
onProgress?.('构建索引...');
const sortedChunks = [...chunks].sort((a, b) => a.chunkId.localeCompare(b.chunkId));
const chunkVectorMap = new Map(chunkVectors.map(cv => [cv.chunkId, cv.vector]));
const chunksJsonl = sortedChunks.map(c => JSON.stringify({
chunkId: c.chunkId,
floor: c.floor,
chunkIdx: c.chunkIdx,
speaker: c.speaker,
isUser: c.isUser,
text: c.text,
textHash: c.textHash,
})).join('\n');
const chunkVectorsOrdered = sortedChunks.map(c => chunkVectorMap.get(c.chunkId) || new Array(dims).fill(0));
onProgress?.('压缩向量...');
const sortedEventVectors = [...eventVectors].sort((a, b) => a.eventId.localeCompare(b.eventId));
const eventsJsonl = sortedEventVectors.map(ev => JSON.stringify({
eventId: ev.eventId,
})).join('\n');
const eventVectorsOrdered = sortedEventVectors.map(ev => ev.vector);
const sortedStateVectors = [...stateVectors].sort((a, b) => String(a.atomId).localeCompare(String(b.atomId)));
const stateVectorsOrdered = sortedStateVectors.map(v => v.vector);
const rDims = sortedStateVectors.find(v => v.rVector?.length)?.rVector?.length || dims;
const stateRVectorsOrdered = sortedStateVectors.map(v =>
v.rVector?.length ? v.rVector : new Array(rDims).fill(0)
);
const stateVectorsJsonl = sortedStateVectors.map(v => JSON.stringify({
atomId: v.atomId,
floor: v.floor,
hasRVector: !!(v.rVector?.length),
rDims: v.rVector?.length || 0,
})).join('\n');
const manifest = {
version: EXPORT_VERSION,
exportedAt: Date.now(),
chatId,
fingerprint: meta.fingerprint || '',
dims,
chunkCount: sortedChunks.length,
chunkVectorCount: chunkVectors.length,
eventCount: sortedEventVectors.length,
stateAtomCount: stateAtoms.length,
stateVectorCount: stateVectors.length,
stateRVectorCount: sortedStateVectors.filter(v => v.rVector?.length).length,
rDims,
lastChunkFloor: meta.lastChunkFloor ?? -1,
};
onProgress?.('打包文件...');
const zipData = zipSync({
'manifest.json': strToU8(JSON.stringify(manifest, null, 2)),
'chunks.jsonl': strToU8(chunksJsonl),
'chunk_vectors.bin': float32ToBytes(chunkVectorsOrdered, dims),
'events.jsonl': strToU8(eventsJsonl),
'event_vectors.bin': float32ToBytes(eventVectorsOrdered, dims),
'state_atoms.json': strToU8(JSON.stringify(stateAtoms)),
'state_vectors.jsonl': strToU8(stateVectorsJsonl),
'state_vectors.bin': stateVectorsOrdered.length
? float32ToBytes(stateVectorsOrdered, dims)
: new Uint8Array(0),
'state_r_vectors.bin': stateRVectorsOrdered.length
? float32ToBytes(stateRVectorsOrdered, rDims)
: new Uint8Array(0),
}, { level: 1 });
onProgress?.('上传到服务器...');
const base64 = uint8ToBase64(zipData);
const filename = getBackupFilename(chatId);
const res = await fetch('/api/files/upload', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: filename, data: base64 }),
});
if (!res.ok) {
throw new Error(`服务器返回 ${res.status}`);
}
// 新增:安全读取 path 字段
let uploadedPath = null;
try {
const resJson = await res.json();
if (typeof resJson?.path === 'string') uploadedPath = resJson.path;
} catch (_) { /* JSON 解析失败时 uploadedPath 保持 null */ }
// 新增:写清单(独立 try/catch失败不影响原有备份返回
try {
await upsertManifestEntry({
filename,
serverPath: uploadedPath,
size: zipData.byteLength,
chatId,
backupTime: new Date().toISOString(),
});
} catch (e) {
xbLog.warn(MODULE_ID, `清单写入失败(不影响备份结果): ${e.message}`);
}
const sizeMB = (zipData.byteLength / 1024 / 1024).toFixed(2);
xbLog.info(MODULE_ID, `备份完成: ${filename} (${sizeMB}MB)`);
return {
filename,
size: zipData.byteLength,
chunkCount: sortedChunks.length,
eventCount: sortedEventVectors.length,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 从服务器恢复
// ═══════════════════════════════════════════════════════════════════════════
export async function restoreFromServer(onProgress) {
const { chatId } = getContext();
if (!chatId) {
throw new Error('未打开聊天');
}
onProgress?.('从服务器下载...');
const filename = getBackupFilename(chatId);
const res = await fetch(`/user/files/${filename}`, {
headers: getRequestHeaders(),
cache: 'no-cache',
});
if (!res.ok) {
if (res.status === 404) {
throw new Error('服务器上没有找到此聊天的备份');
}
throw new Error(`服务器返回 ${res.status}`);
}
const arrayBuffer = await res.arrayBuffer();
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
throw new Error('服务器上没有找到此聊天的备份');
}
onProgress?.('解压文件...');
const zipData = new Uint8Array(arrayBuffer);
let unzipped;
try {
unzipped = unzipSync(zipData);
} catch (e) {
throw new Error('备份文件格式错误,无法解压');
}
if (!unzipped['manifest.json']) {
throw new Error('缺少 manifest.json');
}
const manifest = JSON.parse(strFromU8(unzipped['manifest.json']));
if (![1, 2].includes(manifest.version)) {
throw new Error(`不支持的版本: ${manifest.version}`);
}
onProgress?.('校验数据...');
const vectorCfg = getVectorConfig();
const currentFingerprint = vectorCfg ? getEngineFingerprint(vectorCfg) : '';
const fingerprintMismatch = manifest.fingerprint && currentFingerprint && manifest.fingerprint !== currentFingerprint;
const chatIdMismatch = manifest.chatId !== chatId;
const warnings = [];
if (fingerprintMismatch) {
warnings.push(`向量引擎不匹配(文件: ${manifest.fingerprint}, 当前: ${currentFingerprint}),导入后需重新生成`);
}
if (chatIdMismatch) {
warnings.push(`聊天ID不匹配文件: ${manifest.chatId}, 当前: ${chatId}`);
}
onProgress?.('解析数据...');
const chunksJsonl = unzipped['chunks.jsonl'] ? strFromU8(unzipped['chunks.jsonl']) : '';
const chunkMetas = chunksJsonl.split('\n').filter(Boolean).map(line => JSON.parse(line));
const chunkVectorsBytes = unzipped['chunk_vectors.bin'];
const chunkVectors = chunkVectorsBytes ? bytesToFloat32(chunkVectorsBytes, manifest.dims) : [];
const eventsJsonl = unzipped['events.jsonl'] ? strFromU8(unzipped['events.jsonl']) : '';
const eventMetas = eventsJsonl.split('\n').filter(Boolean).map(line => JSON.parse(line));
const eventVectorsBytes = unzipped['event_vectors.bin'];
const eventVectors = eventVectorsBytes ? bytesToFloat32(eventVectorsBytes, manifest.dims) : [];
const stateAtoms = unzipped['state_atoms.json']
? JSON.parse(strFromU8(unzipped['state_atoms.json']))
: [];
const stateVectorsJsonl = unzipped['state_vectors.jsonl'] ? strFromU8(unzipped['state_vectors.jsonl']) : '';
const stateVectorMetas = stateVectorsJsonl.split('\n').filter(Boolean).map(line => JSON.parse(line));
const stateVectorsBytes = unzipped['state_vectors.bin'];
const stateVectors = (stateVectorsBytes && stateVectorMetas.length)
? bytesToFloat32(stateVectorsBytes, manifest.dims)
: [];
const stateRVectorsBytes = unzipped['state_r_vectors.bin'];
const stateRVectors = (stateRVectorsBytes && stateVectorMetas.length)
? bytesToFloat32(stateRVectorsBytes, manifest.rDims || manifest.dims)
: [];
const hasRVectorMeta = stateVectorMetas.some(m => typeof m.hasRVector === 'boolean');
if (chunkMetas.length !== chunkVectors.length) {
throw new Error(`chunk 数量不匹配: 元数据 ${chunkMetas.length}, 向量 ${chunkVectors.length}`);
}
if (eventMetas.length !== eventVectors.length) {
throw new Error(`event 数量不匹配: 元数据 ${eventMetas.length}, 向量 ${eventVectors.length}`);
}
if (stateVectorMetas.length !== stateVectors.length) {
throw new Error(`state 向量数量不匹配: 元数据 ${stateVectorMetas.length}, 向量 ${stateVectors.length}`);
}
if (stateRVectors.length > 0 && stateVectorMetas.length !== stateRVectors.length) {
throw new Error(`state r-vector count mismatch: meta=${stateVectorMetas.length}, vectors=${stateRVectors.length}`);
}
onProgress?.('清空旧数据...');
await clearAllChunks(chatId);
await clearEventVectors(chatId);
await clearStateVectors(chatId);
clearStateAtoms();
onProgress?.('写入数据...');
if (chunkMetas.length > 0) {
const chunksToSave = chunkMetas.map(meta => ({
chunkId: meta.chunkId,
floor: meta.floor,
chunkIdx: meta.chunkIdx,
speaker: meta.speaker,
isUser: meta.isUser,
text: meta.text,
textHash: meta.textHash,
}));
await saveChunks(chatId, chunksToSave);
const chunkVectorItems = chunkMetas.map((meta, idx) => ({
chunkId: meta.chunkId,
vector: chunkVectors[idx],
}));
await saveChunkVectors(chatId, chunkVectorItems, manifest.fingerprint);
}
if (eventMetas.length > 0) {
const eventVectorItems = eventMetas.map((meta, idx) => ({
eventId: meta.eventId,
vector: eventVectors[idx],
}));
await saveEventVectors(chatId, eventVectorItems, manifest.fingerprint);
}
if (stateAtoms.length > 0) {
saveStateAtoms(stateAtoms);
}
if (stateVectorMetas.length > 0) {
const stateVectorItems = stateVectorMetas.map((meta, idx) => ({
atomId: meta.atomId,
floor: meta.floor,
vector: stateVectors[idx],
rVector: (stateRVectors[idx] && (!hasRVectorMeta || meta.hasRVector)) ? stateRVectors[idx] : null,
}));
await saveStateVectors(chatId, stateVectorItems, manifest.fingerprint);
}
await updateMeta(chatId, {
fingerprint: manifest.fingerprint,
lastChunkFloor: manifest.lastChunkFloor,
});
xbLog.info(MODULE_ID, `从服务器恢复完成: ${chunkMetas.length} chunks, ${eventMetas.length} events, ${stateAtoms.length} state atoms`);
return {
chunkCount: chunkMetas.length,
eventCount: eventMetas.length,
warnings,
fingerprintMismatch,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 备份清单管理
// ═══════════════════════════════════════════════════════════════════════════
const BACKUP_MANIFEST = 'LWB_BackupManifest.json';
// 宽容解析:非数组/JSON 失败/字段异常时清洗,不抛错
async function fetchManifest() {
try {
const res = await fetch(`/user/files/${BACKUP_MANIFEST}`, {
headers: getRequestHeaders(),
cache: 'no-cache',
});
if (!res.ok) return [];
const raw = await res.json();
if (!Array.isArray(raw)) return [];
return raw.map(normalizeManifestEntry).filter(Boolean);
} catch (_) {
return [];
}
}
// 标准化单条条目字段,非法 filename 直接丢弃,其余字段降级
function normalizeManifestEntry(raw) {
if (!raw || typeof raw !== 'object') return null;
const filename = typeof raw.filename === 'string' ? raw.filename : null;
if (!filename || !/^LWB_VectorBackup_[a-z0-9]+\.zip$/.test(filename)) return null;
const rawPath = typeof raw.serverPath === 'string' ? raw.serverPath.replace(/^\/+/, '') : null;
return {
filename,
serverPath: rawPath,
size: typeof raw.size === 'number' ? raw.size : null,
chatId: typeof raw.chatId === 'string' ? raw.chatId : null,
backupTime: typeof raw.backupTime === 'string' ? raw.backupTime : null,
};
}
// 安全推导/校验 serverPath缺失时推导与 filename 不一致时拒绝
function buildSafeServerPath(filename, serverPath) {
const expected = `user/files/${filename}`;
if (!serverPath) return expected;
const normalized = serverPath.replace(/^\/+/, '');
if (normalized !== expected) {
throw new Error(`serverPath 不安全: ${serverPath}`);
}
return normalized;
}
// 读-改(upsert by filename)-写回-验证,失败最多重试 2 次
async function upsertManifestEntry({ filename, serverPath, size, chatId, backupTime }) {
if (typeof serverPath === 'string') serverPath = serverPath.replace(/^\/+/, '');
const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
// 读取现有清单
const existing = await fetchManifest();
// upsert by filename
const idx = existing.findIndex(e => e.filename === filename);
const entry = { filename, serverPath, size, chatId, backupTime };
if (idx >= 0) {
existing[idx] = entry;
} else {
existing.push(entry);
}
// 上传清单
const json = JSON.stringify(existing, null, 2);
const base64 = uint8ToBase64(new TextEncoder().encode(json));
const res = await fetch('/api/files/upload', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: BACKUP_MANIFEST, data: base64 }),
});
if (!res.ok) throw new Error(`清单上传失败: ${res.status}`);
// 写后立即重读验证
const verified = await fetchManifest();
if (verified.some(e => e.filename === filename)) return;
// 最后一次仍失败才抛出
if (attempt === MAX_RETRIES - 1) {
throw new Error('清单写入后验证失败,重试已耗尽');
}
}
}
// 删除前校验 + POST /api/files/delete + 更新清单
async function deleteServerBackup(filename, serverPath) {
// 安全校验
if (!/^LWB_VectorBackup_[a-z0-9]+\.zip$/.test(filename)) {
throw new Error(`非法文件名: ${filename}`);
}
const safePath = buildSafeServerPath(filename, serverPath || null);
// 物理删除
const res = await fetch('/api/files/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ path: safePath }),
});
if (!res.ok) {
const err = new Error(`删除失败: ${res.status}`);
err.status = res.status;
err.method = 'DELETE';
throw err;
}
// 更新清单(删除条目)
try {
const existing = await fetchManifest();
const filtered = existing.filter(e => e.filename !== filename);
const json = JSON.stringify(filtered, null, 2);
const base64 = uint8ToBase64(new TextEncoder().encode(json));
const upRes = await fetch('/api/files/upload', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: BACKUP_MANIFEST, data: base64 }),
});
if (!upRes.ok) {
throw new Error('zip 已删除,但清单更新失败,请手动刷新');
}
} catch (e) {
// zip 删成功但清单更新失败 → 抛"部分成功"错误
const partialErr = new Error(e.message || 'zip 已删除,清单同步失败');
partialErr.partial = true;
throw partialErr;
}
}
// 集中判断 404/405/method not allowed/unsupported
function isDeleteUnsupportedError(err) {
if (!err) return false;
const status = err.status;
if (status === 404 || status === 405) return true;
const msg = String(err.message || '').toLowerCase();
return msg.includes('method not allowed') || msg.includes('unsupported') || msg.includes('not found');
}
export { fetchManifest, deleteServerBackup, isDeleteUnsupportedError, getBackupFilename };