Sync local version

This commit is contained in:
2026-01-26 01:16:35 +08:00
parent 3ad32da21a
commit c1202c2ca2
27 changed files with 16595 additions and 2369 deletions

View File

@@ -0,0 +1,208 @@
// Story Summary - Generator
// 调用 LLM 生成总结
import { getContext } from "../../../../../../extensions.js";
import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData } from "../data/store.js";
import { generateSummary, parseSummaryJson } from "./llm.js";
const MODULE_ID = 'summaryGenerator';
const SUMMARY_SESSION_ID = 'xb9';
// ═══════════════════════════════════════════════════════════════════════════
// worldUpdate 清洗
// ═══════════════════════════════════════════════════════════════════════════
function sanitizeWorldUpdate(parsed) {
if (!parsed) return;
const wu = Array.isArray(parsed.worldUpdate) ? parsed.worldUpdate : [];
const ok = [];
for (const item of wu) {
const category = String(item?.category || '').trim().toLowerCase();
const topic = String(item?.topic || '').trim();
if (!category || !topic) continue;
// status/knowledge/relation 必须包含 "::"
if (['status', 'knowledge', 'relation'].includes(category) && !topic.includes('::')) {
xbLog.warn(MODULE_ID, `丢弃不合格 worldUpdate: ${category}/${topic}`);
continue;
}
if (item.cleared === true) {
ok.push({ category, topic, cleared: true });
continue;
}
const content = String(item?.content || '').trim();
if (!content) continue;
ok.push({ category, topic, content });
}
parsed.worldUpdate = ok;
}
// ═══════════════════════════════════════════════════════════════════════════
// 辅助函数
// ═══════════════════════════════════════════════════════════════════════════
export function formatExistingSummaryForAI(store) {
if (!store?.json) return "(空白,这是首次总结)";
const data = store.json;
const parts = [];
if (data.events?.length) {
parts.push("【已记录事件】");
data.events.forEach((ev, i) => parts.push(`${i + 1}. [${ev.timeLabel}] ${ev.title}${ev.summary}`));
}
if (data.characters?.main?.length) {
const names = data.characters.main.map(m => typeof m === 'string' ? m : m.name);
parts.push(`\n【主要角色】${names.join("、")}`);
}
if (data.characters?.relationships?.length) {
parts.push("【人物关系】");
data.characters.relationships.forEach(r => parts.push(`- ${r.from}${r.to}${r.label}${r.trend}`));
}
if (data.arcs?.length) {
parts.push("【角色弧光】");
data.arcs.forEach(a => parts.push(`- ${a.name}${a.trajectory}(进度${Math.round(a.progress * 100)}%`));
}
if (data.keywords?.length) {
parts.push(`\n【关键词】${data.keywords.map(k => k.text).join("、")}`);
}
return parts.join("\n") || "(空白,这是首次总结)";
}
export function getNextEventId(store) {
const events = store?.json?.events || [];
if (!events.length) return 1;
const maxId = Math.max(...events.map(e => {
const match = e.id?.match(/evt-(\d+)/);
return match ? parseInt(match[1]) : 0;
}));
return maxId + 1;
}
export function buildIncrementalSlice(targetMesId, lastSummarizedMesId, maxPerRun = 100) {
const { chat, name1, name2 } = getContext();
const start = Math.max(0, (lastSummarizedMesId ?? -1) + 1);
const rawEnd = Math.min(targetMesId, chat.length - 1);
const end = Math.min(rawEnd, start + maxPerRun - 1);
if (start > end) return { text: "", count: 0, range: "", endMesId: -1 };
const userLabel = name1 || '用户';
const charLabel = name2 || '角色';
const slice = chat.slice(start, end + 1);
const text = slice.map((m, i) => {
const speaker = m.name || (m.is_user ? userLabel : charLabel);
return `#${start + i + 1}${speaker}\n${m.mes}`;
}).join('\n\n');
return { text, count: slice.length, range: `${start + 1}-${end + 1}`, endMesId: end };
}
// ═══════════════════════════════════════════════════════════════════════════
// 主生成函数
// ═══════════════════════════════════════════════════════════════════════════
export async function runSummaryGeneration(mesId, config, callbacks = {}) {
const { onStatus, onError, onComplete } = callbacks;
const store = getSummaryStore();
const lastSummarized = store?.lastSummarizedMesId ?? -1;
const maxPerRun = config.trigger?.maxPerRun || 100;
const slice = buildIncrementalSlice(mesId, lastSummarized, maxPerRun);
if (slice.count === 0) {
onStatus?.("没有新的对话需要总结");
return { success: true, noContent: true };
}
onStatus?.(`正在总结 ${slice.range}${slice.count}楼新内容)...`);
const existingSummary = formatExistingSummaryForAI(store);
const existingWorld = store?.json?.world || [];
const nextEventId = getNextEventId(store);
const existingEventCount = store?.json?.events?.length || 0;
const useStream = config.trigger?.useStream !== false;
let raw;
try {
raw = await generateSummary({
existingSummary,
existingWorld,
newHistoryText: slice.text,
historyRange: slice.range,
nextEventId,
existingEventCount,
llmApi: {
provider: config.api?.provider,
url: config.api?.url,
key: config.api?.key,
model: config.api?.model,
},
genParams: config.gen || {},
useStream,
timeout: 120000,
sessionId: SUMMARY_SESSION_ID,
});
} catch (err) {
xbLog.error(MODULE_ID, '生成失败', err);
onError?.(err?.message || "生成失败");
return { success: false, error: err };
}
if (!raw?.trim()) {
xbLog.error(MODULE_ID, 'AI返回为空');
onError?.("AI返回为空");
return { success: false, error: "empty" };
}
const parsed = parseSummaryJson(raw);
if (!parsed) {
xbLog.error(MODULE_ID, 'JSON解析失败');
onError?.("AI未返回有效JSON");
return { success: false, error: "parse" };
}
sanitizeWorldUpdate(parsed);
const merged = mergeNewData(store?.json || {}, parsed, slice.endMesId);
store.lastSummarizedMesId = slice.endMesId;
store.json = merged;
store.updatedAt = Date.now();
addSummarySnapshot(store, slice.endMesId);
saveSummaryStore();
xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1}`);
if (parsed.worldUpdate?.length) {
xbLog.info(MODULE_ID, `世界状态更新: ${parsed.worldUpdate.length}`);
}
const newEventIds = (parsed.events || []).map(e => e.id);
onComplete?.({
merged,
endMesId: slice.endMesId,
newEventIds,
l3Stats: { worldUpdate: parsed.worldUpdate?.length || 0 },
});
return { success: true, merged, endMesId: slice.endMesId, newEventIds };
}

View File

@@ -0,0 +1,444 @@
// LLM Service
const PROVIDER_MAP = {
openai: "openai",
google: "gemini",
gemini: "gemini",
claude: "claude",
anthropic: "claude",
deepseek: "deepseek",
cohere: "cohere",
custom: "custom",
};
const LLM_PROMPT_CONFIG = {
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:
- 核心: 删掉故事就崩
- 主线: 推动主要剧情
- 转折: 改变某条线走向
- 点睛: 有细节不影响主线
- 氛围: 纯粹氛围片段
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
- World_State_Tracking: 维护当前世界的硬性约束。解决"什么不能违反"。采用 KV 覆盖模型,追踪生死、物品归属、秘密知情、关系状态、环境规则等不可违背的事实。(覆盖式更新)
categories:
- status: 角色生死、位置锁定、重大状态
- inventory: 重要物品归属
- knowledge: 秘密的知情状态
- relation: 硬性关系(在一起/决裂)
- rule: 环境规则/契约限制
</task_settings>
---
Story Analyst:
[Responsibility Definition]
\`\`\`yaml
analysis_task:
title: Incremental Story Summarization with World State
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 world state changes, outputting
structured JSON for incremental summary database updates.
assistant:
role: Summary Specialist
description: Incremental Story Summary & World State 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 world state as key-value updates with clear flags,
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, relationships,
arcs, world state) 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
world_state_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: 完整弧光链描述(30字内)
├─ progress: 0.0 to 1.0
└─ newMoment: 仅记录本次新增的关键时刻
[World State Maintenance]
├─ 维护方式: Key-Value 覆盖category + topic 为键)
├─ 只输出有变化的条目
├─ 清除时使用 cleared: true不要填 content
└─ 不记录情绪、衣着、临时动作
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 relationships as baseline
3. Note existing arc progress levels
4. Identify established keywords
5. Review current world state (category + topic baseline)`,
assistantAskContent: `
Summary Specialist:
Existing summary fully analyzed and indexed. I understand:
├─ Recorded events: Indexed for deduplication
├─ Character relationships: Baseline mapped
├─ Arc progress: Levels noted
├─ Keywords: Current state acknowledged
└─ World state: 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 world state changes occurred? (status/inventory/knowledge/relation/rule)
## Output Format
\`\`\`json
{
"mindful_prelude": {
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
"dedup_analysis": "已有X个事件本次识别Y个新事件",
"world_changes": "识别到的世界状态变化概述,仅精选不记录则可能导致吃书的硬状态变化"
},
"keywords": [
{"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
],
"events": [
{
"id": "evt-{nextEventId}起始,依次递增",
"title": "地点·事件标题",
"timeLabel": "时间线标签(如:开场、第二天晚上)",
"summary": "1-2句话描述涵盖丰富信息素末尾标注楼层(#X-Y)",
"participants": ["参与角色名"],
"type": "相遇|冲突|揭示|抉择|羁绊|转变|收束|日常",
"weight": "核心|主线|转折|点睛|氛围"
}
],
"newCharacters": ["仅本次首次出现的角色名"],
"newRelationships": [
{"from": "A", "to": "B", "label": "基于全局的关系描述", "trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融"}
],
"arcUpdates": [
{"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
],
"worldUpdate": [
{
"category": "status|inventory|knowledge|relation|rule",
"topic": "主体名称(人/物/关系/规则)",
"content": "当前状态描述",
"cleared": true
}
]
}
\`\`\`
## Field Guidelines
### worldUpdate世界状态·硬约束KV表
- category 固定 5 选 1status / inventory / knowledge / relation / rule
- topic 命名规范:
- status「角色名::状态类型」如 张三::生死、李四::位置、王五::伤势
- knowledge「角色名::知情事项」如 张三::知道某秘密、李四::知道真相
- relation「角色A::与角色B关系」如 张三::与李四关系
- inventory物品名称如 钥匙、信物、武器
- rule规则/契约名称,如 门禁时间、魔法契约、禁令
- content当前状态的简短描述
- cleared: true 表示该条目已失效需删除(不填 content
- status/knowledge/relation 的 topic 必须包含「::」分隔符
- 硬约束才记录,避免叙事化,确保少、硬、稳定、可覆盖
## CRITICAL NOTES
- events.id 从 evt-{nextEventId} 开始编号
- 仅输出【增量】内容,已有事件绝不重复
- keywords 是全局关键词,综合已有+新增
- worldUpdate 可为空数组
- 合法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
├─ World state tracking: ✓ Enabled
└─ Output format: ✓ JSON specification loaded
[Material Verification]
├─ Existing events: Indexed ({existingEventCount} recorded)
├─ Character baseline: Mapped
├─ Relationship baseline: Mapped
├─ Arc progress baseline: Noted
├─ World state: Baseline loaded
└─ Output specification: ✓ Defined in <meta_protocol>
All checks passed. Beginning incremental extraction...
{
"mindful_prelude":`,
userConfirm: `怎么截断了重新完整生成只输出JSON不要任何其他内容
</Chat_History>`,
assistantPrefill: `非常抱歉现在重新完整生成JSON。`
};
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
function b64UrlEncode(str) {
const utf8 = new TextEncoder().encode(String(str));
let bin = '';
utf8.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function getStreamingModule() {
const mod = window.xiaobaixStreamingGeneration;
return mod?.xbgenrawCommand ? mod : null;
}
function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
return new Promise((resolve, reject) => {
const start = Date.now();
const poll = () => {
const { isStreaming, text } = streamingMod.getStatus(sessionId);
if (!isStreaming) return resolve(text || '');
if (Date.now() - start > timeout) return reject(new Error('生成超时'));
setTimeout(poll, 300);
};
poll();
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 提示词构建
// ═══════════════════════════════════════════════════════════════════════════
function formatWorldForLLM(worldList) {
if (!worldList?.length) {
return '(空白,尚无世界状态记录)';
}
const grouped = { status: [], inventory: [], knowledge: [], relation: [], rule: [] };
const labels = {
status: '状态(生死/位置锁定)',
inventory: '物品归属',
knowledge: '秘密/认知',
relation: '关系状态',
rule: '规则/约束'
};
worldList.forEach(w => {
if (grouped[w.category]) {
grouped[w.category].push(w);
}
});
const parts = [];
for (const [cat, items] of Object.entries(grouped)) {
if (items.length > 0) {
const lines = items.map(w => ` - ${w.topic}: ${w.content}`).join('\n');
parts.push(`${labels[cat]}\n${lines}`);
}
}
return parts.join('\n\n') || '(空白,尚无世界状态记录)';
}
function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, historyRange, nextEventId, existingEventCount) {
const worldStateText = formatWorldForLLM(existingWorld);
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
.replace(/\{nextEventId\}/g, String(nextEventId));
const checkContent = LLM_PROMPT_CONFIG.assistantCheck
.replace(/\{existingEventCount\}/g, String(existingEventCount));
const topMessages = [
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前世界状态>\n${worldStateText}\n</当前世界状态>` },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
{ role: 'user', content: `<新对话内容>${historyRange}\n${newHistoryText}\n</新对话内容>` }
];
const bottomMessages = [
{ role: 'user', content: LLM_PROMPT_CONFIG.metaProtocolStart + '\n' + jsonFormat },
{ role: 'assistant', content: checkContent },
{ role: 'user', content: LLM_PROMPT_CONFIG.userConfirm }
];
return {
top64: b64UrlEncode(JSON.stringify(topMessages)),
bottom64: b64UrlEncode(JSON.stringify(bottomMessages)),
assistantPrefill: LLM_PROMPT_CONFIG.assistantPrefill
};
}
// ═══════════════════════════════════════════════════════════════════════════
// JSON 解析
// ═══════════════════════════════════════════════════════════════════════════
export function parseSummaryJson(raw) {
if (!raw) return null;
let cleaned = String(raw).trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
try {
return JSON.parse(cleaned);
} catch { }
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start !== -1 && end > start) {
let jsonStr = cleaned.slice(start, end + 1)
.replace(/,(\s*[}\]])/g, '$1');
try {
return JSON.parse(jsonStr);
} catch { }
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// 主生成函数
// ═══════════════════════════════════════════════════════════════════════════
export async function generateSummary(options) {
const {
existingSummary,
existingWorld,
newHistoryText,
historyRange,
nextEventId,
existingEventCount = 0,
llmApi = {},
genParams = {},
useStream = true,
timeout = 120000,
sessionId = 'xb_summary'
} = options;
if (!newHistoryText?.trim()) {
throw new Error('新对话内容为空');
}
const streamingMod = getStreamingModule();
if (!streamingMod) {
throw new Error('生成模块未加载');
}
const promptData = buildSummaryMessages(
existingSummary,
existingWorld,
newHistoryText,
historyRange,
nextEventId,
existingEventCount
);
const args = {
as: 'user',
nonstream: useStream ? 'false' : 'true',
top64: promptData.top64,
bottom64: promptData.bottom64,
bottomassistant: promptData.assistantPrefill,
id: sessionId,
};
if (llmApi.provider && llmApi.provider !== 'st') {
const mappedApi = PROVIDER_MAP[String(llmApi.provider).toLowerCase()];
if (mappedApi) {
args.api = mappedApi;
if (llmApi.url) args.apiurl = llmApi.url;
if (llmApi.key) args.apipassword = llmApi.key;
if (llmApi.model) args.model = llmApi.model;
}
}
if (genParams.temperature != null) args.temperature = genParams.temperature;
if (genParams.top_p != null) args.top_p = genParams.top_p;
if (genParams.top_k != null) args.top_k = genParams.top_k;
if (genParams.presence_penalty != null) args.presence_penalty = genParams.presence_penalty;
if (genParams.frequency_penalty != null) args.frequency_penalty = genParams.frequency_penalty;
let rawOutput;
if (useStream) {
const sid = await streamingMod.xbgenrawCommand(args, '');
rawOutput = await waitForStreamingComplete(sid, streamingMod, timeout);
} else {
rawOutput = await streamingMod.xbgenrawCommand(args, '');
}
console.group('%c[Story-Summary] LLM输出', 'color: #7c3aed; font-weight: bold');
console.log(rawOutput);
console.groupEnd();
return rawOutput;
}

View File

@@ -0,0 +1,394 @@
// Story Summary - Prompt Injection
// 注入格式:世界状态 → 亲身经历(含闪回) → 相关背景(含闪回) → 记忆碎片 → 人物弧光
import { getContext } from "../../../../../../extensions.js";
import { extension_prompts, extension_prompt_types, extension_prompt_roles } from "../../../../../../../script.js";
import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory, buildQueryText } from "../vector/recall.js";
const MODULE_ID = "summaryPrompt";
const SUMMARY_PROMPT_KEY = "LittleWhiteBox_StorySummary";
const BUDGET = { total: 10000, l3Max: 2000, l2Max: 5000 };
const MAX_CHUNKS_PER_EVENT = 2;
const MAX_ORPHAN_CHUNKS = 6;
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
function estimateTokens(text) {
if (!text) return 0;
const s = String(text);
const zh = (s.match(/[\u4e00-\u9fff]/g) || []).length;
return Math.ceil(zh + (s.length - zh) / 4);
}
function pushWithBudget(lines, text, state) {
const t = estimateTokens(text);
if (state.used + t > state.max) return false;
lines.push(text);
state.used += t;
return true;
}
// 从 summary 解析楼层范围:(#321-322) 或 (#321)
function parseFloorRange(summary) {
if (!summary) return null;
// 匹配 (#123-456) 或 (#123)
const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/);
if (!match) return null;
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : start;
return { start, end };
}
// 去掉 summary 末尾的楼层标记
function cleanSummary(summary) {
return String(summary || '').replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '').trim();
}
// ═══════════════════════════════════════════════════════════════════════════
// L1 → L2 归属
// ═══════════════════════════════════════════════════════════════════════════
function attachChunksToEvents(events, chunks) {
const usedChunkIds = new Set();
// 给每个 event 挂载 chunks
for (const e of events) {
e._chunks = [];
const range = parseFloorRange(e.event?.summary);
if (!range) continue;
for (const c of chunks) {
if (c.floor >= range.start && c.floor <= range.end) {
if (!usedChunkIds.has(c.chunkId)) {
e._chunks.push(c);
usedChunkIds.add(c.chunkId);
}
}
}
// 每个事件最多保留 N 条,按相似度排序
e._chunks.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
e._chunks = e._chunks.slice(0, MAX_CHUNKS_PER_EVENT);
}
// 找出无归属的 chunks记忆碎片
const orphans = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.sort((a, b) => (b.similarity || 0) - (a.similarity || 0))
.slice(0, MAX_ORPHAN_CHUNKS);
return { events, orphans };
}
// ═══════════════════════════════════════════════════════════════════════════
// 格式化函数
// ═══════════════════════════════════════════════════════════════════════════
function formatWorldLines(world) {
return [...(world || [])]
.sort((a, b) => (b.floor || 0) - (a.floor || 0))
.map(w => `- ${w.topic}${w.content}`);
}
function formatChunkLine(c) {
const text = String(c.text || '');
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
return ` #${c.floor} ${preview}`;
}
function formatEventBlock(e, idx) {
const ev = e.event || {};
const time = ev.timeLabel || '';
const people = (ev.participants || []).join(' / ');
const summary = cleanSummary(ev.summary);
const lines = [];
// 标题行
const header = time ? `${idx}.【${time}${people}` : `${idx}. ${people}`;
lines.push(header);
// 摘要
lines.push(` ${summary}`);
// 挂载的闪回
for (const c of (e._chunks || [])) {
lines.push(` ${formatChunkLine(c)}`);
}
return lines.join('\n');
}
function formatArcLine(a) {
const moments = (a.moments || [])
.map(m => typeof m === 'string' ? m : m.text)
.filter(Boolean);
if (moments.length) {
return `- ${a.name}${moments.join(' → ')}(当前:${a.trajectory}`;
}
return `- ${a.name}${a.trajectory}`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 主构建函数
// ═══════════════════════════════════════════════════════════════════════════
function buildMemoryPromptVectorEnabled(store, recallResult) {
const data = store.json || {};
const total = { used: 0, max: BUDGET.total };
const sections = [];
// ─────────────────────────────────────────────────────────────────────
// [世界状态]
// ─────────────────────────────────────────────────────────────────────
const worldLines = formatWorldLines(data.world);
if (worldLines.length) {
const l3 = { used: 0, max: Math.min(BUDGET.l3Max, total.max) };
const l3Lines = [];
for (const line of worldLines) {
if (!pushWithBudget(l3Lines, line, l3)) break;
}
if (l3Lines.length) {
sections.push(`[世界状态] 请严格遵守\n${l3Lines.join('\n')}`);
total.used += l3.used;
}
}
// ─────────────────────────────────────────────────────────────────────
// L1 → L2 归属处理
// ─────────────────────────────────────────────────────────────────────
const events = recallResult?.events || [];
const chunks = recallResult?.chunks || [];
const { events: eventsWithChunks, orphans } = attachChunksToEvents(events, chunks);
// 分离 DIRECT 和 SIMILAR
const directEvents = eventsWithChunks.filter(e => e._recallType === 'DIRECT');
const similarEvents = eventsWithChunks.filter(e => e._recallType !== 'DIRECT');
// ─────────────────────────────────────────────────────────────────────
// [亲身经历] - DIRECT
// ─────────────────────────────────────────────────────────────────────
if (directEvents.length) {
const l2 = { used: 0, max: Math.min(BUDGET.l2Max * 0.7, total.max - total.used) };
const lines = [];
let idx = 1;
for (const e of directEvents) {
const block = formatEventBlock(e, idx);
if (!pushWithBudget(lines, block, l2)) break;
idx++;
}
if (lines.length) {
sections.push(`[亲身经历]\n\n${lines.join('\n\n')}`);
total.used += l2.used;
}
}
// ─────────────────────────────────────────────────────────────────────
// [相关背景] - SIMILAR
// ─────────────────────────────────────────────────────────────────────
if (similarEvents.length) {
const l2s = { used: 0, max: Math.min(BUDGET.l2Max * 0.3, total.max - total.used) };
const lines = [];
let idx = directEvents.length + 1;
for (const e of similarEvents) {
const block = formatEventBlock(e, idx);
if (!pushWithBudget(lines, block, l2s)) break;
idx++;
}
if (lines.length) {
sections.push(`[相关背景]\n\n${lines.join('\n\n')}`);
total.used += l2s.used;
}
}
// ─────────────────────────────────────────────────────────────────────
// [记忆碎片] - 无归属的 chunks
// ─────────────────────────────────────────────────────────────────────
if (orphans.length && total.used < total.max) {
const l1 = { used: 0, max: total.max - total.used };
const lines = [];
for (const c of orphans) {
const line = formatChunkLine(c);
if (!pushWithBudget(lines, line, l1)) break;
}
if (lines.length) {
sections.push(`[记忆碎片]\n${lines.join('\n')}`);
total.used += l1.used;
}
}
// ─────────────────────────────────────────────────────────────────────
// [人物弧光]
// ─────────────────────────────────────────────────────────────────────
if (data.arcs?.length && total.used < total.max) {
const arcLines = data.arcs.map(formatArcLine);
const arcText = `[人物弧光]\n${arcLines.join('\n')}`;
if (total.used + estimateTokens(arcText) <= total.max) {
sections.push(arcText);
}
}
// ─────────────────────────────────────────────────────────────────────
// 组装
// ─────────────────────────────────────────────────────────────────────
if (!sections.length) return '';
return `<剧情记忆>\n\n${sections.join('\n\n')}\n\n</剧情记忆>`;
}
function buildMemoryPromptVectorDisabled(store) {
const data = store.json || {};
const sections = [];
// 世界状态
if (data.world?.length) {
const lines = formatWorldLines(data.world);
sections.push(`[世界状态] 请严格遵守\n${lines.join('\n')}`);
}
// 全部事件(无召回,按时间)
if (data.events?.length) {
const lines = data.events.map((ev, i) => {
const time = ev.timeLabel || '';
const people = (ev.participants || []).join(' / ');
const summary = cleanSummary(ev.summary);
const header = time ? `${i + 1}.【${time}${people}` : `${i + 1}. ${people}`;
return `${header}\n ${summary}`;
});
sections.push(`[剧情记忆]\n\n${lines.join('\n\n')}`);
}
// 弧光
if (data.arcs?.length) {
const lines = data.arcs.map(formatArcLine);
sections.push(`[人物弧光]\n${lines.join('\n')}`);
}
if (!sections.length) return '';
return `<剧情记忆>\n\n${sections.join('\n\n')}\n\n</剧情记忆>`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 导出
// ═══════════════════════════════════════════════════════════════════════════
export function formatPromptWithMemory(store, recallResult) {
const vectorCfg = getVectorConfig();
return vectorCfg?.enabled
? buildMemoryPromptVectorEnabled(store, recallResult)
: buildMemoryPromptVectorDisabled(store);
}
export async function recallAndInjectPrompt(excludeLastAi = false, postToFrame = null) {
if (!getSettings().storySummary?.enabled) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const { chat } = getContext();
const store = getSummaryStore();
if (!store?.json) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const allEvents = store.json.events || [];
const lastIdx = store.lastSummarizedMesId ?? 0;
const length = chat?.length || 0;
if (lastIdx >= length) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const vectorCfg = getVectorConfig();
let recallResult = { events: [], chunks: [] };
if (vectorCfg?.enabled) {
try {
const queryText = buildQueryText(chat, 2, excludeLastAi);
recallResult = await recallMemory(queryText, allEvents, vectorCfg, { excludeLastAi });
postToFrame?.({ type: "RECALL_LOG", text: recallResult.logText || "" });
} catch (e) {
xbLog.error(MODULE_ID, "召回失败", e);
}
}
injectPrompt(store, recallResult, chat);
}
export function updateSummaryExtensionPrompt() {
if (!getSettings().storySummary?.enabled) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const { chat } = getContext();
const store = getSummaryStore();
if (!store?.json || (store.lastSummarizedMesId ?? 0) >= (chat?.length || 0)) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
injectPrompt(store, { events: [], chunks: [] }, chat);
}
function injectPrompt(store, recallResult, chat) {
const length = chat?.length || 0;
let text = formatPromptWithMemory(store, recallResult);
const cfg = getSummaryPanelConfig();
if (cfg.trigger?.wrapperHead) {
text = cfg.trigger.wrapperHead + "\n" + text;
}
if (cfg.trigger?.wrapperTail) {
text = text + "\n" + cfg.trigger.wrapperTail;
}
if (!text.trim()) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const lastIdx = store.lastSummarizedMesId ?? 0;
let depth = length - lastIdx - 1;
if (depth < 0) depth = 0;
if (cfg.trigger?.forceInsertAtEnd) {
depth = 10000;
}
extension_prompts[SUMMARY_PROMPT_KEY] = {
value: text,
position: extension_prompt_types.IN_CHAT,
depth,
role: extension_prompt_roles.ASSISTANT,
};
}
export function clearSummaryExtensionPrompt() {
delete extension_prompts[SUMMARY_PROMPT_KEY];
}