sync: align local with upstream main

This commit is contained in:
2026-04-02 15:00:25 +08:00
parent 003f7acfaf
commit 43efd2ee89
15 changed files with 920 additions and 1240 deletions

View File

@@ -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() || '') : '';

View File

@@ -72,11 +72,9 @@ function djb2(str) {
function shouldRenderContentByBlock(codeBlock) { function shouldRenderContentByBlock(codeBlock) {
if (!codeBlock) return false; if (!codeBlock) return false;
const content = (codeBlock.textContent || '').trim(); const content = (codeBlock.textContent || '').trim().toLowerCase();
if (!content) return false; if (!content) return false;
if (extractExternalUrl(content)) return true; return content.includes('<!doctype') || content.includes('<html') || content.includes('<script');
const lower = content.toLowerCase();
return lower.includes('<!doctype') || lower.includes('<html') || lower.includes('<script');
} }
function generateUniqueId() { function generateUniqueId() {
@@ -150,66 +148,6 @@ function buildResourceHints(html) {
return hints + preload; return hints + preload;
} }
function extractExternalUrl(content) {
const trimmed = (content || '').trim();
if (!trimmed) return null;
if (/^https?:\/\/[^\s]+$/i.test(trimmed)) return trimmed;
const match = trimmed.match(/<!--\s*xb-src:\s*(https?:\/\/[^\s>]+)\s*-->/i);
if (match) return match[1];
return null;
}
async function fetchExternalHtml(url) {
try {
const r = await fetch(url, { mode: 'cors' });
if (r.ok) return await r.text();
} catch (_) {}
return null;
}
async function loadExternalUrl(iframe, url, settings) {
try {
iframe.srcdoc = '<!DOCTYPE html><html><body style="display:flex;justify-content:center;align-items:center;height:100px;color:#888;font-family:sans-serif;background:transparent">加载中...</body></html>';
let html = await fetchExternalHtml(url);
if (html && settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') {
try {
html = replaceXbGetVarInString(html);
} catch (e) {
console.warn('xbgetvar 宏替换失败:', e);
}
}
if (html) {
const full = buildWrappedHtml(html);
if (settings.useBlob) {
const codeHash = djb2(html);
setIframeBlobHTML(iframe, full, codeHash);
} else {
iframe.srcdoc = full;
}
setTimeout(() => {
try {
const targetOrigin = getIframeTargetOrigin(iframe);
postToIframe(iframe, { type: 'probe' }, null, targetOrigin);
} catch (e) {}
}, 100);
} else {
iframe.removeAttribute('srcdoc');
iframe.src = url;
iframe.style.minHeight = '800px';
iframe.setAttribute('scrolling', 'auto');
}
} catch (err) {
console.error('[iframeRenderer] 外部URL加载失败:', err);
iframe.removeAttribute('srcdoc');
iframe.src = url;
iframe.style.minHeight = '800px';
iframe.setAttribute('scrolling', 'auto');
}
}
function buildWrappedHtml(html) { function buildWrappedHtml(html) {
const settings = getSettings(); const settings = getSettings();
const wrapperToggle = settings.wrapperIframe ?? true; const wrapperToggle = settings.wrapperIframe ?? true;
@@ -403,7 +341,15 @@ export function renderHtmlInIframe(htmlContent, container, preElement) {
const settings = getSettings(); const settings = getSettings();
try { try {
const originalHash = djb2(htmlContent); const originalHash = djb2(htmlContent);
const externalUrl = extractExternalUrl(htmlContent);
if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') {
try {
htmlContent = replaceXbGetVarInString(htmlContent);
} catch (e) {
console.warn('xbgetvar 宏替换失败:', e);
}
}
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.id = generateUniqueId(); iframe.id = generateUniqueId();
iframe.className = 'xiaobaix-iframe'; iframe.className = 'xiaobaix-iframe';
@@ -419,37 +365,24 @@ export function renderHtmlInIframe(htmlContent, container, preElement) {
old.remove(); old.remove();
}); });
const codeHash = djb2(htmlContent);
const full = buildWrappedHtml(htmlContent);
if (settings.useBlob) {
setIframeBlobHTML(iframe, full, codeHash);
} else {
iframe.srcdoc = full;
}
wrapper.appendChild(iframe); wrapper.appendChild(iframe);
preElement.classList.remove('xb-show'); preElement.classList.remove('xb-show');
preElement.style.display = 'none'; preElement.style.display = 'none';
registerIframeMapping(iframe, wrapper); registerIframeMapping(iframe, wrapper);
if (externalUrl) { try {
loadExternalUrl(iframe, externalUrl, settings); const targetOrigin = getIframeTargetOrigin(iframe);
} else { postToIframe(iframe, { type: 'probe' }, null, targetOrigin);
if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') { } catch (e) {}
try {
htmlContent = replaceXbGetVarInString(htmlContent);
} catch (e) {
console.warn('xbgetvar 宏替换失败:', e);
}
}
const codeHash = djb2(htmlContent);
const full = buildWrappedHtml(htmlContent);
if (settings.useBlob) {
setIframeBlobHTML(iframe, full, codeHash);
} else {
iframe.srcdoc = full;
}
try {
const targetOrigin = getIframeTargetOrigin(iframe);
postToIframe(iframe, { type: 'probe' }, null, targetOrigin);
} catch (e) {}
}
preElement.dataset.xbFinal = 'true'; preElement.dataset.xbFinal = 'true';
preElement.dataset.xbHash = originalHash; preElement.dataset.xbHash = originalHash;
@@ -479,11 +412,10 @@ export function processCodeBlocks(messageElement, forceFinal = true) {
const should = shouldRenderContentByBlock(codeBlock); const should = shouldRenderContentByBlock(codeBlock);
const html = codeBlock.textContent || ''; const html = codeBlock.textContent || '';
const hash = djb2(html); const hash = djb2(html);
const externalUrl = extractExternalUrl(html);
const isFinal = preElement.dataset.xbFinal === 'true'; const isFinal = preElement.dataset.xbFinal === 'true';
const same = preElement.dataset.xbHash === hash; const same = preElement.dataset.xbHash === hash;
if (!externalUrl && isFinal && same) return; if (isFinal && same) return;
if (should) { if (should) {
renderHtmlInIframe(html, preElement.parentNode, preElement); renderHtmlInIframe(html, preElement.parentNode, preElement);

View File

@@ -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,10 +304,35 @@ 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数组输出。`,
u2: v => `### 上下文\n\n**1. 世界观:**\n${worldInfo}\n\n**2. {{user}}经历:**\n${history(v.historyCount)}${v.storyOutline ? `\n\n**剧情大纲:**\n${wrap('story_outline', v.storyOutline)}` : ''}${nameList(v.existingContacts, v.existingStrangers)}\n\n### 输出要求\n\n1. 返回一个合法 JSON 数组,使用标准 JSON 语法(键名和字符串都用半角双引号 "\n2. 只提取有具体称呼的角色\n3. 每个角色只需 name / location / info 三个字段\n4. 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n5. 无新角色返回 []\n\n\n模板:${JSON_TEMPLATES.npc}`, u2: v => `### 上下文\n\n**1. 世界观:**\n${worldInfo}\n\n**2. {{user}}经历:**\n${history(v.historyCount)}${v.storyOutline ? `\n\n**剧情大纲:**\n${wrap('story_outline', v.storyOutline)}` : ''}${nameList(v.existingContacts, v.existingStrangers)}\n\n### 输出要求\n\n1. 返回一个合法 JSON 数组,使用标准 JSON 语法(键名和字符串都用半角双引号 "\n2. 只提取有具体称呼的角色\n3. 每个角色只需 name / location / info 三个字段\n4. 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n5. 无新角色返回 []\n\n\n模板:${JSON_TEMPLATES.stranger}`,
a2: () => `了解开始生成JSON:` a2: () => `了解开始生成JSON:`
}, },
worldGenStep1: { worldGenStep1: {
@@ -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

View File

@@ -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}`);

View File

@@ -12,249 +12,6 @@ const DEFAULT_FILTER_RULES = [
{ start: "```", end: "```" }, { start: "```", end: "```" },
]; ];
export const DEFAULT_SUMMARY_SYSTEM_PROMPT = `Story Analyst: This task involves narrative comprehension and structured incremental summarization, representing creative story analysis at the intersection of plot tracking and character development. As a story analyst, you will conduct systematic evaluation of provided dialogue content to generate structured incremental summary data.
[Read the settings for this task]
<task_settings>
Incremental_Summary_Requirements:
- Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结
- Event_Granularity: 记录有叙事价值的事件,而非剧情梗概
- Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册
- Event_Classification:
type:
- 相遇: 人物/事物初次接触
- 冲突: 对抗、矛盾激化
- 揭示: 真相、秘密、身份
- 抉择: 关键决定
- 羁绊: 关系加深或破裂
- 转变: 角色/局势改变
- 收束: 问题解决、和解
- 日常: 生活片段
weight:
- 核心: 删掉故事就崩
- 主线: 推动主要剧情
- 转折: 改变某条线走向
- 点睛: 有细节不影响主线
- 氛围: 纯粹氛围片段
- Causal_Chain: 为每个新事件标注直接前因事件IDcausedBy。仅在因果关系明确直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个只填 evt-数字,指向已存在或本次新输出事件。
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
- Fact_Tracking: 维护 SPO 三元组知识图谱。追踪生死、物品归属、位置、关系等硬性事实。采用 KV 覆盖模型s+p 为键)。
</task_settings>
---
Story Analyst:
[Responsibility Definition]
\`\`\`yaml
analysis_task:
title: Incremental Story Summarization with Knowledge Graph
Story Analyst:
role: Antigravity
task: >-
To analyze provided dialogue content against existing summary state,
extract only NEW plot elements, character developments, relationship
changes, arc progressions, AND fact updates, outputting
structured JSON for incremental summary database updates.
assistant:
role: Summary Specialist
description: Incremental Story Summary & Knowledge Graph Analyst
behavior: >-
To compare new dialogue against existing summary, identify genuinely
new events and character interactions, classify events by narrative
type and weight, track character arc progression with percentage,
maintain facts as SPO triples with clear semantics,
and output structured JSON containing only incremental updates.
Must strictly avoid repeating any existing summary content.
user:
role: Content Provider
description: Supplies existing summary state and new dialogue
behavior: >-
To provide existing summary state (events, characters, arcs, facts)
and new dialogue content for incremental analysis.
interaction_mode:
type: incremental_analysis
output_format: structured_json
deduplication: strict_enforcement
execution_context:
summary_active: true
incremental_only: true
memory_album_style: true
fact_tracking: true
\`\`\`
---
Summary Specialist:
<Chat_History>`;
export const DEFAULT_MEMORY_PROMPT_TEMPLATE = `以上是还留在眼前的对话
以下是脑海里的记忆:
• [定了的事] 这些是不会变的
• [其他人的事] 别人的经历,当前角色可能不知晓
• 其余部分是过往经历的回忆碎片
请内化这些记忆:
{$剧情记忆}
这些记忆是真实的,请自然地记住它们。`;
export const DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT = `
Summary Specialist:
Acknowledged. Now reviewing the incremental summarization specifications:
[Event Classification System]
├─ Types: 相遇|冲突|揭示|抉择|羁绊|转变|收束|日常
├─ Weights: 核心|主线|转折|点睛|氛围
└─ Each event needs: id, title, timeLabel, summary(含楼层), participants, type, weight
[Relationship Trend Scale]
破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
[Arc Progress Tracking]
├─ trajectory: 当前阶段描述(15字内)
├─ progress: 0.0 to 1.0
└─ newMoment: 仅记录本次新增的关键时刻
[Fact Tracking - SPO / World Facts]
We maintain a small "world state" as SPO triples.
Each update is a JSON object: {s, p, o, isState, trend?, retracted?}
Core rules:
1) Keyed by (s + p). If a new update has the same (s+p), it overwrites the previous value.
2) Only output facts that are NEW or CHANGED in the new dialogue. Do NOT repeat unchanged facts.
3) isState meaning:
- isState: true -> core constraints that must stay stable and should NEVER be auto-deleted
(identity, location, life/death, ownership, relationship status, binding rules)
- isState: false -> non-core facts / soft memories that may be pruned by capacity limits later
4) Relationship facts:
- Use predicate format: "对X的看法" (X is the target person)
- trend is required for relationship facts, one of:
破裂 | 厌恶 | 反感 | 陌生 | 投缘 | 亲密 | 交融
5) Retraction (deletion):
- To delete a fact, output: {s, p, retracted: true}
6) Predicate normalization:
- Reuse existing predicates whenever possible, avoid inventing synonyms.
Ready to process incremental summary requests with strict deduplication.`;
export const DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT = `
Summary Specialist:
Specifications internalized. Please provide the existing summary state so I can:
1. Index all recorded events to avoid duplication
2. Map current character list as baseline
3. Note existing arc progress levels
4. Identify established keywords
5. Review current facts (SPO triples baseline)`;
export const DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT = `
Summary Specialist:
Existing summary fully analyzed and indexed. I understand:
├─ Recorded events: Indexed for deduplication
├─ Character list: Baseline mapped
├─ Arc progress: Levels noted
├─ Keywords: Current state acknowledged
└─ Facts: SPO baseline loaded
I will extract only genuinely NEW elements from the upcoming dialogue.
Please provide the new dialogue content requiring incremental analysis.`;
export const DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT = `
Summary Specialist:
ACKNOWLEDGED. Beginning structured JSON generation:
<meta_protocol>`;
export const DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT = `
## Output Rule
Generate a single valid JSON object with INCREMENTAL updates only.
## Mindful Approach
Before generating, observe the USER and analyze carefully:
- What is user's writing style and emotional expression?
- What NEW events occurred (not in existing summary)?
- What NEW characters appeared for the first time?
- What relationship CHANGES happened?
- What arc PROGRESS was made?
- What facts changed? (status/position/ownership/relationships)
## factUpdates 规则
- 目的: 纠错 & 世界一致性约束,只记录硬性事实
- s+p 为键,相同键会覆盖旧值
- isState: true=核心约束(位置/身份/生死/关系)false=有容量上限会被清理
- 关系类: p="对X的看法"trend 必填(破裂|厌恶|反感|陌生|投缘|亲密|交融)
- 删除: {s, p, retracted: true},不需要 o 字段
- 更新: {s, p, o, isState, trend?}
- 谓词规范化: 复用已有谓词,不要发明同义词
- 只输出有变化的条目,确保少、硬、稳定
## Output Format
\`\`\`json
{
"mindful_prelude": {
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
"dedup_analysis": "已有X个事件本次识别Y个新事件",
"fact_changes": "识别到的事实变化概述"
},
"keywords": [
{"text": "综合历史+新内容的全剧情关键词(5-10个)", "weight": "核心|重要|一般"}
],
"events": [
{
"id": "evt-{$nextEventId}起始,依次递增",
"title": "地点·事件标题",
"timeLabel": "时间线标签(如:开场、第二天晚上)",
"summary": "1-2句话描述涵盖丰富信息素末尾标注楼层(#X-Y)",
"participants": ["参与角色名,不要使用人称代词或别名,只用正式人名"],
"type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常",
"weight": "核心|主线|转折|点睛|氛围",
"causedBy": ["evt-12", "evt-14"]
}
],
"newCharacters": ["仅本次首次出现的角色名"],
"arcUpdates": [
{"name": "角色名,不要使用人称代词或别名,只用正式人名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
],
"factUpdates": [
{"s": "主体", "p": "谓词", "o": "当前值", "isState": true, "trend": "仅关系类填"},
{"s": "要删除的主体", "p": "要删除的谓词", "retracted": true}
]
}
\`\`\`
## CRITICAL NOTES
- events.id 从 evt-{$nextEventId} 开始编号
- 仅输出【增量】内容,已有事件绝不重复
- /地点、通过什么方式、对谁、做了什么事、结果如何。如果原文有具体道具(如一把枪、一封信),必须在总结中提及。
- keywords 是全局关键词,综合已有+新增
- causedBy 仅在因果明确时填写,允许为[]0-2个
- factUpdates 可为空数组
- 合法JSON字符串值内部避免英文双引号
- 用朴实、白描、有烟火气的笔触记录事实,避免比喻和意象
- 严谨、注重细节,避免使用模糊的概括性语言,应用具体的动词描述动作,例:谁,在什么时间/地点,通过什么方式,对谁,做了什么事,出现了什么道具,结果如何。
</meta_protocol>
## Placeholder Notes
- {$nextEventId} 会在运行时替换成实际起始事件编号,不要删除
- {$existingEventCount}、{$historyRange} 这类占位符如果出现在你的自定义版本里,通常也不应该删除`;
export const DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT = `Content review initiated...
[Compliance Check Results]
├─ Existing summary loaded: ✓ Fully indexed
├─ New dialogue received: ✓ Content parsed
├─ Deduplication engine: ✓ Active
├─ Event classification: ✓ Ready
├─ Fact tracking: ✓ Enabled
└─ Output format: ✓ JSON specification loaded
[Material Verification]
├─ Existing events: Indexed ({$existingEventCount} recorded)
├─ Character baseline: Mapped
├─ Arc progress baseline: Noted
├─ Facts baseline: Loaded
└─ Output specification: ✓ Defined in <meta_protocol>
All checks passed. Beginning incremental extraction...
{
"mindful_prelude":`;
export const DEFAULT_SUMMARY_USER_CONFIRM_PROMPT = `怎么截断了重新完整生成只输出JSON不要任何其他内容3000字以内
</Chat_History>`;
export const DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT = '下面重新生成完整JSON。';
export function getSettings() { export function getSettings() {
const ext = (extension_settings[EXT_ID] ||= {}); const ext = (extension_settings[EXT_ID] ||= {});
ext.storySummary ||= { enabled: true }; ext.storySummary ||= { enabled: true };
@@ -287,18 +44,6 @@ export function getSummaryPanelConfig() {
keepVisibleCount: 6, keepVisibleCount: 6,
}, },
textFilterRules: [...DEFAULT_FILTER_RULES], textFilterRules: [...DEFAULT_FILTER_RULES],
prompts: {
summarySystemPrompt: DEFAULT_SUMMARY_SYSTEM_PROMPT,
summaryAssistantDocPrompt: DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT,
summaryAssistantAskSummaryPrompt: DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT,
summaryAssistantAskContentPrompt: DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT,
summaryMetaProtocolStartPrompt: DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT,
summaryUserJsonFormatPrompt: DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT,
summaryAssistantCheckPrompt: DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT,
summaryUserConfirmPrompt: DEFAULT_SUMMARY_USER_CONFIRM_PROMPT,
summaryAssistantPrefillPrompt: DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT,
memoryTemplate: DEFAULT_MEMORY_PROMPT_TEMPLATE,
},
vector: null, vector: null,
}; };
@@ -319,7 +64,6 @@ export function getSummaryPanelConfig() {
trigger: { ...defaults.trigger, ...(parsed.trigger || {}) }, trigger: { ...defaults.trigger, ...(parsed.trigger || {}) },
ui: { ...defaults.ui, ...(parsed.ui || {}) }, ui: { ...defaults.ui, ...(parsed.ui || {}) },
textFilterRules, textFilterRules,
prompts: { ...defaults.prompts, ...(parsed.prompts || {}) },
vector: parsed.vector || null, vector: parsed.vector || null,
}; };

View File

@@ -1,18 +1,5 @@
// LLM Service // LLM Service
import {
getSummaryPanelConfig,
DEFAULT_SUMMARY_SYSTEM_PROMPT,
DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT,
DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT,
DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT,
DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT,
DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT,
DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT,
DEFAULT_SUMMARY_USER_CONFIRM_PROMPT,
DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT,
} from "../data/config.js";
const PROVIDER_MAP = { const PROVIDER_MAP = {
openai: "openai", openai: "openai",
google: "gemini", google: "gemini",
@@ -24,18 +11,237 @@ const PROVIDER_MAP = {
custom: "custom", custom: "custom",
}; };
const JSON_PREFILL = DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT; const JSON_PREFILL = '下面重新生成完整JSON。';
const LLM_PROMPT_CONFIG = { const LLM_PROMPT_CONFIG = {
topSystem: DEFAULT_SUMMARY_SYSTEM_PROMPT, topSystem: `Story Analyst: This task involves narrative comprehension and structured incremental summarization, representing creative story analysis at the intersection of plot tracking and character development. As a story analyst, you will conduct systematic evaluation of provided dialogue content to generate structured incremental summary data.
assistantDoc: DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT, [Read the settings for this task]
assistantAskSummary: DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT, <task_settings>
assistantAskContent: DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT, Incremental_Summary_Requirements:
metaProtocolStart: DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT, - Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结
userJsonFormat: DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT, - Event_Granularity: 记录有叙事价值的事件,而非剧情梗概
assistantCheck: DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT, - Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册
userConfirm: DEFAULT_SUMMARY_USER_CONFIRM_PROMPT, - Event_Classification:
assistantPrefill: DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT, type:
- 相遇: 人物/事物初次接触
- 冲突: 对抗、矛盾激化
- 揭示: 真相、秘密、身份
- 抉择: 关键决定
- 羁绊: 关系加深或破裂
- 转变: 角色/局势改变
- 收束: 问题解决、和解
- 日常: 生活片段
weight:
- 核心: 删掉故事就崩
- 主线: 推动主要剧情
- 转折: 改变某条线走向
- 点睛: 有细节不影响主线
- 氛围: 纯粹氛围片段
- Causal_Chain: 为每个新事件标注直接前因事件IDcausedBy。仅在因果关系明确直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个只填 evt-数字,指向已存在或本次新输出事件。
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
- Fact_Tracking: 维护 SPO 三元组知识图谱。追踪生死、物品归属、位置、关系等硬性事实。采用 KV 覆盖模型s+p 为键)。
</task_settings>
---
Story Analyst:
[Responsibility Definition]
\`\`\`yaml
analysis_task:
title: Incremental Story Summarization with Knowledge Graph
Story Analyst:
role: Antigravity
task: >-
To analyze provided dialogue content against existing summary state,
extract only NEW plot elements, character developments, relationship
changes, arc progressions, AND fact updates, outputting
structured JSON for incremental summary database updates.
assistant:
role: Summary Specialist
description: Incremental Story Summary & Knowledge Graph Analyst
behavior: >-
To compare new dialogue against existing summary, identify genuinely
new events and character interactions, classify events by narrative
type and weight, track character arc progression with percentage,
maintain facts as SPO triples with clear semantics,
and output structured JSON containing only incremental updates.
Must strictly avoid repeating any existing summary content.
user:
role: Content Provider
description: Supplies existing summary state and new dialogue
behavior: >-
To provide existing summary state (events, characters, arcs, facts)
and new dialogue content for incremental analysis.
interaction_mode:
type: incremental_analysis
output_format: structured_json
deduplication: strict_enforcement
execution_context:
summary_active: true
incremental_only: true
memory_album_style: true
fact_tracking: true
\`\`\`
---
Summary Specialist:
<Chat_History>`,
assistantDoc: `
Summary Specialist:
Acknowledged. Now reviewing the incremental summarization specifications:
[Event Classification System]
├─ Types: 相遇|冲突|揭示|抉择|羁绊|转变|收束|日常
├─ Weights: 核心|主线|转折|点睛|氛围
└─ Each event needs: id, title, timeLabel, summary(含楼层), participants, type, weight
[Relationship Trend Scale]
破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
[Arc Progress Tracking]
├─ trajectory: 当前阶段描述(15字内)
├─ progress: 0.0 to 1.0
└─ newMoment: 仅记录本次新增的关键时刻
[Fact Tracking - SPO / World Facts]
We maintain a small "world state" as SPO triples.
Each update is a JSON object: {s, p, o, isState, trend?, retracted?}
Core rules:
1) Keyed by (s + p). If a new update has the same (s+p), it overwrites the previous value.
2) Only output facts that are NEW or CHANGED in the new dialogue. Do NOT repeat unchanged facts.
3) isState meaning:
- isState: true -> core constraints that must stay stable and should NEVER be auto-deleted
(identity, location, life/death, ownership, relationship status, binding rules)
- isState: false -> non-core facts / soft memories that may be pruned by capacity limits later
4) Relationship facts:
- Use predicate format: "对X的看法" (X is the target person)
- trend is required for relationship facts, one of:
破裂 | 厌恶 | 反感 | 陌生 | 投缘 | 亲密 | 交融
5) Retraction (deletion):
- To delete a fact, output: {s, p, retracted: true}
6) Predicate normalization:
- Reuse existing predicates whenever possible, avoid inventing synonyms.
Ready to process incremental summary requests with strict deduplication.`,
assistantAskSummary: `
Summary Specialist:
Specifications internalized. Please provide the existing summary state so I can:
1. Index all recorded events to avoid duplication
2. Map current character list as baseline
3. Note existing arc progress levels
4. Identify established keywords
5. Review current facts (SPO triples baseline)`,
assistantAskContent: `
Summary Specialist:
Existing summary fully analyzed and indexed. I understand:
├─ Recorded events: Indexed for deduplication
├─ Character list: Baseline mapped
├─ Arc progress: Levels noted
├─ Keywords: Current state acknowledged
└─ Facts: SPO baseline loaded
I will extract only genuinely NEW elements from the upcoming dialogue.
Please provide the new dialogue content requiring incremental analysis.`,
metaProtocolStart: `
Summary Specialist:
ACKNOWLEDGED. Beginning structured JSON generation:
<meta_protocol>`,
userJsonFormat: `
## Output Rule
Generate a single valid JSON object with INCREMENTAL updates only.
## Mindful Approach
Before generating, observe the USER and analyze carefully:
- What is user's writing style and emotional expression?
- What NEW events occurred (not in existing summary)?
- What NEW characters appeared for the first time?
- What relationship CHANGES happened?
- What arc PROGRESS was made?
- What facts changed? (status/position/ownership/relationships)
## factUpdates 规则
- 目的: 纠错 & 世界一致性约束,只记录硬性事实
- s+p 为键,相同键会覆盖旧值
- isState: true=核心约束(位置/身份/生死/关系)false=有容量上限会被清理
- 关系类: p="对X的看法"trend 必填(破裂|厌恶|反感|陌生|投缘|亲密|交融)
- 删除: {s, p, retracted: true},不需要 o 字段
- 更新: {s, p, o, isState, trend?}
- 谓词规范化: 复用已有谓词,不要发明同义词
- 只输出有变化的条目,确保少、硬、稳定
## Output Format
\`\`\`json
{
"mindful_prelude": {
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
"dedup_analysis": "已有X个事件本次识别Y个新事件",
"fact_changes": "识别到的事实变化概述"
},
"keywords": [
{"text": "综合历史+新内容的全剧情关键词(5-10个)", "weight": "核心|重要|一般"}
],
"events": [
{
"id": "evt-{nextEventId}起始,依次递增",
"title": "地点·事件标题",
"timeLabel": "时间线标签(如:开场、第二天晚上)",
"summary": "1-2句话描述涵盖丰富信息素末尾标注楼层(#X-Y)",
"participants": ["参与角色名,不要使用人称代词或别名,只用正式人名"],
"type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常",
"weight": "核心|主线|转折|点睛|氛围",
"causedBy": ["evt-12", "evt-14"]
}
],
"newCharacters": ["仅本次首次出现的角色名"],
"arcUpdates": [
{"name": "角色名,不要使用人称代词或别名,只用正式人名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
],
"factUpdates": [
{"s": "主体", "p": "谓词", "o": "当前值", "isState": true, "trend": "仅关系类填"},
{"s": "要删除的主体", "p": "要删除的谓词", "retracted": true}
]
}
\`\`\`
## CRITICAL NOTES
- events.id 从 evt-{nextEventId} 开始编号
- 仅输出【增量】内容,已有事件绝不重复
- /地点、通过什么方式、对谁、做了什么事、结果如何。如果原文有具体道具(如一把枪、一封信),必须在总结中提及。
- keywords 是全局关键词,综合已有+新增
- causedBy 仅在因果明确时填写,允许为[]0-2个
- factUpdates 可为空数组
- 合法JSON字符串值内部避免英文双引号
- 用朴实、白描、有烟火气的笔触记录事实,避免比喻和意象
- 严谨、注重细节,避免使用模糊的概括性语言,应用具体的动词描述动作,例:谁,在什么时间/地点,通过什么方式,对谁,做了什么事,出现了什么道具,结果如何。
</meta_protocol>`,
assistantCheck: `Content review initiated...
[Compliance Check Results]
├─ Existing summary loaded: ✓ Fully indexed
├─ New dialogue received: ✓ Content parsed
├─ Deduplication engine: ✓ Active
├─ Event classification: ✓ Ready
├─ Fact tracking: ✓ Enabled
└─ Output format: ✓ JSON specification loaded
[Material Verification]
├─ Existing events: Indexed ({existingEventCount} recorded)
├─ Character baseline: Mapped
├─ Arc progress baseline: Noted
├─ Facts baseline: Loaded
└─ Output specification: ✓ Defined in <meta_protocol>
All checks passed. Beginning incremental extraction...
{
"mindful_prelude":`,
userConfirm: `怎么截断了重新完整生成只输出JSON不要任何其他内容3000字以内
</Chat_History>`,
assistantPrefill: JSON_PREFILL
}; };
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -92,51 +298,37 @@ function formatFactsForLLM(facts) {
} }
function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, historyRange, nextEventId, existingEventCount) { function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, historyRange, nextEventId, existingEventCount) {
const promptCfg = getSummaryPanelConfig()?.prompts || {};
const summarySystemPrompt = String(promptCfg.summarySystemPrompt || DEFAULT_SUMMARY_SYSTEM_PROMPT).trim() || DEFAULT_SUMMARY_SYSTEM_PROMPT;
const assistantDocPrompt = String(promptCfg.summaryAssistantDocPrompt || DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT).trim() || DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT;
const assistantAskSummaryPrompt = String(promptCfg.summaryAssistantAskSummaryPrompt || DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT).trim() || DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT;
const assistantAskContentPrompt = String(promptCfg.summaryAssistantAskContentPrompt || DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT).trim() || DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT;
const metaProtocolStartPrompt = String(promptCfg.summaryMetaProtocolStartPrompt || DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT).trim() || DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT;
const userJsonFormatPrompt = String(promptCfg.summaryUserJsonFormatPrompt || DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT).trim() || DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT;
const assistantCheckPrompt = String(promptCfg.summaryAssistantCheckPrompt || DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT).trim() || DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT;
const userConfirmPrompt = String(promptCfg.summaryUserConfirmPrompt || DEFAULT_SUMMARY_USER_CONFIRM_PROMPT).trim() || DEFAULT_SUMMARY_USER_CONFIRM_PROMPT;
const assistantPrefillPrompt = String(promptCfg.summaryAssistantPrefillPrompt || DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT).trim() || DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT;
const { text: factsText, predicates } = formatFactsForLLM(existingFacts); const { text: factsText, predicates } = formatFactsForLLM(existingFacts);
const predicatesHint = predicates.length > 0 const predicatesHint = predicates.length > 0
? `\n\n<\u5df2\u6709\u8c13\u8bcd\uff0c\u8bf7\u590d\u7528>\n${predicates.join('\u3001')}\n</\u5df2\u6709\u8c13\u8bcd\uff0c\u8bf7\u590d\u7528>` ? `\n\n<\u5df2\u6709\u8c13\u8bcd\uff0c\u8bf7\u590d\u7528>\n${predicates.join('\u3001')}\n</\u5df2\u6709\u8c13\u8bcd\uff0c\u8bf7\u590d\u7528>`
: ''; : '';
const jsonFormat = userJsonFormatPrompt const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
.replace(/\{\$nextEventId\}/g, String(nextEventId)) .replace(/\{nextEventId\}/g, String(nextEventId));
.replace(/\{nextEventId\}/g, String(nextEventId))
.replace(/\{\$historyRange\}/g, String(historyRange ?? ''))
.replace(/\{historyRange\}/g, String(historyRange ?? ''));
const checkContent = assistantCheckPrompt const checkContent = LLM_PROMPT_CONFIG.assistantCheck
.replace(/\{\$existingEventCount\}/g, String(existingEventCount))
.replace(/\{existingEventCount\}/g, String(existingEventCount)); .replace(/\{existingEventCount\}/g, String(existingEventCount));
const topMessages = [ const topMessages = [
{ role: 'system', content: summarySystemPrompt }, { role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
{ role: 'assistant', content: assistantDocPrompt }, { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
{ role: 'assistant', content: assistantAskSummaryPrompt }, { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
{ role: 'user', content: `<\u5df2\u6709\u603b\u7ed3\u72b6\u6001>\n${existingSummary}\n</\u5df2\u6709\u603b\u7ed3\u72b6\u6001>\n\n<\u5f53\u524d\u4e8b\u5b9e\u56fe\u8c31>\n${factsText}\n</\u5f53\u524d\u4e8b\u5b9e\u56fe\u8c31>${predicatesHint}` }, { role: 'user', content: `<\u5df2\u6709\u603b\u7ed3\u72b6\u6001>\n${existingSummary}\n</\u5df2\u6709\u603b\u7ed3\u72b6\u6001>\n\n<\u5f53\u524d\u4e8b\u5b9e\u56fe\u8c31>\n${factsText}\n</\u5f53\u524d\u4e8b\u5b9e\u56fe\u8c31>${predicatesHint}` },
{ role: 'assistant', content: assistantAskContentPrompt }, { role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
{ role: 'user', content: `<\u65b0\u5bf9\u8bdd\u5185\u5bb9>\uff08${historyRange}\uff09\n${newHistoryText}\n</\u65b0\u5bf9\u8bdd\u5185\u5bb9>` } { role: 'user', content: `<\u65b0\u5bf9\u8bdd\u5185\u5bb9>\uff08${historyRange}\uff09\n${newHistoryText}\n</\u65b0\u5bf9\u8bdd\u5185\u5bb9>` }
]; ];
const bottomMessages = [ const bottomMessages = [
{ role: 'user', content: metaProtocolStartPrompt + '\n' + jsonFormat }, { role: 'user', content: LLM_PROMPT_CONFIG.metaProtocolStart + '\n' + jsonFormat },
{ role: 'assistant', content: checkContent }, { role: 'assistant', content: checkContent },
{ role: 'user', content: userConfirmPrompt } { role: 'user', content: LLM_PROMPT_CONFIG.userConfirm }
]; ];
return { return {
top64: b64UrlEncode(JSON.stringify(topMessages)), top64: b64UrlEncode(JSON.stringify(topMessages)),
bottom64: b64UrlEncode(JSON.stringify(bottomMessages)), bottom64: b64UrlEncode(JSON.stringify(bottomMessages)),
assistantPrefill: assistantPrefillPrompt assistantPrefill: LLM_PROMPT_CONFIG.assistantPrefill
}; };
} }

View File

@@ -15,7 +15,7 @@
import { getContext } from "../../../../../../extensions.js"; import { getContext } from "../../../../../../extensions.js";
import { xbLog } from "../../../core/debug-core.js"; import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js"; import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings, DEFAULT_MEMORY_PROMPT_TEMPLATE } from "../data/config.js"; import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory } from "../vector/retrieval/recall.js"; import { recallMemory } from "../vector/retrieval/recall.js";
import { getMeta } from "../vector/storage/chunk-store.js"; import { getMeta } from "../vector/storage/chunk-store.js";
import { getStateAtoms } from "../vector/storage/state-store.js"; import { getStateAtoms } from "../vector/storage/state-store.js";
@@ -208,15 +208,27 @@ function renumberEventText(text, newIndex) {
* 构建系统前导文本 * 构建系统前导文本
* @returns {string} 前导文本 * @returns {string} 前导文本
*/ */
function buildMemoryPromptText(memoryBody) { function buildSystemPreamble() {
const templateRaw = String( return [
getSummaryPanelConfig()?.prompts?.memoryTemplate || DEFAULT_MEMORY_PROMPT_TEMPLATE "以上是还留在眼前的对话",
); "以下是脑海里的记忆:",
const template = templateRaw.trim() || DEFAULT_MEMORY_PROMPT_TEMPLATE; "• [定了的事] 这些是不会变的",
if (template.includes("{$剧情记忆}")) { "• [其他人的事] 别人的经历,当前角色可能不知晓",
return template.replaceAll("{$剧情记忆}", memoryBody); "• 其余部分是过往经历的回忆碎片",
} "",
return `${template}\n${memoryBody}`; "请内化这些记忆:",
].join("\n");
}
/**
* 构建后缀文本
* @returns {string} 后缀文本
*/
function buildPostscript() {
return [
"",
"这些记忆是真实的,请自然地记住它们。",
].join("\n");
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -1282,8 +1294,10 @@ async function buildVectorPrompt(store, recallResult, causalById, focusCharacter
return { promptText: "", injectionStats, metrics }; return { promptText: "", injectionStats, metrics };
} }
const memoryBody = `<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>`; const promptText =
const promptText = buildMemoryPromptText(memoryBody); `${buildSystemPreamble()}\n` +
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
`${buildPostscript()}`;
if (metrics) { if (metrics) {
metrics.formatting.sectionsIncluded = []; metrics.formatting.sectionsIncluded = [];

View File

@@ -1539,7 +1539,6 @@ h1 {
margin-bottom: 4px; margin-bottom: 4px;
} }
.vector-mismatch-warning { .vector-mismatch-warning {
font-size: .75rem; font-size: .75rem;
color: var(--downloading); color: var(--downloading);

View File

@@ -4,249 +4,6 @@
(function () { (function () {
'use strict'; 'use strict';
const DEFAULT_SUMMARY_SYSTEM_PROMPT = `Story Analyst: This task involves narrative comprehension and structured incremental summarization, representing creative story analysis at the intersection of plot tracking and character development. As a story analyst, you will conduct systematic evaluation of provided dialogue content to generate structured incremental summary data.
[Read the settings for this task]
<task_settings>
Incremental_Summary_Requirements:
- Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结
- Event_Granularity: 记录有叙事价值的事件,而非剧情梗概
- Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册
- Event_Classification:
type:
- 相遇: 人物/事物初次接触
- 冲突: 对抗、矛盾激化
- 揭示: 真相、秘密、身份
- 抉择: 关键决定
- 羁绊: 关系加深或破裂
- 转变: 角色/局势改变
- 收束: 问题解决、和解
- 日常: 生活片段
weight:
- 核心: 删掉故事就崩
- 主线: 推动主要剧情
- 转折: 改变某条线走向
- 点睛: 有细节不影响主线
- 氛围: 纯粹氛围片段
- Causal_Chain: 为每个新事件标注直接前因事件IDcausedBy。仅在因果关系明确直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个只填 evt-数字,指向已存在或本次新输出事件。
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
- Fact_Tracking: 维护 SPO 三元组知识图谱。追踪生死、物品归属、位置、关系等硬性事实。采用 KV 覆盖模型s+p 为键)。
</task_settings>
---
Story Analyst:
[Responsibility Definition]
\`\`\`yaml
analysis_task:
title: Incremental Story Summarization with Knowledge Graph
Story Analyst:
role: Antigravity
task: >-
To analyze provided dialogue content against existing summary state,
extract only NEW plot elements, character developments, relationship
changes, arc progressions, AND fact updates, outputting
structured JSON for incremental summary database updates.
assistant:
role: Summary Specialist
description: Incremental Story Summary & Knowledge Graph Analyst
behavior: >-
To compare new dialogue against existing summary, identify genuinely
new events and character interactions, classify events by narrative
type and weight, track character arc progression with percentage,
maintain facts as SPO triples with clear semantics,
and output structured JSON containing only incremental updates.
Must strictly avoid repeating any existing summary content.
user:
role: Content Provider
description: Supplies existing summary state and new dialogue
behavior: >-
To provide existing summary state (events, characters, arcs, facts)
and new dialogue content for incremental analysis.
interaction_mode:
type: incremental_analysis
output_format: structured_json
deduplication: strict_enforcement
execution_context:
summary_active: true
incremental_only: true
memory_album_style: true
fact_tracking: true
\`\`\`
---
Summary Specialist:
<Chat_History>`;
const DEFAULT_MEMORY_PROMPT_TEMPLATE = `以上是还留在眼前的对话
以下是脑海里的记忆:
• [定了的事] 这些是不会变的
• [其他人的事] 别人的经历,当前角色可能不知晓
• 其余部分是过往经历的回忆碎片
请内化这些记忆:
{$剧情记忆}
这些记忆是真实的,请自然地记住它们。`;
const DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT = `
Summary Specialist:
Acknowledged. Now reviewing the incremental summarization specifications:
[Event Classification System]
├─ Types: 相遇|冲突|揭示|抉择|羁绊|转变|收束|日常
├─ Weights: 核心|主线|转折|点睛|氛围
└─ Each event needs: id, title, timeLabel, summary(含楼层), participants, type, weight
[Relationship Trend Scale]
破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
[Arc Progress Tracking]
├─ trajectory: 当前阶段描述(15字内)
├─ progress: 0.0 to 1.0
└─ newMoment: 仅记录本次新增的关键时刻
[Fact Tracking - SPO / World Facts]
We maintain a small "world state" as SPO triples.
Each update is a JSON object: {s, p, o, isState, trend?, retracted?}
Core rules:
1) Keyed by (s + p). If a new update has the same (s+p), it overwrites the previous value.
2) Only output facts that are NEW or CHANGED in the new dialogue. Do NOT repeat unchanged facts.
3) isState meaning:
- isState: true -> core constraints that must stay stable and should NEVER be auto-deleted
(identity, location, life/death, ownership, relationship status, binding rules)
- isState: false -> non-core facts / soft memories that may be pruned by capacity limits later
4) Relationship facts:
- Use predicate format: "对X的看法" (X is the target person)
- trend is required for relationship facts, one of:
破裂 | 厌恶 | 反感 | 陌生 | 投缘 | 亲密 | 交融
5) Retraction (deletion):
- To delete a fact, output: {s, p, retracted: true}
6) Predicate normalization:
- Reuse existing predicates whenever possible, avoid inventing synonyms.
Ready to process incremental summary requests with strict deduplication.`;
const DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT = `
Summary Specialist:
Specifications internalized. Please provide the existing summary state so I can:
1. Index all recorded events to avoid duplication
2. Map current character list as baseline
3. Note existing arc progress levels
4. Identify established keywords
5. Review current facts (SPO triples baseline)`;
const DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT = `
Summary Specialist:
Existing summary fully analyzed and indexed. I understand:
├─ Recorded events: Indexed for deduplication
├─ Character list: Baseline mapped
├─ Arc progress: Levels noted
├─ Keywords: Current state acknowledged
└─ Facts: SPO baseline loaded
I will extract only genuinely NEW elements from the upcoming dialogue.
Please provide the new dialogue content requiring incremental analysis.`;
const DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT = `
Summary Specialist:
ACKNOWLEDGED. Beginning structured JSON generation:
<meta_protocol>`;
const DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT = `
## Output Rule
Generate a single valid JSON object with INCREMENTAL updates only.
## Mindful Approach
Before generating, observe the USER and analyze carefully:
- What is user's writing style and emotional expression?
- What NEW events occurred (not in existing summary)?
- What NEW characters appeared for the first time?
- What relationship CHANGES happened?
- What arc PROGRESS was made?
- What facts changed? (status/position/ownership/relationships)
## factUpdates 规则
- 目的: 纠错 & 世界一致性约束,只记录硬性事实
- s+p 为键,相同键会覆盖旧值
- isState: true=核心约束(位置/身份/生死/关系)false=有容量上限会被清理
- 关系类: p="对X的看法"trend 必填(破裂|厌恶|反感|陌生|投缘|亲密|交融)
- 删除: {s, p, retracted: true},不需要 o 字段
- 更新: {s, p, o, isState, trend?}
- 谓词规范化: 复用已有谓词,不要发明同义词
- 只输出有变化的条目,确保少、硬、稳定
## Output Format
\`\`\`json
{
"mindful_prelude": {
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
"dedup_analysis": "已有X个事件本次识别Y个新事件",
"fact_changes": "识别到的事实变化概述"
},
"keywords": [
{"text": "综合历史+新内容的全剧情关键词(5-10个)", "weight": "核心|重要|一般"}
],
"events": [
{
"id": "evt-{$nextEventId}起始,依次递增",
"title": "地点·事件标题",
"timeLabel": "时间线标签(如:开场、第二天晚上)",
"summary": "1-2句话描述涵盖丰富信息素末尾标注楼层(#X-Y)",
"participants": ["参与角色名,不要使用人称代词或别名,只用正式人名"],
"type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常",
"weight": "核心|主线|转折|点睛|氛围",
"causedBy": ["evt-12", "evt-14"]
}
],
"newCharacters": ["仅本次首次出现的角色名"],
"arcUpdates": [
{"name": "角色名,不要使用人称代词或别名,只用正式人名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
],
"factUpdates": [
{"s": "主体", "p": "谓词", "o": "当前值", "isState": true, "trend": "仅关系类填"},
{"s": "要删除的主体", "p": "要删除的谓词", "retracted": true}
]
}
\`\`\`
## CRITICAL NOTES
- events.id 从 evt-{$nextEventId} 开始编号
- 仅输出【增量】内容,已有事件绝不重复
- /地点、通过什么方式、对谁、做了什么事、结果如何。如果原文有具体道具(如一把枪、一封信),必须在总结中提及。
- keywords 是全局关键词,综合已有+新增
- causedBy 仅在因果明确时填写,允许为[]0-2个
- factUpdates 可为空数组
- 合法JSON字符串值内部避免英文双引号
- 用朴实、白描、有烟火气的笔触记录事实,避免比喻和意象
- 严谨、注重细节,避免使用模糊的概括性语言,应用具体的动词描述动作,例:谁,在什么时间/地点,通过什么方式,对谁,做了什么事,出现了什么道具,结果如何。
</meta_protocol>
## Placeholder Notes
- {$nextEventId} 会在运行时替换成实际起始事件编号,不要删除
- {$existingEventCount}、{$historyRange} 这类占位符如果出现在你的自定义版本里,通常也不应该删除`;
const DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT = `Content review initiated...
[Compliance Check Results]
├─ Existing summary loaded: ✓ Fully indexed
├─ New dialogue received: ✓ Content parsed
├─ Deduplication engine: ✓ Active
├─ Event classification: ✓ Ready
├─ Fact tracking: ✓ Enabled
└─ Output format: ✓ JSON specification loaded
[Material Verification]
├─ Existing events: Indexed ({$existingEventCount} recorded)
├─ Character baseline: Mapped
├─ Arc progress baseline: Noted
├─ Facts baseline: Loaded
└─ Output specification: ✓ Defined in <meta_protocol>
All checks passed. Beginning incremental extraction...
{
"mindful_prelude":`;
const DEFAULT_SUMMARY_USER_CONFIRM_PROMPT = `怎么截断了重新完整生成只输出JSON不要任何其他内容3000字以内
</Chat_History>`;
const DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT = '下面重新生成完整JSON。';
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// DOM Helpers // DOM Helpers
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -291,11 +48,11 @@ All checks passed. Beginning incremental extraction...
})(); })();
const PROVIDER_DEFAULTS = { const PROVIDER_DEFAULTS = {
st: { url: '', needKey: false, canFetch: false }, st: { url: '', needKey: false, canFetch: false, needManualModel: false },
openai: { url: 'https://api.openai.com', needKey: true, canFetch: true }, openai: { url: 'https://api.openai.com', needKey: true, canFetch: true, needManualModel: false },
google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false }, google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false, needManualModel: true },
claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false }, claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false, needManualModel: true },
custom: { url: '', needKey: true, canFetch: true } custom: { url: '', needKey: true, canFetch: true, needManualModel: false }
}; };
const SECTION_META = { const SECTION_META = {
@@ -331,18 +88,6 @@ All checks passed. Beginning incremental extraction...
gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null }, gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null },
trigger: { enabled: false, interval: 20, timing: 'before_user', role: 'system', useStream: true, maxPerRun: 100, wrapperHead: '', wrapperTail: '', forceInsertAtEnd: false }, trigger: { enabled: false, interval: 20, timing: 'before_user', role: 'system', useStream: true, maxPerRun: 100, wrapperHead: '', wrapperTail: '', forceInsertAtEnd: false },
ui: { hideSummarized: true, keepVisibleCount: 6 }, ui: { hideSummarized: true, keepVisibleCount: 6 },
prompts: {
summarySystemPrompt: '',
summaryAssistantDocPrompt: '',
summaryAssistantAskSummaryPrompt: '',
summaryAssistantAskContentPrompt: '',
summaryMetaProtocolStartPrompt: '',
summaryUserJsonFormatPrompt: '',
summaryAssistantCheckPrompt: '',
summaryUserConfirmPrompt: '',
summaryAssistantPrefillPrompt: '',
memoryTemplate: '',
},
textFilterRules: [...DEFAULT_FILTER_RULES], textFilterRules: [...DEFAULT_FILTER_RULES],
vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } } vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } }
}; };
@@ -359,7 +104,6 @@ All checks passed. Beginning incremental extraction...
let allLinks = []; let allLinks = [];
let activeRelationTooltip = null; let activeRelationTooltip = null;
let lastRecallLogText = ''; let lastRecallLogText = '';
let modelListFetchedThisIframe = false;
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Messaging // Messaging
@@ -379,11 +123,9 @@ All checks passed. Beginning incremental extraction...
if (s) { if (s) {
const p = JSON.parse(s); const p = JSON.parse(s);
Object.assign(config.api, p.api || {}); Object.assign(config.api, p.api || {});
config.api.modelCache = [];
Object.assign(config.gen, p.gen || {}); Object.assign(config.gen, p.gen || {});
Object.assign(config.trigger, p.trigger || {}); Object.assign(config.trigger, p.trigger || {});
Object.assign(config.ui, p.ui || {}); Object.assign(config.ui, p.ui || {});
Object.assign(config.prompts, p.prompts || {});
config.textFilterRules = Array.isArray(p.textFilterRules) config.textFilterRules = Array.isArray(p.textFilterRules)
? p.textFilterRules ? p.textFilterRules
: (Array.isArray(p.vector?.textFilterRules) ? p.vector.textFilterRules : [...DEFAULT_FILTER_RULES]); : (Array.isArray(p.vector?.textFilterRules) ? p.vector.textFilterRules : [...DEFAULT_FILTER_RULES]);
@@ -399,11 +141,9 @@ All checks passed. Beginning incremental extraction...
function applyConfig(cfg) { function applyConfig(cfg) {
if (!cfg) return; if (!cfg) return;
Object.assign(config.api, cfg.api || {}); Object.assign(config.api, cfg.api || {});
config.api.modelCache = [];
Object.assign(config.gen, cfg.gen || {}); Object.assign(config.gen, cfg.gen || {});
Object.assign(config.trigger, cfg.trigger || {}); Object.assign(config.trigger, cfg.trigger || {});
Object.assign(config.ui, cfg.ui || {}); Object.assign(config.ui, cfg.ui || {});
Object.assign(config.prompts, cfg.prompts || {});
config.textFilterRules = Array.isArray(cfg.textFilterRules) config.textFilterRules = Array.isArray(cfg.textFilterRules)
? cfg.textFilterRules ? cfg.textFilterRules
: (Array.isArray(cfg.vector?.textFilterRules) : (Array.isArray(cfg.vector?.textFilterRules)
@@ -536,6 +276,7 @@ All checks passed. Beginning incremental extraction...
el.textContent = count; el.textContent = count;
} }
function updateOnlineStatus(status, message) { function updateOnlineStatus(status, message) {
const dot = $('online-api-status').querySelector('.status-dot'); const dot = $('online-api-status').querySelector('.status-dot');
const text = $('online-api-status').querySelector('.status-text'); const text = $('online-api-status').querySelector('.status-text');
@@ -700,32 +441,6 @@ All checks passed. Beginning incremental extraction...
initAnchorUI(); initAnchorUI();
postMsg('REQUEST_ANCHOR_STATS'); postMsg('REQUEST_ANCHOR_STATS');
} }
function initSummaryIOUI() {
$('btn-copy-summary').onclick = () => {
$('btn-copy-summary').disabled = true;
$('summary-io-status').textContent = '复制中...';
postMsg('SUMMARY_COPY');
};
$('btn-import-summary').onclick = async () => {
const text = await showConfirmInput(
'覆盖导入记忆包',
'导入会覆盖当前聊天已有的总结资料,并立即清空向量、锚点、总结边界。请把记忆包粘贴到下面。',
'继续导入',
'取消',
'在这里粘贴记忆包 JSON'
);
if (text == null) return;
if (!String(text).trim()) {
$('summary-io-status').textContent = '导入失败: 记忆包内容为空';
return;
}
$('btn-import-summary').disabled = true;
$('summary-io-status').textContent = '导入中...';
postMsg('SUMMARY_IMPORT_TEXT', { text });
};
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Settings Modal // Settings Modal
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -733,14 +448,12 @@ All checks passed. Beginning incremental extraction...
function updateProviderUI(provider) { function updateProviderUI(provider) {
const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom; const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom;
const isSt = provider === 'st'; const isSt = provider === 'st';
const hasModelCache = modelListFetchedThisIframe && Array.isArray(config.api.modelCache) && config.api.modelCache.length > 0;
$('api-url-row').classList.toggle('hidden', isSt); $('api-url-row').classList.toggle('hidden', isSt);
$('api-key-row').classList.toggle('hidden', !pv.needKey); $('api-key-row').classList.toggle('hidden', !pv.needKey);
$('api-model-manual-row').classList.toggle('hidden', isSt); $('api-model-manual-row').classList.toggle('hidden', isSt || !pv.needManualModel);
$('api-model-select-row').classList.toggle('hidden', isSt || !hasModelCache); $('api-model-select-row').classList.toggle('hidden', isSt || pv.needManualModel || !config.api.modelCache.length);
$('api-connect-row').classList.toggle('hidden', isSt || !pv.canFetch); $('api-connect-row').classList.toggle('hidden', isSt || !pv.canFetch);
$('api-connect-status').classList.toggle('hidden', isSt || !pv.canFetch);
const urlInput = $('api-url'); const urlInput = $('api-url');
if (!urlInput.value && pv.url) urlInput.value = pv.url; if (!urlInput.value && pv.url) urlInput.value = pv.url;
@@ -765,17 +478,6 @@ All checks passed. Beginning incremental extraction...
$('trigger-wrapper-head').value = config.trigger.wrapperHead || ''; $('trigger-wrapper-head').value = config.trigger.wrapperHead || '';
$('trigger-wrapper-tail').value = config.trigger.wrapperTail || ''; $('trigger-wrapper-tail').value = config.trigger.wrapperTail || '';
$('trigger-insert-at-end').checked = !!config.trigger.forceInsertAtEnd; $('trigger-insert-at-end').checked = !!config.trigger.forceInsertAtEnd;
$('summary-system-prompt').value = config.prompts.summarySystemPrompt || '';
$('summary-assistant-doc-prompt').value = config.prompts.summaryAssistantDocPrompt || '';
$('summary-assistant-ask-summary-prompt').value = config.prompts.summaryAssistantAskSummaryPrompt || '';
$('summary-assistant-ask-content-prompt').value = config.prompts.summaryAssistantAskContentPrompt || '';
$('summary-meta-protocol-start-prompt').value = config.prompts.summaryMetaProtocolStartPrompt || '';
$('summary-user-json-format-prompt').value = config.prompts.summaryUserJsonFormatPrompt || '';
$('summary-assistant-check-prompt').value = config.prompts.summaryAssistantCheckPrompt || '';
$('summary-user-confirm-prompt').value = config.prompts.summaryUserConfirmPrompt || '';
$('summary-assistant-prefill-prompt').value = config.prompts.summaryAssistantPrefillPrompt || '';
$('memory-prompt-template').value = config.prompts.memoryTemplate || '';
$('api-connect-status').textContent = '';
const en = $('trigger-enabled'); const en = $('trigger-enabled');
if (config.trigger.timing === 'manual') { if (config.trigger.timing === 'manual') {
@@ -788,10 +490,9 @@ All checks passed. Beginning incremental extraction...
} }
if (config.api.modelCache.length) { if (config.api.modelCache.length) {
setSelectOptions($('api-model-select'), config.api.modelCache, '请选择'); setHtml($('api-model-select'), config.api.modelCache.map(m =>
$('api-model-select').value = config.api.modelCache.includes(config.api.model) ? config.api.model : ''; `<option value="${m}"${m === config.api.model ? ' selected' : ''}>${m}</option>`
} else { ).join(''));
setSelectOptions($('api-model-select'), [], '请选择');
} }
updateProviderUI(config.api.provider); updateProviderUI(config.api.provider);
@@ -823,12 +524,12 @@ All checks passed. Beginning incremental extraction...
if (save) { if (save) {
const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v); }; const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v); };
const provider = $('api-provider').value; const provider = $('api-provider').value;
const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom;
config.api.provider = provider; config.api.provider = provider;
config.api.url = $('api-url').value; config.api.url = $('api-url').value;
config.api.key = $('api-key').value; config.api.key = $('api-key').value;
config.api.model = provider === 'st' ? '' : $('api-model-text').value.trim(); config.api.model = provider === 'st' ? '' : pv.needManualModel ? $('api-model-text').value : $('api-model-select').value;
config.api.modelCache = [];
config.gen.temperature = pn('gen-temp'); config.gen.temperature = pn('gen-temp');
config.gen.top_p = pn('gen-top-p'); config.gen.top_p = pn('gen-top-p');
@@ -846,16 +547,6 @@ All checks passed. Beginning incremental extraction...
config.trigger.wrapperHead = $('trigger-wrapper-head').value; config.trigger.wrapperHead = $('trigger-wrapper-head').value;
config.trigger.wrapperTail = $('trigger-wrapper-tail').value; config.trigger.wrapperTail = $('trigger-wrapper-tail').value;
config.trigger.forceInsertAtEnd = $('trigger-insert-at-end').checked; config.trigger.forceInsertAtEnd = $('trigger-insert-at-end').checked;
config.prompts.summarySystemPrompt = $('summary-system-prompt').value;
config.prompts.summaryAssistantDocPrompt = $('summary-assistant-doc-prompt').value;
config.prompts.summaryAssistantAskSummaryPrompt = $('summary-assistant-ask-summary-prompt').value;
config.prompts.summaryAssistantAskContentPrompt = $('summary-assistant-ask-content-prompt').value;
config.prompts.summaryMetaProtocolStartPrompt = $('summary-meta-protocol-start-prompt').value;
config.prompts.summaryUserJsonFormatPrompt = $('summary-user-json-format-prompt').value;
config.prompts.summaryAssistantCheckPrompt = $('summary-assistant-check-prompt').value;
config.prompts.summaryUserConfirmPrompt = $('summary-user-confirm-prompt').value;
config.prompts.summaryAssistantPrefillPrompt = $('summary-assistant-prefill-prompt').value;
config.prompts.memoryTemplate = $('memory-prompt-template').value;
config.textFilterRules = collectFilterRules(); config.textFilterRules = collectFilterRules();
config.vector = getVectorConfig(); config.vector = getVectorConfig();
@@ -868,11 +559,10 @@ All checks passed. Beginning incremental extraction...
async function fetchModels() { async function fetchModels() {
const btn = $('btn-connect'); const btn = $('btn-connect');
const statusEl = $('api-connect-status');
const provider = $('api-provider').value; const provider = $('api-provider').value;
if (!PROVIDER_DEFAULTS[provider]?.canFetch) { if (!PROVIDER_DEFAULTS[provider]?.canFetch) {
statusEl.textContent = '当前渠道不支持自动拉取模型'; alert('当前渠道不支持自动拉取模型');
return; return;
} }
@@ -880,13 +570,12 @@ All checks passed. Beginning incremental extraction...
const apiKey = $('api-key').value.trim(); const apiKey = $('api-key').value.trim();
if (!apiKey) { if (!apiKey) {
statusEl.textContent = '请先填写 API KEY'; alert('请先填写 API KEY');
return; return;
} }
btn.disabled = true; btn.disabled = true;
btn.textContent = '连接中...'; btn.textContent = '连接中...';
statusEl.textContent = '连接中...';
try { try {
const tryFetch = async url => { const tryFetch = async url => {
@@ -903,21 +592,21 @@ All checks passed. Beginning incremental extraction...
if (!models?.length) throw new Error('未获取到模型列表'); if (!models?.length) throw new Error('未获取到模型列表');
config.api.modelCache = [...new Set(models)]; config.api.modelCache = [...new Set(models)];
modelListFetchedThisIframe = true; const sel = $('api-model-select');
setSelectOptions($('api-model-select'), config.api.modelCache, '请选择'); setSelectOptions(sel, config.api.modelCache);
$('api-model-select-row').classList.remove('hidden'); $('api-model-select-row').classList.remove('hidden');
if (!config.api.model && models.length) { if (!config.api.model && models.length) {
config.api.model = models[0]; config.api.model = models[0];
$('api-model-text').value = models[0]; sel.value = models[0];
$('api-model-select').value = models[0];
} else if (config.api.model) { } else if (config.api.model) {
$('api-model-select').value = config.api.model; sel.value = config.api.model;
} }
statusEl.textContent = `拉取成功:${models.length} 个模型`; saveConfig();
alert(`成功获取 ${models.length} 个模型`);
} catch (e) { } catch (e) {
statusEl.textContent = '拉取失败:' + (e.message || '请检查 URL 和 KEY'); alert('连接失败:' + (e.message || '请检查 URL 和 KEY'));
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = '连接 / 拉取模型列表'; btn.textContent = '连接 / 拉取模型列表';
@@ -1306,8 +995,6 @@ All checks passed. Beginning incremental extraction...
const modal = $('confirm-modal'); const modal = $('confirm-modal');
const titleEl = $('confirm-title'); const titleEl = $('confirm-title');
const msgEl = $('confirm-message'); const msgEl = $('confirm-message');
const inputWrap = $('confirm-input-wrap');
const inputEl = $('confirm-input');
const okBtn = $('confirm-ok'); const okBtn = $('confirm-ok');
const cancelBtn = $('confirm-cancel'); const cancelBtn = $('confirm-cancel');
const closeBtn = $('confirm-close'); const closeBtn = $('confirm-close');
@@ -1315,8 +1002,6 @@ All checks passed. Beginning incremental extraction...
titleEl.textContent = title; titleEl.textContent = title;
msgEl.textContent = message; msgEl.textContent = message;
inputWrap.classList.add('hidden');
inputEl.value = '';
okBtn.textContent = okText; okBtn.textContent = okText;
cancelBtn.textContent = cancelText; cancelBtn.textContent = cancelText;
@@ -1338,47 +1023,6 @@ All checks passed. Beginning incremental extraction...
}); });
} }
function showConfirmInput(title, message, okText = '执行', cancelText = '取消', placeholder = '') {
return new Promise(resolve => {
const modal = $('confirm-modal');
const titleEl = $('confirm-title');
const msgEl = $('confirm-message');
const inputWrap = $('confirm-input-wrap');
const inputEl = $('confirm-input');
const okBtn = $('confirm-ok');
const cancelBtn = $('confirm-cancel');
const closeBtn = $('confirm-close');
const backdrop = $('confirm-backdrop');
titleEl.textContent = title;
msgEl.textContent = message;
inputWrap.classList.remove('hidden');
inputEl.placeholder = placeholder || '';
inputEl.value = '';
okBtn.textContent = okText;
cancelBtn.textContent = cancelText;
const close = (result) => {
modal.classList.remove('active');
inputWrap.classList.add('hidden');
inputEl.value = '';
okBtn.onclick = null;
cancelBtn.onclick = null;
closeBtn.onclick = null;
backdrop.onclick = null;
resolve(result);
};
okBtn.onclick = () => close(inputEl.value);
cancelBtn.onclick = () => close(null);
closeBtn.onclick = () => close(null);
backdrop.onclick = () => close(null);
modal.classList.add('active');
setTimeout(() => inputEl.focus(), 0);
});
}
function renderArcsEditor(arcs) { function renderArcsEditor(arcs) {
const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }]; const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }];
const es = $('editor-struct'); const es = $('editor-struct');
@@ -1855,27 +1499,6 @@ All checks passed. Beginning incremental extraction...
} }
break; break;
case 'SUMMARY_COPY_RESULT':
$('btn-copy-summary').disabled = false;
if (d.success) {
$('summary-io-status').textContent = `复制成功: ${d.events || 0} 条事件, ${d.facts || 0} 条世界状态`;
} else {
$('summary-io-status').textContent = '复制失败: ' + (d.error || '未知错误');
}
break;
case 'SUMMARY_IMPORT_RESULT':
$('btn-import-summary').disabled = false;
if (d.success) {
const c = d.counts || {};
$('summary-io-status').textContent = `导入成功: ${c.events || 0} 条事件, ${c.facts || 0} 条世界状态,已覆盖当前总结资料并清空向量/锚点,请重新生成向量。`;
postMsg('REQUEST_VECTOR_STATS');
postMsg('REQUEST_ANCHOR_STATS');
} else {
$('summary-io-status').textContent = '导入失败: ' + (d.error || '未知错误');
}
break;
case 'VECTOR_IMPORT_RESULT': case 'VECTOR_IMPORT_RESULT':
$('btn-import-vectors').disabled = false; $('btn-import-vectors').disabled = false;
if (d.success) { if (d.success) {
@@ -1965,34 +1588,12 @@ All checks passed. Beginning incremental extraction...
$('api-provider').onchange = e => { $('api-provider').onchange = e => {
const pv = PROVIDER_DEFAULTS[e.target.value]; const pv = PROVIDER_DEFAULTS[e.target.value];
$('api-url').value = ''; $('api-url').value = '';
modelListFetchedThisIframe = false;
if (!pv.canFetch) config.api.modelCache = []; if (!pv.canFetch) config.api.modelCache = [];
updateProviderUI(e.target.value); updateProviderUI(e.target.value);
}; };
$('btn-connect').onclick = fetchModels; $('btn-connect').onclick = fetchModels;
$('api-model-text').oninput = e => { config.api.model = e.target.value.trim(); }; $('api-model-select').onchange = e => { config.api.model = e.target.value; };
$('api-model-select').onchange = e => {
const value = e.target.value || '';
if (value) {
$('api-model-text').value = value;
config.api.model = value;
}
};
$('btn-reset-summary-prompts').onclick = () => {
$('summary-system-prompt').value = DEFAULT_SUMMARY_SYSTEM_PROMPT;
$('summary-assistant-doc-prompt').value = DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT;
$('summary-assistant-ask-summary-prompt').value = DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT;
$('summary-assistant-ask-content-prompt').value = DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT;
$('summary-meta-protocol-start-prompt').value = DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT;
$('summary-user-json-format-prompt').value = DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT;
$('summary-assistant-check-prompt').value = DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT;
$('summary-user-confirm-prompt').value = DEFAULT_SUMMARY_USER_CONFIRM_PROMPT;
$('summary-assistant-prefill-prompt').value = DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT;
};
$('btn-reset-memory-prompt-template').onclick = () => {
$('memory-prompt-template').value = DEFAULT_MEMORY_PROMPT_TEMPLATE;
};
// Trigger timing // Trigger timing
$('trigger-timing').onchange = e => { $('trigger-timing').onchange = e => {
@@ -2061,7 +1662,6 @@ All checks passed. Beginning incremental extraction...
}; };
// Vector UI // Vector UI
initSummaryIOUI();
initVectorUI(); initVectorUI();
// Gen params collapsible // Gen params collapsible

View File

@@ -1506,7 +1506,6 @@ h1 span {
margin-bottom: 4px; margin-bottom: 4px;
} }
.vector-stats { .vector-stats {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@@ -161,9 +161,8 @@
<div class="modal-box settings-modal-box"> <div class="modal-box settings-modal-box">
<div class="modal-head"> <div class="modal-head">
<div class="settings-tabs"> <div class="settings-tabs">
<div class="settings-tab active" data-tab="tab-summary">总结</div> <div class="settings-tab active" data-tab="tab-summary">总结设置</div>
<div class="settings-tab" data-tab="tab-vector">向量</div> <div class="settings-tab" data-tab="tab-vector">向量设置</div>
<div class="settings-tab" data-tab="tab-prompts">提示词</div>
<div class="settings-tab" data-tab="tab-debug">调试</div> <div class="settings-tab" data-tab="tab-debug">调试</div>
<div class="settings-tab" data-tab="tab-guide">说明</div> <div class="settings-tab" data-tab="tab-guide">说明</div>
</div> </div>
@@ -223,17 +222,16 @@
</div> </div>
<div class="settings-row hidden" id="api-model-manual-row"> <div class="settings-row hidden" id="api-model-manual-row">
<div class="settings-field full"> <div class="settings-field full">
<label>模型</label> <label>模型</label>
<input type="text" id="api-model-text" placeholder="可手动填写,如 cursor/google/gemini-3-flash"> <input type="text" id="api-model-text" placeholder="如 gemini-1.5-pro、claude-3-haiku">
</div> </div>
</div> </div>
<div class="settings-row hidden" id="api-model-select-row"> <div class="settings-row hidden" id="api-model-select-row">
<div class="settings-field full"> <div class="settings-field full">
<label>已拉取模型</label> <label>可用模型</label>
<select id="api-model-select"> <select id="api-model-select">
<option value="">选择</option> <option value="">先拉取模型列表</option>
</select> </select>
<div class="settings-hint">选择后会回填到上面的模型名输入框。原生下拉更稳,不依赖额外样式。</div>
</div> </div>
</div> </div>
<div class="settings-btn-row hidden" id="api-connect-row" <div class="settings-btn-row hidden" id="api-connect-row"
@@ -245,7 +243,6 @@
<span>流式</span> <span>流式</span>
</label> </label>
</div> </div>
<div class="settings-hint hidden" id="api-connect-status"></div>
<!-- Collapsible Gen Params --> <!-- Collapsible Gen Params -->
<div class="settings-collapse"> <div class="settings-collapse">
@@ -386,15 +383,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section" style="padding: 0; margin-top: 16px;">
<div class="settings-section-title">导出与导入</div>
<div class="settings-btn-row" style="margin-top: 8px;">
<button class="btn btn-sm" id="btn-copy-summary" style="flex:1">复制记忆包</button>
<button class="btn btn-sm" id="btn-import-summary" style="flex:1">粘贴导入记忆包</button>
</div>
<div class="settings-hint" id="summary-io-status">复制会把记忆包放进剪贴板;导入会覆盖当前聊天的总结资料,并自动清空向量与总结边界。</div>
</div>
</div> </div>
</div> </div>
@@ -593,75 +581,6 @@
</div> </div>
</div> </div>
<div class="tab-pane" id="tab-prompts">
<div class="settings-section">
<div class="settings-btn-row" style="margin: 0 0 12px 0; align-items: center;">
<div class="settings-section-title" style="margin: 0;">增量总结提示词</div>
<button class="btn btn-sm" id="btn-reset-summary-prompts" style="margin-left:auto;">恢复默认</button>
</div>
<div class="settings-hint" style="margin-bottom: 12px;">这里展示的是一次完整增量总结的各段提示词。像 <code>{$nextEventId}</code><code>{$existingEventCount}</code> 这样的占位符会在运行时自动替换,不要删除。</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-system-prompt" style="min-height: 300px;" placeholder="assistant"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-assistant-doc-prompt" style="min-height: 220px;" placeholder="assistant"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-assistant-ask-summary-prompt" style="min-height: 120px;" placeholder="user"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-assistant-ask-content-prompt" style="min-height: 160px;" placeholder="assistant"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<label>{插入聊天历史记录}</label>
<textarea class="editor-ta" id="summary-meta-protocol-start-prompt" style="min-height: 120px;" placeholder="user"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-user-json-format-prompt" style="min-height: 320px;" placeholder="user"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-assistant-check-prompt" style="min-height: 180px;" placeholder="assistant"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-user-confirm-prompt" style="min-height: 100px;" placeholder="user"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="summary-assistant-prefill-prompt" style="min-height: 80px;" placeholder="assistant"></textarea>
</div>
</div>
</div>
<div class="settings-section">
<div class="settings-btn-row" style="margin: 0 0 12px 0; align-items: center;">
<div class="settings-section-title" style="margin: 0;">记忆注入提示词</div>
<button class="btn btn-sm" id="btn-reset-memory-prompt-template" style="margin-left:auto;">恢复默认</button>
</div>
<div class="settings-row">
<div class="settings-field full">
<textarea class="editor-ta" id="memory-prompt-template" style="min-height: 220px;" placeholder="聊天注入模板"></textarea>
<div class="settings-hint">必须保留 <code>{$剧情记忆}</code> 这个占位符,运行时会替换成实际记忆内容。</div>
</div>
</div>
</div>
</div>
<!-- Tab 3: Debug --> <!-- Tab 3: Debug -->
<div class="tab-pane" id="tab-debug"> <div class="tab-pane" id="tab-debug">
<div class="debug-log-header"> <div class="debug-log-header">
@@ -940,9 +859,6 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="confirm-message" style="margin: 10px 0; line-height: 1.6; color: var(--fg);">内容</div> <div id="confirm-message" style="margin: 10px 0; line-height: 1.6; color: var(--fg);">内容</div>
<div id="confirm-input-wrap" class="hidden" style="margin-top: 12px;">
<textarea class="editor-ta" id="confirm-input" style="min-height: 220px;" placeholder="在这里粘贴记忆包"></textarea>
</div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button class="btn" id="confirm-cancel">取消</button> <button class="btn" id="confirm-cancel">取消</button>

View File

@@ -944,8 +944,10 @@ function initButtonsForAll() {
async function sendSavedConfigToFrame() { async function sendSavedConfigToFrame() {
try { try {
const savedConfig = getSummaryPanelConfig(); const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig }); if (savedConfig) {
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
}
} catch (e) { } catch (e) {
xbLog.warn(MODULE_ID, "加载面板配置失败", e); xbLog.warn(MODULE_ID, "加载面板配置失败", e);
} }
@@ -1029,270 +1031,6 @@ 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. // Compatibility export for ena-planner.
// Returns a compact plain-text snapshot of story-summary memory. // Returns a compact plain-text snapshot of story-summary memory.
export function getStorySummaryForEna() { export function getStorySummaryForEna() {
@@ -1688,43 +1426,6 @@ async function handleFrameMessage(event) {
})(); })();
break; 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": case "VECTOR_IMPORT_PICK":
// 在 parent 创建 file picker避免 iframe 传大文件 // 在 parent 创建 file picker避免 iframe 传大文件
(async () => { (async () => {

View File

@@ -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 };

View File

@@ -177,9 +177,7 @@ class StreamingGeneration {
const provider = String(opts.api || '').toLowerCase(); const provider = String(opts.api || '').toLowerCase();
const reverseProxyConfigured = String(opts.apiurl || '').trim().length > 0; const reverseProxyConfigured = String(opts.apiurl || '').trim().length > 0;
const pwd = String(opts.apipassword || '').trim(); const pwd = String(opts.apipassword || '').trim();
if (pwd && provider === 'custom') { if (!reverseProxyConfigured && pwd) {
await writeSecret(SECRET_KEYS.CUSTOM, pwd, 'xbgen-inline');
} else if (!reverseProxyConfigured && pwd) {
const providerToSecretKey = { const providerToSecretKey = {
openai: SECRET_KEYS.OPENAI, openai: SECRET_KEYS.OPENAI,
gemini: SECRET_KEYS.MAKERSUITE, gemini: SECRET_KEYS.MAKERSUITE,