sync: align local with upstream main
This commit is contained in:
@@ -8,6 +8,7 @@ import { EnaPlannerStorage } from '../../core/server-storage.js';
|
||||
import { postToIframe, isTrustedIframeEvent } from '../../core/iframe-messaging.js';
|
||||
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
|
||||
import { formatOutlinePrompt } from '../story-outline/story-outline.js';
|
||||
import jsyaml from '../../libs/js-yaml.mjs';
|
||||
|
||||
const EXT_NAME = 'ena-planner';
|
||||
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 total = keys.length;
|
||||
if (total === 0) return false;
|
||||
const hit = keys.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0);
|
||||
|
||||
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() {
|
||||
try {
|
||||
if (window.Mvu?.getMvuData) {
|
||||
@@ -858,6 +871,7 @@ async function renderTemplateAll(text, env, messageVars) {
|
||||
out = await evalEjsIfPossible(out, env);
|
||||
out = substituteMacrosViaST(out);
|
||||
out = resolveGetMessageVariableMacros(out, messageVars);
|
||||
out = resolveFormatMessageVariableMacros(out, messageVars);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1133,7 +1147,7 @@ async function buildPlannerMessages(rawUserInput) {
|
||||
const vectorRaw = '';
|
||||
|
||||
// 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 outlineRaw = typeof formatOutlinePrompt === 'function' ? (formatOutlinePrompt() || '') : '';
|
||||
|
||||
@@ -72,11 +72,9 @@ function djb2(str) {
|
||||
|
||||
function shouldRenderContentByBlock(codeBlock) {
|
||||
if (!codeBlock) return false;
|
||||
const content = (codeBlock.textContent || '').trim();
|
||||
const content = (codeBlock.textContent || '').trim().toLowerCase();
|
||||
if (!content) return false;
|
||||
if (extractExternalUrl(content)) return true;
|
||||
const lower = content.toLowerCase();
|
||||
return lower.includes('<!doctype') || lower.includes('<html') || lower.includes('<script');
|
||||
return content.includes('<!doctype') || content.includes('<html') || content.includes('<script');
|
||||
}
|
||||
|
||||
function generateUniqueId() {
|
||||
@@ -150,66 +148,6 @@ function buildResourceHints(html) {
|
||||
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) {
|
||||
const settings = getSettings();
|
||||
const wrapperToggle = settings.wrapperIframe ?? true;
|
||||
@@ -403,7 +341,15 @@ export function renderHtmlInIframe(htmlContent, container, preElement) {
|
||||
const settings = getSettings();
|
||||
try {
|
||||
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');
|
||||
iframe.id = generateUniqueId();
|
||||
iframe.className = 'xiaobaix-iframe';
|
||||
@@ -419,37 +365,24 @@ export function renderHtmlInIframe(htmlContent, container, preElement) {
|
||||
old.remove();
|
||||
});
|
||||
|
||||
const codeHash = djb2(htmlContent);
|
||||
const full = buildWrappedHtml(htmlContent);
|
||||
|
||||
if (settings.useBlob) {
|
||||
setIframeBlobHTML(iframe, full, codeHash);
|
||||
} else {
|
||||
iframe.srcdoc = full;
|
||||
}
|
||||
|
||||
wrapper.appendChild(iframe);
|
||||
preElement.classList.remove('xb-show');
|
||||
preElement.style.display = 'none';
|
||||
registerIframeMapping(iframe, wrapper);
|
||||
|
||||
if (externalUrl) {
|
||||
loadExternalUrl(iframe, externalUrl, settings);
|
||||
} else {
|
||||
if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') {
|
||||
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) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const targetOrigin = getIframeTargetOrigin(iframe);
|
||||
postToIframe(iframe, { type: 'probe' }, null, targetOrigin);
|
||||
} catch (e) {}
|
||||
preElement.dataset.xbFinal = 'true';
|
||||
preElement.dataset.xbHash = originalHash;
|
||||
|
||||
@@ -479,11 +412,10 @@ export function processCodeBlocks(messageElement, forceFinal = true) {
|
||||
const should = shouldRenderContentByBlock(codeBlock);
|
||||
const html = codeBlock.textContent || '';
|
||||
const hash = djb2(html);
|
||||
const externalUrl = extractExternalUrl(html);
|
||||
const isFinal = preElement.dataset.xbFinal === 'true';
|
||||
const same = preElement.dataset.xbHash === hash;
|
||||
|
||||
if (!externalUrl && isFinal && same) return;
|
||||
if (isFinal && same) return;
|
||||
|
||||
if (should) {
|
||||
renderHtmlInIframe(html, preElement.parentNode, preElement);
|
||||
|
||||
@@ -54,6 +54,52 @@ const DEFAULT_JSON_TEMPLATES = {
|
||||
"stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'",
|
||||
"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": "一句话简介" }]`,
|
||||
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}`,
|
||||
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: {
|
||||
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为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:`
|
||||
},
|
||||
worldGenStep1: {
|
||||
@@ -585,6 +656,7 @@ export const buildSmsMessages = v => build('sms', v);
|
||||
export const buildSummaryMessages = v => build('summary', v);
|
||||
export const buildInviteMessages = v => build('invite', v);
|
||||
export const buildNpcGenerationMessages = v => build('npc', v);
|
||||
export const buildImportantNpcGenerationMessages = v => build('importantNpc', v);
|
||||
export const buildExtractStrangersMessages = v => build('stranger', v);
|
||||
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
|
||||
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@ import { StoryOutlineStorage } from "../../core/server-storage.js";
|
||||
import { promptManager } from "../../../../../openai.js";
|
||||
import {
|
||||
buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent,
|
||||
buildNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages,
|
||||
buildNpcGenerationMessages, buildImportantNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages,
|
||||
buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages,
|
||||
buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages,
|
||||
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 }) });
|
||||
}
|
||||
|
||||
async function handleGenNpc({ requestId, strangerName, strangerInfo }) {
|
||||
async function handleGenNpc({ requestId, strangerName, strangerInfo, npcType = 'npc' }) {
|
||||
try {
|
||||
const comm = getCommSettings();
|
||||
const ctx = getContext(), char = ctx.characters?.[ctx.characterId];
|
||||
if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡');
|
||||
const primary = char.data?.extensions?.world;
|
||||
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 });
|
||||
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}`);
|
||||
|
||||
@@ -12,249 +12,6 @@ const DEFAULT_FILTER_RULES = [
|
||||
{ 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: 为每个新事件标注直接前因事件ID(causedBy)。仅在因果关系明确(直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。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() {
|
||||
const ext = (extension_settings[EXT_ID] ||= {});
|
||||
ext.storySummary ||= { enabled: true };
|
||||
@@ -287,18 +44,6 @@ export function getSummaryPanelConfig() {
|
||||
keepVisibleCount: 6,
|
||||
},
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -319,7 +64,6 @@ export function getSummaryPanelConfig() {
|
||||
trigger: { ...defaults.trigger, ...(parsed.trigger || {}) },
|
||||
ui: { ...defaults.ui, ...(parsed.ui || {}) },
|
||||
textFilterRules,
|
||||
prompts: { ...defaults.prompts, ...(parsed.prompts || {}) },
|
||||
vector: parsed.vector || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
// 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 = {
|
||||
openai: "openai",
|
||||
google: "gemini",
|
||||
@@ -24,18 +11,237 @@ const PROVIDER_MAP = {
|
||||
custom: "custom",
|
||||
};
|
||||
|
||||
const JSON_PREFILL = DEFAULT_SUMMARY_ASSISTANT_PREFILL_PROMPT;
|
||||
const JSON_PREFILL = '下面重新生成完整JSON。';
|
||||
|
||||
const LLM_PROMPT_CONFIG = {
|
||||
topSystem: DEFAULT_SUMMARY_SYSTEM_PROMPT,
|
||||
assistantDoc: DEFAULT_SUMMARY_ASSISTANT_DOC_PROMPT,
|
||||
assistantAskSummary: DEFAULT_SUMMARY_ASSISTANT_ASK_SUMMARY_PROMPT,
|
||||
assistantAskContent: DEFAULT_SUMMARY_ASSISTANT_ASK_CONTENT_PROMPT,
|
||||
metaProtocolStart: DEFAULT_SUMMARY_META_PROTOCOL_START_PROMPT,
|
||||
userJsonFormat: DEFAULT_SUMMARY_USER_JSON_FORMAT_PROMPT,
|
||||
assistantCheck: DEFAULT_SUMMARY_ASSISTANT_CHECK_PROMPT,
|
||||
userConfirm: DEFAULT_SUMMARY_USER_CONFIRM_PROMPT,
|
||||
assistantPrefill: DEFAULT_SUMMARY_ASSISTANT_PREFILL_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.
|
||||
[Read the settings for this task]
|
||||
<task_settings>
|
||||
Incremental_Summary_Requirements:
|
||||
- Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结
|
||||
- Event_Granularity: 记录有叙事价值的事件,而非剧情梗概
|
||||
- Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册
|
||||
- Event_Classification:
|
||||
type:
|
||||
- 相遇: 人物/事物初次接触
|
||||
- 冲突: 对抗、矛盾激化
|
||||
- 揭示: 真相、秘密、身份
|
||||
- 抉择: 关键决定
|
||||
- 羁绊: 关系加深或破裂
|
||||
- 转变: 角色/局势改变
|
||||
- 收束: 问题解决、和解
|
||||
- 日常: 生活片段
|
||||
weight:
|
||||
- 核心: 删掉故事就崩
|
||||
- 主线: 推动主要剧情
|
||||
- 转折: 改变某条线走向
|
||||
- 点睛: 有细节不影响主线
|
||||
- 氛围: 纯粹氛围片段
|
||||
- Causal_Chain: 为每个新事件标注直接前因事件ID(causedBy)。仅在因果关系明确(直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。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) {
|
||||
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 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>`
|
||||
: '';
|
||||
|
||||
const jsonFormat = userJsonFormatPrompt
|
||||
.replace(/\{\$nextEventId\}/g, String(nextEventId))
|
||||
.replace(/\{nextEventId\}/g, String(nextEventId))
|
||||
.replace(/\{\$historyRange\}/g, String(historyRange ?? ''))
|
||||
.replace(/\{historyRange\}/g, String(historyRange ?? ''));
|
||||
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
|
||||
.replace(/\{nextEventId\}/g, String(nextEventId));
|
||||
|
||||
const checkContent = assistantCheckPrompt
|
||||
.replace(/\{\$existingEventCount\}/g, String(existingEventCount))
|
||||
const checkContent = LLM_PROMPT_CONFIG.assistantCheck
|
||||
.replace(/\{existingEventCount\}/g, String(existingEventCount));
|
||||
|
||||
const topMessages = [
|
||||
{ role: 'system', content: summarySystemPrompt },
|
||||
{ role: 'assistant', content: assistantDocPrompt },
|
||||
{ role: 'assistant', content: assistantAskSummaryPrompt },
|
||||
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
|
||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
|
||||
{ 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: '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>` }
|
||||
];
|
||||
|
||||
const bottomMessages = [
|
||||
{ role: 'user', content: metaProtocolStartPrompt + '\n' + jsonFormat },
|
||||
{ role: 'user', content: LLM_PROMPT_CONFIG.metaProtocolStart + '\n' + jsonFormat },
|
||||
{ role: 'assistant', content: checkContent },
|
||||
{ role: 'user', content: userConfirmPrompt }
|
||||
{ role: 'user', content: LLM_PROMPT_CONFIG.userConfirm }
|
||||
];
|
||||
|
||||
return {
|
||||
top64: b64UrlEncode(JSON.stringify(topMessages)),
|
||||
bottom64: b64UrlEncode(JSON.stringify(bottomMessages)),
|
||||
assistantPrefill: assistantPrefillPrompt
|
||||
assistantPrefill: LLM_PROMPT_CONFIG.assistantPrefill
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { getContext } from "../../../../../../extensions.js";
|
||||
import { xbLog } from "../../../core/debug-core.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 { getMeta } from "../vector/storage/chunk-store.js";
|
||||
import { getStateAtoms } from "../vector/storage/state-store.js";
|
||||
@@ -208,15 +208,27 @@ function renumberEventText(text, newIndex) {
|
||||
* 构建系统前导文本
|
||||
* @returns {string} 前导文本
|
||||
*/
|
||||
function buildMemoryPromptText(memoryBody) {
|
||||
const templateRaw = String(
|
||||
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}`;
|
||||
function buildSystemPreamble() {
|
||||
return [
|
||||
"以上是还留在眼前的对话",
|
||||
"以下是脑海里的记忆:",
|
||||
"• [定了的事] 这些是不会变的",
|
||||
"• [其他人的事] 别人的经历,当前角色可能不知晓",
|
||||
"• 其余部分是过往经历的回忆碎片",
|
||||
"",
|
||||
"请内化这些记忆:",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建后缀文本
|
||||
* @returns {string} 后缀文本
|
||||
*/
|
||||
function buildPostscript() {
|
||||
return [
|
||||
"",
|
||||
"这些记忆是真实的,请自然地记住它们。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1282,8 +1294,10 @@ async function buildVectorPrompt(store, recallResult, causalById, focusCharacter
|
||||
return { promptText: "", injectionStats, metrics };
|
||||
}
|
||||
|
||||
const memoryBody = `<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>`;
|
||||
const promptText = buildMemoryPromptText(memoryBody);
|
||||
const promptText =
|
||||
`${buildSystemPreamble()}\n` +
|
||||
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
|
||||
`${buildPostscript()}`;
|
||||
|
||||
if (metrics) {
|
||||
metrics.formatting.sectionsIncluded = [];
|
||||
|
||||
@@ -1539,7 +1539,6 @@ h1 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
.vector-mismatch-warning {
|
||||
font-size: .75rem;
|
||||
color: var(--downloading);
|
||||
|
||||
@@ -4,249 +4,6 @@
|
||||
(function () {
|
||||
'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: 为每个新事件标注直接前因事件ID(causedBy)。仅在因果关系明确(直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -291,11 +48,11 @@ All checks passed. Beginning incremental extraction...
|
||||
})();
|
||||
|
||||
const PROVIDER_DEFAULTS = {
|
||||
st: { url: '', needKey: false, canFetch: false },
|
||||
openai: { url: 'https://api.openai.com', needKey: true, canFetch: true },
|
||||
google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false },
|
||||
claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false },
|
||||
custom: { url: '', needKey: true, canFetch: true }
|
||||
st: { url: '', needKey: false, canFetch: false, needManualModel: false },
|
||||
openai: { url: 'https://api.openai.com', needKey: true, canFetch: true, needManualModel: false },
|
||||
google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false, needManualModel: true },
|
||||
claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false, needManualModel: true },
|
||||
custom: { url: '', needKey: true, canFetch: true, needManualModel: false }
|
||||
};
|
||||
|
||||
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 },
|
||||
trigger: { enabled: false, interval: 20, timing: 'before_user', role: 'system', useStream: true, maxPerRun: 100, wrapperHead: '', wrapperTail: '', forceInsertAtEnd: false },
|
||||
ui: { hideSummarized: true, keepVisibleCount: 6 },
|
||||
prompts: {
|
||||
summarySystemPrompt: '',
|
||||
summaryAssistantDocPrompt: '',
|
||||
summaryAssistantAskSummaryPrompt: '',
|
||||
summaryAssistantAskContentPrompt: '',
|
||||
summaryMetaProtocolStartPrompt: '',
|
||||
summaryUserJsonFormatPrompt: '',
|
||||
summaryAssistantCheckPrompt: '',
|
||||
summaryUserConfirmPrompt: '',
|
||||
summaryAssistantPrefillPrompt: '',
|
||||
memoryTemplate: '',
|
||||
},
|
||||
textFilterRules: [...DEFAULT_FILTER_RULES],
|
||||
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 activeRelationTooltip = null;
|
||||
let lastRecallLogText = '';
|
||||
let modelListFetchedThisIframe = false;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Messaging
|
||||
@@ -379,11 +123,9 @@ All checks passed. Beginning incremental extraction...
|
||||
if (s) {
|
||||
const p = JSON.parse(s);
|
||||
Object.assign(config.api, p.api || {});
|
||||
config.api.modelCache = [];
|
||||
Object.assign(config.gen, p.gen || {});
|
||||
Object.assign(config.trigger, p.trigger || {});
|
||||
Object.assign(config.ui, p.ui || {});
|
||||
Object.assign(config.prompts, p.prompts || {});
|
||||
config.textFilterRules = Array.isArray(p.textFilterRules)
|
||||
? p.textFilterRules
|
||||
: (Array.isArray(p.vector?.textFilterRules) ? p.vector.textFilterRules : [...DEFAULT_FILTER_RULES]);
|
||||
@@ -399,11 +141,9 @@ All checks passed. Beginning incremental extraction...
|
||||
function applyConfig(cfg) {
|
||||
if (!cfg) return;
|
||||
Object.assign(config.api, cfg.api || {});
|
||||
config.api.modelCache = [];
|
||||
Object.assign(config.gen, cfg.gen || {});
|
||||
Object.assign(config.trigger, cfg.trigger || {});
|
||||
Object.assign(config.ui, cfg.ui || {});
|
||||
Object.assign(config.prompts, cfg.prompts || {});
|
||||
config.textFilterRules = Array.isArray(cfg.textFilterRules)
|
||||
? cfg.textFilterRules
|
||||
: (Array.isArray(cfg.vector?.textFilterRules)
|
||||
@@ -536,6 +276,7 @@ All checks passed. Beginning incremental extraction...
|
||||
el.textContent = count;
|
||||
}
|
||||
|
||||
|
||||
function updateOnlineStatus(status, message) {
|
||||
const dot = $('online-api-status').querySelector('.status-dot');
|
||||
const text = $('online-api-status').querySelector('.status-text');
|
||||
@@ -700,32 +441,6 @@ All checks passed. Beginning incremental extraction...
|
||||
initAnchorUI();
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -733,14 +448,12 @@ All checks passed. Beginning incremental extraction...
|
||||
function updateProviderUI(provider) {
|
||||
const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom;
|
||||
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-key-row').classList.toggle('hidden', !pv.needKey);
|
||||
$('api-model-manual-row').classList.toggle('hidden', isSt);
|
||||
$('api-model-select-row').classList.toggle('hidden', isSt || !hasModelCache);
|
||||
$('api-model-manual-row').classList.toggle('hidden', isSt || !pv.needManualModel);
|
||||
$('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-status').classList.toggle('hidden', isSt || !pv.canFetch);
|
||||
|
||||
const urlInput = $('api-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-tail').value = config.trigger.wrapperTail || '';
|
||||
$('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');
|
||||
if (config.trigger.timing === 'manual') {
|
||||
@@ -788,10 +490,9 @@ All checks passed. Beginning incremental extraction...
|
||||
}
|
||||
|
||||
if (config.api.modelCache.length) {
|
||||
setSelectOptions($('api-model-select'), config.api.modelCache, '请选择');
|
||||
$('api-model-select').value = config.api.modelCache.includes(config.api.model) ? config.api.model : '';
|
||||
} else {
|
||||
setSelectOptions($('api-model-select'), [], '请选择');
|
||||
setHtml($('api-model-select'), config.api.modelCache.map(m =>
|
||||
`<option value="${m}"${m === config.api.model ? ' selected' : ''}>${m}</option>`
|
||||
).join(''));
|
||||
}
|
||||
|
||||
updateProviderUI(config.api.provider);
|
||||
@@ -823,12 +524,12 @@ All checks passed. Beginning incremental extraction...
|
||||
if (save) {
|
||||
const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v); };
|
||||
const provider = $('api-provider').value;
|
||||
const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom;
|
||||
|
||||
config.api.provider = provider;
|
||||
config.api.url = $('api-url').value;
|
||||
config.api.key = $('api-key').value;
|
||||
config.api.model = provider === 'st' ? '' : $('api-model-text').value.trim();
|
||||
config.api.modelCache = [];
|
||||
config.api.model = provider === 'st' ? '' : pv.needManualModel ? $('api-model-text').value : $('api-model-select').value;
|
||||
|
||||
config.gen.temperature = pn('gen-temp');
|
||||
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.wrapperTail = $('trigger-wrapper-tail').value;
|
||||
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.vector = getVectorConfig();
|
||||
@@ -868,11 +559,10 @@ All checks passed. Beginning incremental extraction...
|
||||
|
||||
async function fetchModels() {
|
||||
const btn = $('btn-connect');
|
||||
const statusEl = $('api-connect-status');
|
||||
const provider = $('api-provider').value;
|
||||
|
||||
if (!PROVIDER_DEFAULTS[provider]?.canFetch) {
|
||||
statusEl.textContent = '当前渠道不支持自动拉取模型';
|
||||
alert('当前渠道不支持自动拉取模型');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -880,13 +570,12 @@ All checks passed. Beginning incremental extraction...
|
||||
const apiKey = $('api-key').value.trim();
|
||||
|
||||
if (!apiKey) {
|
||||
statusEl.textContent = '请先填写 API KEY';
|
||||
alert('请先填写 API KEY');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '连接中...';
|
||||
statusEl.textContent = '连接中...';
|
||||
|
||||
try {
|
||||
const tryFetch = async url => {
|
||||
@@ -903,21 +592,21 @@ All checks passed. Beginning incremental extraction...
|
||||
if (!models?.length) throw new Error('未获取到模型列表');
|
||||
|
||||
config.api.modelCache = [...new Set(models)];
|
||||
modelListFetchedThisIframe = true;
|
||||
setSelectOptions($('api-model-select'), config.api.modelCache, '请选择');
|
||||
const sel = $('api-model-select');
|
||||
setSelectOptions(sel, config.api.modelCache);
|
||||
$('api-model-select-row').classList.remove('hidden');
|
||||
|
||||
if (!config.api.model && models.length) {
|
||||
config.api.model = models[0];
|
||||
$('api-model-text').value = models[0];
|
||||
$('api-model-select').value = models[0];
|
||||
sel.value = models[0];
|
||||
} 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) {
|
||||
statusEl.textContent = '拉取失败:' + (e.message || '请检查 URL 和 KEY');
|
||||
alert('连接失败:' + (e.message || '请检查 URL 和 KEY'));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '连接 / 拉取模型列表';
|
||||
@@ -1306,8 +995,6 @@ All checks passed. Beginning incremental extraction...
|
||||
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');
|
||||
@@ -1315,8 +1002,6 @@ All checks passed. Beginning incremental extraction...
|
||||
|
||||
titleEl.textContent = title;
|
||||
msgEl.textContent = message;
|
||||
inputWrap.classList.add('hidden');
|
||||
inputEl.value = '';
|
||||
okBtn.textContent = okText;
|
||||
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) {
|
||||
const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }];
|
||||
const es = $('editor-struct');
|
||||
@@ -1855,27 +1499,6 @@ All checks passed. Beginning incremental extraction...
|
||||
}
|
||||
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':
|
||||
$('btn-import-vectors').disabled = false;
|
||||
if (d.success) {
|
||||
@@ -1965,34 +1588,12 @@ All checks passed. Beginning incremental extraction...
|
||||
$('api-provider').onchange = e => {
|
||||
const pv = PROVIDER_DEFAULTS[e.target.value];
|
||||
$('api-url').value = '';
|
||||
modelListFetchedThisIframe = false;
|
||||
if (!pv.canFetch) config.api.modelCache = [];
|
||||
updateProviderUI(e.target.value);
|
||||
};
|
||||
|
||||
$('btn-connect').onclick = fetchModels;
|
||||
$('api-model-text').oninput = e => { config.api.model = e.target.value.trim(); };
|
||||
$('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;
|
||||
};
|
||||
$('api-model-select').onchange = e => { config.api.model = e.target.value; };
|
||||
|
||||
// Trigger timing
|
||||
$('trigger-timing').onchange = e => {
|
||||
@@ -2061,7 +1662,6 @@ All checks passed. Beginning incremental extraction...
|
||||
};
|
||||
|
||||
// Vector UI
|
||||
initSummaryIOUI();
|
||||
initVectorUI();
|
||||
|
||||
// Gen params collapsible
|
||||
|
||||
@@ -1506,7 +1506,6 @@ h1 span {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
.vector-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -161,9 +161,8 @@
|
||||
<div class="modal-box settings-modal-box">
|
||||
<div class="modal-head">
|
||||
<div class="settings-tabs">
|
||||
<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-prompts">提示词</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-debug">调试</div>
|
||||
<div class="settings-tab" data-tab="tab-guide">说明</div>
|
||||
</div>
|
||||
@@ -223,17 +222,16 @@
|
||||
</div>
|
||||
<div class="settings-row hidden" id="api-model-manual-row">
|
||||
<div class="settings-field full">
|
||||
<label>模型名</label>
|
||||
<input type="text" id="api-model-text" placeholder="可手动填写,如 cursor/google/gemini-3-flash">
|
||||
<label>模型</label>
|
||||
<input type="text" id="api-model-text" placeholder="如 gemini-1.5-pro、claude-3-haiku">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row hidden" id="api-model-select-row">
|
||||
<div class="settings-field full">
|
||||
<label>已拉取模型</label>
|
||||
<label>可用模型</label>
|
||||
<select id="api-model-select">
|
||||
<option value="">请选择</option>
|
||||
<option value="">请先拉取模型列表</option>
|
||||
</select>
|
||||
<div class="settings-hint">选择后会回填到上面的模型名输入框。原生下拉更稳,不依赖额外样式。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-btn-row hidden" id="api-connect-row"
|
||||
@@ -245,7 +243,6 @@
|
||||
<span>流式</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-hint hidden" id="api-connect-status"></div>
|
||||
|
||||
<!-- Collapsible Gen Params -->
|
||||
<div class="settings-collapse">
|
||||
@@ -386,15 +383,6 @@
|
||||
</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>
|
||||
|
||||
@@ -593,75 +581,6 @@
|
||||
</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 -->
|
||||
<div class="tab-pane" id="tab-debug">
|
||||
<div class="debug-log-header">
|
||||
@@ -940,9 +859,6 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<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 class="modal-foot">
|
||||
<button class="btn" id="confirm-cancel">取消</button>
|
||||
|
||||
@@ -944,8 +944,10 @@ function initButtonsForAll() {
|
||||
|
||||
async function sendSavedConfigToFrame() {
|
||||
try {
|
||||
const savedConfig = getSummaryPanelConfig();
|
||||
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
|
||||
const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
|
||||
if (savedConfig) {
|
||||
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
|
||||
}
|
||||
} catch (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.
|
||||
// Returns a compact plain-text snapshot of story-summary memory.
|
||||
export function getStorySummaryForEna() {
|
||||
@@ -1688,43 +1426,6 @@ async function handleFrameMessage(event) {
|
||||
})();
|
||||
break;
|
||||
|
||||
case "SUMMARY_COPY":
|
||||
(async () => {
|
||||
try {
|
||||
const store = getSummaryStore();
|
||||
const payload = buildSummaryExportPackage(store);
|
||||
await copyTextToClipboard(JSON.stringify(payload, null, 2));
|
||||
postToFrame({
|
||||
type: "SUMMARY_COPY_RESULT",
|
||||
success: true,
|
||||
events: payload.counts.events,
|
||||
facts: payload.counts.facts,
|
||||
});
|
||||
} catch (e) {
|
||||
postToFrame({ type: "SUMMARY_COPY_RESULT", success: false, error: e.message });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
|
||||
case "SUMMARY_IMPORT_TEXT":
|
||||
if (guard.isAnyRunning('summary', 'vector', 'anchor')) {
|
||||
postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: "请等待当前总结/向量任务结束" });
|
||||
break;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const result = await importSummaryMemoryPackage(data.text || "");
|
||||
postToFrame({
|
||||
type: "SUMMARY_IMPORT_RESULT",
|
||||
success: true,
|
||||
counts: result.counts,
|
||||
});
|
||||
} catch (e) {
|
||||
postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: e.message });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
|
||||
case "VECTOR_IMPORT_PICK":
|
||||
// 在 parent 创建 file picker,避免 iframe 传大文件
|
||||
(async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { zipSync, unzipSync, strToU8, strFromU8 } from '../../../../libs/fflate.mjs';
|
||||
import { getContext } from '../../../../../../../extensions.js';
|
||||
import { getRequestHeaders } from '../../../../../../../../script.js';
|
||||
import { xbLog } from '../../../../core/debug-core.js';
|
||||
import {
|
||||
getMeta,
|
||||
@@ -72,6 +73,37 @@ function downloadBlob(blob, filename) {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
// 二进制 Uint8Array → base64(分块处理,避免 btoa 栈溢出)
|
||||
function uint8ToBase64(uint8) {
|
||||
const CHUNK = 0x8000;
|
||||
let result = '';
|
||||
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||
result += String.fromCharCode.apply(null, uint8.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(result);
|
||||
}
|
||||
|
||||
// base64 → Uint8Array
|
||||
function base64ToUint8(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// 服务器备份文件名
|
||||
function getBackupFilename(chatId) {
|
||||
// chatId 可能含中文/特殊字符,ST 只接受 [a-zA-Z0-9_-]
|
||||
// 用简单 hash 生成安全文件名
|
||||
let hash = 0;
|
||||
for (let i = 0; i < chatId.length; i++) {
|
||||
hash = ((hash << 5) - hash + chatId.charCodeAt(i)) | 0;
|
||||
}
|
||||
const safe = (hash >>> 0).toString(36);
|
||||
return `LWB_VectorBackup_${safe}.zip`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 导出
|
||||
@@ -383,3 +415,465 @@ export async function importVectors(file, onProgress) {
|
||||
fingerprintMismatch,
|
||||
};
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 备份到服务器
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function backupToServer(onProgress) {
|
||||
const { chatId } = getContext();
|
||||
if (!chatId) {
|
||||
throw new Error('未打开聊天');
|
||||
}
|
||||
|
||||
onProgress?.('读取数据...');
|
||||
|
||||
const meta = await getMeta(chatId);
|
||||
const chunks = await getAllChunks(chatId);
|
||||
const chunkVectors = await getAllChunkVectors(chatId);
|
||||
const eventVectors = await getAllEventVectors(chatId);
|
||||
const stateAtoms = getStateAtoms();
|
||||
const stateVectors = await getAllStateVectors(chatId);
|
||||
|
||||
if (chunkVectors.length === 0 && eventVectors.length === 0 && stateVectors.length === 0) {
|
||||
throw new Error('没有可备份的向量数据');
|
||||
}
|
||||
|
||||
const dims = chunkVectors[0]?.vector?.length
|
||||
|| eventVectors[0]?.vector?.length
|
||||
|| stateVectors[0]?.vector?.length
|
||||
|| 0;
|
||||
if (dims === 0) {
|
||||
throw new Error('无法确定向量维度');
|
||||
}
|
||||
|
||||
onProgress?.('构建索引...');
|
||||
|
||||
const sortedChunks = [...chunks].sort((a, b) => a.chunkId.localeCompare(b.chunkId));
|
||||
const chunkVectorMap = new Map(chunkVectors.map(cv => [cv.chunkId, cv.vector]));
|
||||
|
||||
const chunksJsonl = sortedChunks.map(c => JSON.stringify({
|
||||
chunkId: c.chunkId,
|
||||
floor: c.floor,
|
||||
chunkIdx: c.chunkIdx,
|
||||
speaker: c.speaker,
|
||||
isUser: c.isUser,
|
||||
text: c.text,
|
||||
textHash: c.textHash,
|
||||
})).join('\n');
|
||||
|
||||
const chunkVectorsOrdered = sortedChunks.map(c => chunkVectorMap.get(c.chunkId) || new Array(dims).fill(0));
|
||||
|
||||
onProgress?.('压缩向量...');
|
||||
|
||||
const sortedEventVectors = [...eventVectors].sort((a, b) => a.eventId.localeCompare(b.eventId));
|
||||
const eventsJsonl = sortedEventVectors.map(ev => JSON.stringify({
|
||||
eventId: ev.eventId,
|
||||
})).join('\n');
|
||||
const eventVectorsOrdered = sortedEventVectors.map(ev => ev.vector);
|
||||
|
||||
const sortedStateVectors = [...stateVectors].sort((a, b) => String(a.atomId).localeCompare(String(b.atomId)));
|
||||
const stateVectorsOrdered = sortedStateVectors.map(v => v.vector);
|
||||
const rDims = sortedStateVectors.find(v => v.rVector?.length)?.rVector?.length || dims;
|
||||
const stateRVectorsOrdered = sortedStateVectors.map(v =>
|
||||
v.rVector?.length ? v.rVector : new Array(rDims).fill(0)
|
||||
);
|
||||
const stateVectorsJsonl = sortedStateVectors.map(v => JSON.stringify({
|
||||
atomId: v.atomId,
|
||||
floor: v.floor,
|
||||
hasRVector: !!(v.rVector?.length),
|
||||
rDims: v.rVector?.length || 0,
|
||||
})).join('\n');
|
||||
|
||||
const manifest = {
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: Date.now(),
|
||||
chatId,
|
||||
fingerprint: meta.fingerprint || '',
|
||||
dims,
|
||||
chunkCount: sortedChunks.length,
|
||||
chunkVectorCount: chunkVectors.length,
|
||||
eventCount: sortedEventVectors.length,
|
||||
stateAtomCount: stateAtoms.length,
|
||||
stateVectorCount: stateVectors.length,
|
||||
stateRVectorCount: sortedStateVectors.filter(v => v.rVector?.length).length,
|
||||
rDims,
|
||||
lastChunkFloor: meta.lastChunkFloor ?? -1,
|
||||
};
|
||||
|
||||
onProgress?.('打包文件...');
|
||||
|
||||
const zipData = zipSync({
|
||||
'manifest.json': strToU8(JSON.stringify(manifest, null, 2)),
|
||||
'chunks.jsonl': strToU8(chunksJsonl),
|
||||
'chunk_vectors.bin': float32ToBytes(chunkVectorsOrdered, dims),
|
||||
'events.jsonl': strToU8(eventsJsonl),
|
||||
'event_vectors.bin': float32ToBytes(eventVectorsOrdered, dims),
|
||||
'state_atoms.json': strToU8(JSON.stringify(stateAtoms)),
|
||||
'state_vectors.jsonl': strToU8(stateVectorsJsonl),
|
||||
'state_vectors.bin': stateVectorsOrdered.length
|
||||
? float32ToBytes(stateVectorsOrdered, dims)
|
||||
: new Uint8Array(0),
|
||||
'state_r_vectors.bin': stateRVectorsOrdered.length
|
||||
? float32ToBytes(stateRVectorsOrdered, rDims)
|
||||
: new Uint8Array(0),
|
||||
}, { level: 1 });
|
||||
|
||||
onProgress?.('上传到服务器...');
|
||||
|
||||
const base64 = uint8ToBase64(zipData);
|
||||
const filename = getBackupFilename(chatId);
|
||||
|
||||
const res = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: filename, data: base64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`服务器返回 ${res.status}`);
|
||||
}
|
||||
|
||||
// 新增:安全读取 path 字段
|
||||
let uploadedPath = null;
|
||||
try {
|
||||
const resJson = await res.json();
|
||||
if (typeof resJson?.path === 'string') uploadedPath = resJson.path;
|
||||
} catch (_) { /* JSON 解析失败时 uploadedPath 保持 null */ }
|
||||
|
||||
// 新增:写清单(独立 try/catch,失败不影响原有备份返回)
|
||||
try {
|
||||
await upsertManifestEntry({
|
||||
filename,
|
||||
serverPath: uploadedPath,
|
||||
size: zipData.byteLength,
|
||||
chatId,
|
||||
backupTime: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
xbLog.warn(MODULE_ID, `清单写入失败(不影响备份结果): ${e.message}`);
|
||||
}
|
||||
|
||||
const sizeMB = (zipData.byteLength / 1024 / 1024).toFixed(2);
|
||||
xbLog.info(MODULE_ID, `备份完成: ${filename} (${sizeMB}MB)`);
|
||||
|
||||
return {
|
||||
filename,
|
||||
size: zipData.byteLength,
|
||||
chunkCount: sortedChunks.length,
|
||||
eventCount: sortedEventVectors.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 从服务器恢复
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function restoreFromServer(onProgress) {
|
||||
const { chatId } = getContext();
|
||||
if (!chatId) {
|
||||
throw new Error('未打开聊天');
|
||||
}
|
||||
|
||||
onProgress?.('从服务器下载...');
|
||||
|
||||
const filename = getBackupFilename(chatId);
|
||||
const res = await fetch(`/user/files/${filename}`, {
|
||||
headers: getRequestHeaders(),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error('服务器上没有找到此聊天的备份');
|
||||
}
|
||||
throw new Error(`服务器返回 ${res.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
||||
throw new Error('服务器上没有找到此聊天的备份');
|
||||
}
|
||||
|
||||
onProgress?.('解压文件...');
|
||||
|
||||
const zipData = new Uint8Array(arrayBuffer);
|
||||
|
||||
let unzipped;
|
||||
try {
|
||||
unzipped = unzipSync(zipData);
|
||||
} catch (e) {
|
||||
throw new Error('备份文件格式错误,无法解压');
|
||||
}
|
||||
|
||||
if (!unzipped['manifest.json']) {
|
||||
throw new Error('缺少 manifest.json');
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(strFromU8(unzipped['manifest.json']));
|
||||
|
||||
if (![1, 2].includes(manifest.version)) {
|
||||
throw new Error(`不支持的版本: ${manifest.version}`);
|
||||
}
|
||||
|
||||
onProgress?.('校验数据...');
|
||||
|
||||
const vectorCfg = getVectorConfig();
|
||||
const currentFingerprint = vectorCfg ? getEngineFingerprint(vectorCfg) : '';
|
||||
const fingerprintMismatch = manifest.fingerprint && currentFingerprint && manifest.fingerprint !== currentFingerprint;
|
||||
const chatIdMismatch = manifest.chatId !== chatId;
|
||||
|
||||
const warnings = [];
|
||||
if (fingerprintMismatch) {
|
||||
warnings.push(`向量引擎不匹配(文件: ${manifest.fingerprint}, 当前: ${currentFingerprint}),导入后需重新生成`);
|
||||
}
|
||||
if (chatIdMismatch) {
|
||||
warnings.push(`聊天ID不匹配(文件: ${manifest.chatId}, 当前: ${chatId})`);
|
||||
}
|
||||
|
||||
onProgress?.('解析数据...');
|
||||
|
||||
const chunksJsonl = unzipped['chunks.jsonl'] ? strFromU8(unzipped['chunks.jsonl']) : '';
|
||||
const chunkMetas = chunksJsonl.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
|
||||
const chunkVectorsBytes = unzipped['chunk_vectors.bin'];
|
||||
const chunkVectors = chunkVectorsBytes ? bytesToFloat32(chunkVectorsBytes, manifest.dims) : [];
|
||||
|
||||
const eventsJsonl = unzipped['events.jsonl'] ? strFromU8(unzipped['events.jsonl']) : '';
|
||||
const eventMetas = eventsJsonl.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
|
||||
const eventVectorsBytes = unzipped['event_vectors.bin'];
|
||||
const eventVectors = eventVectorsBytes ? bytesToFloat32(eventVectorsBytes, manifest.dims) : [];
|
||||
|
||||
const stateAtoms = unzipped['state_atoms.json']
|
||||
? JSON.parse(strFromU8(unzipped['state_atoms.json']))
|
||||
: [];
|
||||
|
||||
const stateVectorsJsonl = unzipped['state_vectors.jsonl'] ? strFromU8(unzipped['state_vectors.jsonl']) : '';
|
||||
const stateVectorMetas = stateVectorsJsonl.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
|
||||
const stateVectorsBytes = unzipped['state_vectors.bin'];
|
||||
const stateVectors = (stateVectorsBytes && stateVectorMetas.length)
|
||||
? bytesToFloat32(stateVectorsBytes, manifest.dims)
|
||||
: [];
|
||||
const stateRVectorsBytes = unzipped['state_r_vectors.bin'];
|
||||
const stateRVectors = (stateRVectorsBytes && stateVectorMetas.length)
|
||||
? bytesToFloat32(stateRVectorsBytes, manifest.rDims || manifest.dims)
|
||||
: [];
|
||||
const hasRVectorMeta = stateVectorMetas.some(m => typeof m.hasRVector === 'boolean');
|
||||
|
||||
if (chunkMetas.length !== chunkVectors.length) {
|
||||
throw new Error(`chunk 数量不匹配: 元数据 ${chunkMetas.length}, 向量 ${chunkVectors.length}`);
|
||||
}
|
||||
if (eventMetas.length !== eventVectors.length) {
|
||||
throw new Error(`event 数量不匹配: 元数据 ${eventMetas.length}, 向量 ${eventVectors.length}`);
|
||||
}
|
||||
if (stateVectorMetas.length !== stateVectors.length) {
|
||||
throw new Error(`state 向量数量不匹配: 元数据 ${stateVectorMetas.length}, 向量 ${stateVectors.length}`);
|
||||
}
|
||||
if (stateRVectors.length > 0 && stateVectorMetas.length !== stateRVectors.length) {
|
||||
throw new Error(`state r-vector count mismatch: meta=${stateVectorMetas.length}, vectors=${stateRVectors.length}`);
|
||||
}
|
||||
|
||||
onProgress?.('清空旧数据...');
|
||||
|
||||
await clearAllChunks(chatId);
|
||||
await clearEventVectors(chatId);
|
||||
await clearStateVectors(chatId);
|
||||
clearStateAtoms();
|
||||
|
||||
onProgress?.('写入数据...');
|
||||
|
||||
if (chunkMetas.length > 0) {
|
||||
const chunksToSave = chunkMetas.map(meta => ({
|
||||
chunkId: meta.chunkId,
|
||||
floor: meta.floor,
|
||||
chunkIdx: meta.chunkIdx,
|
||||
speaker: meta.speaker,
|
||||
isUser: meta.isUser,
|
||||
text: meta.text,
|
||||
textHash: meta.textHash,
|
||||
}));
|
||||
await saveChunks(chatId, chunksToSave);
|
||||
|
||||
const chunkVectorItems = chunkMetas.map((meta, idx) => ({
|
||||
chunkId: meta.chunkId,
|
||||
vector: chunkVectors[idx],
|
||||
}));
|
||||
await saveChunkVectors(chatId, chunkVectorItems, manifest.fingerprint);
|
||||
}
|
||||
|
||||
if (eventMetas.length > 0) {
|
||||
const eventVectorItems = eventMetas.map((meta, idx) => ({
|
||||
eventId: meta.eventId,
|
||||
vector: eventVectors[idx],
|
||||
}));
|
||||
await saveEventVectors(chatId, eventVectorItems, manifest.fingerprint);
|
||||
}
|
||||
|
||||
if (stateAtoms.length > 0) {
|
||||
saveStateAtoms(stateAtoms);
|
||||
}
|
||||
|
||||
if (stateVectorMetas.length > 0) {
|
||||
const stateVectorItems = stateVectorMetas.map((meta, idx) => ({
|
||||
atomId: meta.atomId,
|
||||
floor: meta.floor,
|
||||
vector: stateVectors[idx],
|
||||
rVector: (stateRVectors[idx] && (!hasRVectorMeta || meta.hasRVector)) ? stateRVectors[idx] : null,
|
||||
}));
|
||||
await saveStateVectors(chatId, stateVectorItems, manifest.fingerprint);
|
||||
}
|
||||
|
||||
await updateMeta(chatId, {
|
||||
fingerprint: manifest.fingerprint,
|
||||
lastChunkFloor: manifest.lastChunkFloor,
|
||||
});
|
||||
|
||||
xbLog.info(MODULE_ID, `从服务器恢复完成: ${chunkMetas.length} chunks, ${eventMetas.length} events, ${stateAtoms.length} state atoms`);
|
||||
|
||||
return {
|
||||
chunkCount: chunkMetas.length,
|
||||
eventCount: eventMetas.length,
|
||||
warnings,
|
||||
fingerprintMismatch,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 备份清单管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const BACKUP_MANIFEST = 'LWB_BackupManifest.json';
|
||||
|
||||
// 宽容解析:非数组/JSON 失败/字段异常时清洗,不抛错
|
||||
async function fetchManifest() {
|
||||
try {
|
||||
const res = await fetch(`/user/files/${BACKUP_MANIFEST}`, {
|
||||
headers: getRequestHeaders(),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const raw = await res.json();
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(normalizeManifestEntry).filter(Boolean);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 标准化单条条目字段,非法 filename 直接丢弃,其余字段降级
|
||||
function normalizeManifestEntry(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const filename = typeof raw.filename === 'string' ? raw.filename : null;
|
||||
if (!filename || !/^LWB_VectorBackup_[a-z0-9]+\.zip$/.test(filename)) return null;
|
||||
const rawPath = typeof raw.serverPath === 'string' ? raw.serverPath.replace(/^\/+/, '') : null;
|
||||
return {
|
||||
filename,
|
||||
serverPath: rawPath,
|
||||
size: typeof raw.size === 'number' ? raw.size : null,
|
||||
chatId: typeof raw.chatId === 'string' ? raw.chatId : null,
|
||||
backupTime: typeof raw.backupTime === 'string' ? raw.backupTime : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 安全推导/校验 serverPath:缺失时推导,与 filename 不一致时拒绝
|
||||
function buildSafeServerPath(filename, serverPath) {
|
||||
const expected = `user/files/${filename}`;
|
||||
if (!serverPath) return expected;
|
||||
const normalized = serverPath.replace(/^\/+/, '');
|
||||
if (normalized !== expected) {
|
||||
throw new Error(`serverPath 不安全: ${serverPath}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// 读-改(upsert by filename)-写回-验证,失败最多重试 2 次
|
||||
async function upsertManifestEntry({ filename, serverPath, size, chatId, backupTime }) {
|
||||
if (typeof serverPath === 'string') serverPath = serverPath.replace(/^\/+/, '');
|
||||
const MAX_RETRIES = 3;
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
// 读取现有清单
|
||||
const existing = await fetchManifest();
|
||||
|
||||
// upsert by filename
|
||||
const idx = existing.findIndex(e => e.filename === filename);
|
||||
const entry = { filename, serverPath, size, chatId, backupTime };
|
||||
if (idx >= 0) {
|
||||
existing[idx] = entry;
|
||||
} else {
|
||||
existing.push(entry);
|
||||
}
|
||||
|
||||
// 上传清单
|
||||
const json = JSON.stringify(existing, null, 2);
|
||||
const base64 = uint8ToBase64(new TextEncoder().encode(json));
|
||||
const res = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: BACKUP_MANIFEST, data: base64 }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`清单上传失败: ${res.status}`);
|
||||
|
||||
// 写后立即重读验证
|
||||
const verified = await fetchManifest();
|
||||
if (verified.some(e => e.filename === filename)) return;
|
||||
|
||||
// 最后一次仍失败才抛出
|
||||
if (attempt === MAX_RETRIES - 1) {
|
||||
throw new Error('清单写入后验证失败,重试已耗尽');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除前校验 + POST /api/files/delete + 更新清单
|
||||
async function deleteServerBackup(filename, serverPath) {
|
||||
// 安全校验
|
||||
if (!/^LWB_VectorBackup_[a-z0-9]+\.zip$/.test(filename)) {
|
||||
throw new Error(`非法文件名: ${filename}`);
|
||||
}
|
||||
const safePath = buildSafeServerPath(filename, serverPath || null);
|
||||
|
||||
// 物理删除
|
||||
const res = await fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ path: safePath }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = new Error(`删除失败: ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.method = 'DELETE';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 更新清单(删除条目)
|
||||
try {
|
||||
const existing = await fetchManifest();
|
||||
const filtered = existing.filter(e => e.filename !== filename);
|
||||
const json = JSON.stringify(filtered, null, 2);
|
||||
const base64 = uint8ToBase64(new TextEncoder().encode(json));
|
||||
const upRes = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: BACKUP_MANIFEST, data: base64 }),
|
||||
});
|
||||
if (!upRes.ok) {
|
||||
throw new Error('zip 已删除,但清单更新失败,请手动刷新');
|
||||
}
|
||||
} catch (e) {
|
||||
// zip 删成功但清单更新失败 → 抛"部分成功"错误
|
||||
const partialErr = new Error(e.message || 'zip 已删除,清单同步失败');
|
||||
partialErr.partial = true;
|
||||
throw partialErr;
|
||||
}
|
||||
}
|
||||
|
||||
// 集中判断 404/405/method not allowed/unsupported
|
||||
function isDeleteUnsupportedError(err) {
|
||||
if (!err) return false;
|
||||
const status = err.status;
|
||||
if (status === 404 || status === 405) return true;
|
||||
const msg = String(err.message || '').toLowerCase();
|
||||
return msg.includes('method not allowed') || msg.includes('unsupported') || msg.includes('not found');
|
||||
}
|
||||
|
||||
export { fetchManifest, deleteServerBackup, isDeleteUnsupportedError, getBackupFilename };
|
||||
|
||||
@@ -177,9 +177,7 @@ class StreamingGeneration {
|
||||
const provider = String(opts.api || '').toLowerCase();
|
||||
const reverseProxyConfigured = String(opts.apiurl || '').trim().length > 0;
|
||||
const pwd = String(opts.apipassword || '').trim();
|
||||
if (pwd && provider === 'custom') {
|
||||
await writeSecret(SECRET_KEYS.CUSTOM, pwd, 'xbgen-inline');
|
||||
} else if (!reverseProxyConfigured && pwd) {
|
||||
if (!reverseProxyConfigured && pwd) {
|
||||
const providerToSecretKey = {
|
||||
openai: SECRET_KEYS.OPENAI,
|
||||
gemini: SECRET_KEYS.MAKERSUITE,
|
||||
|
||||
Reference in New Issue
Block a user