Sync local version
This commit is contained in:
208
modules/story-summary/generate/generator.js
Normal file
208
modules/story-summary/generate/generator.js
Normal 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 };
|
||||
}
|
||||
444
modules/story-summary/generate/llm.js
Normal file
444
modules/story-summary/generate/llm.js
Normal 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 选 1:status / 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;
|
||||
}
|
||||
394
modules/story-summary/generate/prompt.js
Normal file
394
modules/story-summary/generate/prompt.js
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user