* 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:
@@ -8,6 +8,7 @@ import { EnaPlannerStorage } from '../../core/server-storage.js';
|
|||||||
import { postToIframe, isTrustedIframeEvent } from '../../core/iframe-messaging.js';
|
import { postToIframe, isTrustedIframeEvent } from '../../core/iframe-messaging.js';
|
||||||
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
|
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
|
||||||
import { formatOutlinePrompt } from '../story-outline/story-outline.js';
|
import { formatOutlinePrompt } from '../story-outline/story-outline.js';
|
||||||
|
import jsyaml from '../../libs/js-yaml.mjs';
|
||||||
|
|
||||||
const EXT_NAME = 'ena-planner';
|
const EXT_NAME = 'ena-planner';
|
||||||
const OVERLAY_ID = 'xiaobaix-ena-planner-overlay';
|
const OVERLAY_ID = 'xiaobaix-ena-planner-overlay';
|
||||||
@@ -551,6 +552,7 @@ function matchSelective(entry, scanText) {
|
|||||||
const keys2 = Array.isArray(entry?.keysecondary) ? entry.keysecondary.filter(Boolean) : [];
|
const keys2 = Array.isArray(entry?.keysecondary) ? entry.keysecondary.filter(Boolean) : [];
|
||||||
|
|
||||||
const total = keys.length;
|
const total = keys.length;
|
||||||
|
if (total === 0) return false;
|
||||||
const hit = keys.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0);
|
const hit = keys.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0);
|
||||||
|
|
||||||
let ok = false;
|
let ok = false;
|
||||||
@@ -838,6 +840,17 @@ function resolveGetMessageVariableMacros(text, messageVars) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveFormatMessageVariableMacros(text, messageVars) {
|
||||||
|
return text.replace(/{{\s*format_message_variable::([^}]+)\s*}}/g, (_, rawPath) => {
|
||||||
|
const path = String(rawPath || '').trim();
|
||||||
|
if (!path) return '';
|
||||||
|
const val = deepGet(messageVars, path);
|
||||||
|
if (val == null) return '';
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
try { return jsyaml.dump(val, { lineWidth: -1, noRefs: true }); } catch { return safeStringify(val); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getLatestMessageVarTable() {
|
function getLatestMessageVarTable() {
|
||||||
try {
|
try {
|
||||||
if (window.Mvu?.getMvuData) {
|
if (window.Mvu?.getMvuData) {
|
||||||
@@ -858,6 +871,7 @@ async function renderTemplateAll(text, env, messageVars) {
|
|||||||
out = await evalEjsIfPossible(out, env);
|
out = await evalEjsIfPossible(out, env);
|
||||||
out = substituteMacrosViaST(out);
|
out = substituteMacrosViaST(out);
|
||||||
out = resolveGetMessageVariableMacros(out, messageVars);
|
out = resolveGetMessageVariableMacros(out, messageVars);
|
||||||
|
out = resolveFormatMessageVariableMacros(out, messageVars);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,7 +1147,7 @@ async function buildPlannerMessages(rawUserInput) {
|
|||||||
const vectorRaw = '';
|
const vectorRaw = '';
|
||||||
|
|
||||||
// Build scanText for worldbook keyword activation
|
// Build scanText for worldbook keyword activation
|
||||||
const scanText = [charBlockRaw, cachedSummary, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n');
|
const scanText = [charBlockRaw, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n');
|
||||||
|
|
||||||
const worldbookRaw = await buildWorldbookBlock(scanText);
|
const worldbookRaw = await buildWorldbookBlock(scanText);
|
||||||
const outlineRaw = typeof formatOutlinePrompt === 'function' ? (formatOutlinePrompt() || '') : '';
|
const outlineRaw = typeof formatOutlinePrompt === 'function' ? (formatOutlinePrompt() || '') : '';
|
||||||
|
|||||||
@@ -54,6 +54,52 @@ const DEFAULT_JSON_TEMPLATES = {
|
|||||||
"stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'",
|
"stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'",
|
||||||
"secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。"
|
"secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。"
|
||||||
}
|
}
|
||||||
|
}`,
|
||||||
|
importantNpc: `{
|
||||||
|
"name": "角色全名",
|
||||||
|
"aliases": ["别名1", "别名2", "英文名/拼音"],
|
||||||
|
"intro": "白描一句话:外貌+身份。仅用名词和动词,禁止形容词和比喻。例:'黑色长直发过腰,左眼下泪痣,着灰色风衣的赏金猎人。'",
|
||||||
|
"appearance": {
|
||||||
|
"build": "体型白描(如:比{{user}}高一个头。宽肩,窄腰。)",
|
||||||
|
"face": "面部白描(如:颧骨高,下颌线锐利。左眉尾有一道旧疤。)",
|
||||||
|
"hair_and_eyes": "发型发色、瞳色",
|
||||||
|
"marks": "显著标记——疤痕、痣、纹身等,无则写'无'",
|
||||||
|
"attire": "当前穿着"
|
||||||
|
},
|
||||||
|
"background": "角色来历与当前处境。必须交代因果链:什么过去→塑造了什么性格→为什么出现在当前场景。200字左右。",
|
||||||
|
"world_adaptation": {},
|
||||||
|
"personality_palette": {
|
||||||
|
"base_color": "底色——驱动一切行为的最底层核心性格(如:恐惧、控制欲、孤独)",
|
||||||
|
"main_colors": ["主色调1", "主色调2——日常最常表现出的性格"],
|
||||||
|
"accents": ["点缀——不常见但在特定情境下浮现的性格"],
|
||||||
|
"derivatives": [
|
||||||
|
"[主色调1]衍生一:(写具体场景+具体行为,不是定义。错误:'她很温柔';正确:'会在{{user}}加班时默默端一杯温水放在桌上,不说话,放下就走')",
|
||||||
|
"[主色调1]衍生二:(另一个场景的表现,衍生之间可以互相矛盾——这才是真实的人)",
|
||||||
|
"[主色调2]衍生一:...",
|
||||||
|
"[底色]衍生一:(底色通常不轻易暴露,写什么条件下会泄漏出来)",
|
||||||
|
"[点缀]衍生一:..."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"speaking": {
|
||||||
|
"style": "语气、语速、口癖、惯用词",
|
||||||
|
"samples": ["台词示例1——展现主色调", "台词示例2——展现底色泄漏", "台词示例3——展现对{{user}}的态度"],
|
||||||
|
"attitude_to_user": "对{{user}}的态度及其原因"
|
||||||
|
},
|
||||||
|
"understanding": [
|
||||||
|
{
|
||||||
|
"about": "某个性格特质或行为模式",
|
||||||
|
"clarification": "这个特质的真正含义是……不是……在什么情况下会……常见误读是……正确理解是……"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "另一个容易被AI误读的特质",
|
||||||
|
"clarification": "解释动机而非重复描述。预判AI可能的补全方向并提前纠正。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"game_data": {
|
||||||
|
"stance": "核心态度·具体表现(如:'中立·唯利是图'、'友善·盲目崇拜'、'敌对·疯狂')",
|
||||||
|
"secret": "角色掌握的一个关键秘密/信息/道具。必须结合剧情大纲生成,作为剧情钩子。",
|
||||||
|
"motivation": "核心驱动力与行动优先级准则"
|
||||||
|
}
|
||||||
}`,
|
}`,
|
||||||
stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`,
|
stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`,
|
||||||
worldGenStep1: `{
|
worldGenStep1: `{
|
||||||
@@ -258,6 +304,31 @@ const DEFAULT_PROMPTS = {
|
|||||||
u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*):\n${wrap('story_outline', v.storyOutline) || '<story_outline>\n(无)\n</story_outline>'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}】\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段(intro/background/persona/game_data 等)中,如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n\n模板:${JSON_TEMPLATES.npc}`,
|
u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*):\n${wrap('story_outline', v.storyOutline) || '<story_outline>\n(无)\n</story_outline>'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}】\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段(intro/background/persona/game_data 等)中,如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n\n模板:${JSON_TEMPLATES.npc}`,
|
||||||
a2: () => `了解,开始生成JSON:`
|
a2: () => `了解,开始生成JSON:`
|
||||||
},
|
},
|
||||||
|
importantNpc: {
|
||||||
|
u1: v => `你是TRPG重要角色档案生成器。将陌生人【${v.strangerName} - ${v.strangerInfo}】扩充为剧情核心角色的完整档案。
|
||||||
|
|
||||||
|
核心写作原则:
|
||||||
|
1. **基础信息用绝对零度白描**:只写客观事实,不用形容词/比喻/模糊词(似乎、仿佛、宛如)。用名词和动词直接呈现。
|
||||||
|
× "她有一头好看柔顺的黑色长发" → √ "黑色长直发,过腰。瞳色黑。"
|
||||||
|
× "他身材魁梧,给人压迫感" → √ "身高比{{user}}高一个头。宽肩,厚背。"
|
||||||
|
|
||||||
|
2. **性格用调色盘+衍生展开**:人的性格像调色盘,底色是最深层驱动力,主色调是日常表现,点缀是偶尔闪现的侧面。每种性格必须通过"衍生"展开为具体场景行为——不是贴标签,是写"在什么情况下会做什么"。衍生之间可以互相矛盾,这才是真实的人。
|
||||||
|
× "温柔衍生:她很温柔,对人很好。"(标签重复)
|
||||||
|
√ "温柔衍生:生气时——真正生气基本都和{{user}}有关。当有人欺负{{user}},她会一把拉住{{user}}让其靠自己,然后用冰冷目光看对方。"
|
||||||
|
|
||||||
|
3. **台词示例**:3句具体台词,分别展现主色调、底色泄漏、对{{user}}的态度。
|
||||||
|
|
||||||
|
4. **二次解释(understanding数组)**:逐条针对角色最容易被误读的性格特质,写结构化纠偏。每条包含about(哪个特质)和clarification(真正含义、不是什么、在什么情况下怎样)。至少2条。这不是重复调色盘,是解释动机和预判误读。
|
||||||
|
× "关于温柔:她很温柔,对人好。"(重复调色盘)
|
||||||
|
√ "关于乐观的双重性:和{{user}}在一起时是真实的,和其他人相处时是维持人设的假象。脆弱时只会在{{user}}面前表现。"
|
||||||
|
|
||||||
|
5. **世界观适配(world_adaptation对象)**:根据故事世界观动态生成键值对。修仙世界→灵根、境界、功法等字段;赛博世界→义体部位、型号、副作用等字段;现代世界→可留空对象。不预设固定字段,由你根据世界观判断需要什么。
|
||||||
|
|
||||||
|
基于世界观、剧情大纲和现有角色关系,输出严格JSON。`,
|
||||||
|
a1: () => `明白。我将严格遵循白描原则和调色盘衍生写法,按JSON模板输出完整角色档案,不含多余文本。`,
|
||||||
|
u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密和动机*):\n${wrap('story_outline', v.storyOutline) || '<story_outline>\n(无)\n</story_outline>'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}】\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段中如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n5. personality_palette.derivatives 至少5条,每条都是具体场景+具体行为\n6. speaking.samples 须3句具体台词\n7. understanding数组至少2条,每条须含about和clarification\n8. world_adaptation根据世界观动态生成键值对,无特殊体系则输出空对象{}\n9. 总输出约800-1500字\n\n模板:${JSON_TEMPLATES.importantNpc}`,
|
||||||
|
a2: () => `了解,开始以白描+调色盘衍生法生成重要角色档案JSON:`
|
||||||
|
},
|
||||||
stranger: {
|
stranger: {
|
||||||
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`,
|
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`,
|
||||||
a1: () => `明白。请提供【世界观】和【剧情经历】,我将提取角色并以JSON数组输出。`,
|
a1: () => `明白。请提供【世界观】和【剧情经历】,我将提取角色并以JSON数组输出。`,
|
||||||
@@ -585,6 +656,7 @@ export const buildSmsMessages = v => build('sms', v);
|
|||||||
export const buildSummaryMessages = v => build('summary', v);
|
export const buildSummaryMessages = v => build('summary', v);
|
||||||
export const buildInviteMessages = v => build('invite', v);
|
export const buildInviteMessages = v => build('invite', v);
|
||||||
export const buildNpcGenerationMessages = v => build('npc', v);
|
export const buildNpcGenerationMessages = v => build('npc', v);
|
||||||
|
export const buildImportantNpcGenerationMessages = v => build('importantNpc', v);
|
||||||
export const buildExtractStrangersMessages = v => build('stranger', v);
|
export const buildExtractStrangersMessages = v => build('stranger', v);
|
||||||
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
|
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
|
||||||
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);
|
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@ import { StoryOutlineStorage } from "../../core/server-storage.js";
|
|||||||
import { promptManager } from "../../../../../openai.js";
|
import { promptManager } from "../../../../../openai.js";
|
||||||
import {
|
import {
|
||||||
buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent,
|
buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent,
|
||||||
buildNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages,
|
buildNpcGenerationMessages, buildImportantNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages,
|
||||||
buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages,
|
buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages,
|
||||||
buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages,
|
buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages,
|
||||||
buildOverlayHtml, MOBILE_LAYOUT_STYLE, DESKTOP_LAYOUT_STYLE, getPromptConfigPayload, setPromptConfig
|
buildOverlayHtml, MOBILE_LAYOUT_STYLE, DESKTOP_LAYOUT_STYLE, getPromptConfigPayload, setPromptConfig
|
||||||
@@ -874,14 +874,17 @@ async function handleCheckStrangerWb({ requestId, strangerName }) {
|
|||||||
postFrame({ type: 'CHECK_STRANGER_WORLDBOOK_RESULT', requestId, found: !!r, ...(r && { worldbookUid: r.uid, worldbook: r.bookName, entryName: r.entry.comment || r.entry.key?.[0] || strangerName }) });
|
postFrame({ type: 'CHECK_STRANGER_WORLDBOOK_RESULT', requestId, found: !!r, ...(r && { worldbookUid: r.uid, worldbook: r.bookName, entryName: r.entry.comment || r.entry.key?.[0] || strangerName }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenNpc({ requestId, strangerName, strangerInfo }) {
|
async function handleGenNpc({ requestId, strangerName, strangerInfo, npcType = 'npc' }) {
|
||||||
try {
|
try {
|
||||||
const comm = getCommSettings();
|
const comm = getCommSettings();
|
||||||
const ctx = getContext(), char = ctx.characters?.[ctx.characterId];
|
const ctx = getContext(), char = ctx.characters?.[ctx.characterId];
|
||||||
if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡');
|
if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡');
|
||||||
const primary = char.data?.extensions?.world;
|
const primary = char.data?.extensions?.world;
|
||||||
if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书');
|
if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书');
|
||||||
const msgs = buildNpcGenerationMessages(getCommonPromptVars({ strangerName, strangerInfo: strangerInfo || '(无描述)' }));
|
const vars = getCommonPromptVars({ strangerName, strangerInfo: strangerInfo || '(无描述)' });
|
||||||
|
const msgs = npcType === 'importantNpc'
|
||||||
|
? buildImportantNpcGenerationMessages(vars)
|
||||||
|
: buildNpcGenerationMessages(vars);
|
||||||
const npc = await callLLMJson({ messages: msgs, validate: V.npc });
|
const npc = await callLLMJson({ messages: msgs, validate: V.npc });
|
||||||
if (!npc?.name) return replyErr('GENERATE_NPC_RESULT', requestId, 'NPC 生成失败:无法解析 JSON 数据');
|
if (!npc?.name) return replyErr('GENERATE_NPC_RESULT', requestId, 'NPC 生成失败:无法解析 JSON 数据');
|
||||||
const wd = await loadWorldInfo(primary); if (!wd) return replyErr('GENERATE_NPC_RESULT', requestId, `无法加载世界书: ${primary}`);
|
const wd = await loadWorldInfo(primary); if (!wd) return replyErr('GENERATE_NPC_RESULT', requestId, `无法加载世界书: ${primary}`);
|
||||||
|
|||||||
@@ -424,6 +424,19 @@
|
|||||||
$('vector-io-status').textContent = '导入中...';
|
$('vector-io-status').textContent = '导入中...';
|
||||||
postMsg('VECTOR_IMPORT_PICK');
|
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();
|
initAnchorUI();
|
||||||
postMsg('REQUEST_ANCHOR_STATS');
|
postMsg('REQUEST_ANCHOR_STATS');
|
||||||
@@ -1500,6 +1513,28 @@
|
|||||||
$('vector-io-status').textContent = '导入失败: ' + (d.error || '未知错误');
|
$('vector-io-status').textContent = '导入失败: ' + (d.error || '未知错误');
|
||||||
}
|
}
|
||||||
break;
|
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':
|
case 'RECALL_LOG':
|
||||||
setRecallLog(d.text || '');
|
setRecallLog(d.text || '');
|
||||||
@@ -1777,4 +1812,5 @@
|
|||||||
|
|
||||||
setHtml(container, html);
|
setHtml(container, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -561,6 +561,18 @@
|
|||||||
style="flex:1">导入向量数据</button>
|
style="flex:1">导入向量数据</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-hint" id="vector-io-status"></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,6 +866,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ import {
|
|||||||
} from "./vector/storage/state-store.js";
|
} from "./vector/storage/state-store.js";
|
||||||
|
|
||||||
// vector io
|
// 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";
|
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;
|
let lastVectorWarningAt = 0;
|
||||||
const VECTOR_WARNING_COOLDOWN_MS = 120000; // 2分钟内不重复提醒
|
const VECTOR_WARNING_COOLDOWN_MS = 120000; // 2分钟内不重复提醒
|
||||||
|
let backupDeleteSupported = true;
|
||||||
|
let backupDeleteUnsupportedReason = '';
|
||||||
|
|
||||||
const EXT_PROMPT_KEY = "LittleWhiteBox_StorySummary";
|
const EXT_PROMPT_KEY = "LittleWhiteBox_StorySummary";
|
||||||
const MIN_INJECTION_DEPTH = 2;
|
const MIN_INJECTION_DEPTH = 2;
|
||||||
@@ -1459,6 +1461,56 @@ async function handleFrameMessage(event) {
|
|||||||
input.click();
|
input.click();
|
||||||
})();
|
})();
|
||||||
break;
|
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":
|
case "REQUEST_VECTOR_STATS":
|
||||||
sendVectorStatsToFrame();
|
sendVectorStatsToFrame();
|
||||||
@@ -1600,6 +1652,7 @@ async function handleManualGenerate(mesId, config) {
|
|||||||
|
|
||||||
async function handleChatChanged() {
|
async function handleChatChanged() {
|
||||||
if (!events) return;
|
if (!events) return;
|
||||||
|
_lastBuiltPromptText = ""; // ← 加这一行,切聊天时清掉旧 summary
|
||||||
const { chat } = getContext();
|
const { chat } = getContext();
|
||||||
activeChatId = getContext().chatId || null;
|
activeChatId = getContext().chatId || null;
|
||||||
const newLength = Array.isArray(chat) ? chat.length : 0;
|
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_STARTED, handleGenerationStarted);
|
||||||
events.on(event_types.GENERATION_STOPPED, clearExtensionPrompt);
|
events.on(event_types.GENERATION_STOPPED, clearExtensionPrompt);
|
||||||
events.on(event_types.GENERATION_ENDED, 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() {
|
function unregisterEvents() {
|
||||||
@@ -1915,6 +1972,169 @@ function unregisterEvents() {
|
|||||||
document.removeEventListener("keydown", onSendKeydown, true);
|
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 监听
|
// Toggle 监听
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { zipSync, unzipSync, strToU8, strFromU8 } from '../../../../libs/fflate.mjs';
|
import { zipSync, unzipSync, strToU8, strFromU8 } from '../../../../libs/fflate.mjs';
|
||||||
import { getContext } from '../../../../../../../extensions.js';
|
import { getContext } from '../../../../../../../extensions.js';
|
||||||
|
import { getRequestHeaders } from '../../../../../../../../script.js';
|
||||||
import { xbLog } from '../../../../core/debug-core.js';
|
import { xbLog } from '../../../../core/debug-core.js';
|
||||||
import {
|
import {
|
||||||
getMeta,
|
getMeta,
|
||||||
@@ -72,6 +73,37 @@ function downloadBlob(blob, filename) {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
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,
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user