上傳檔案到「modules/story-summary」
This commit is contained in:
141
modules/story-summary/config.js
Normal file
141
modules/story-summary/config.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Story Summary - Config (v2 简化版)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { extension_settings } from "../../../../../../extensions.js";
|
||||||
|
import { EXT_ID } from "../../../core/constants.js";
|
||||||
|
import { xbLog } from "../../../core/debug-core.js";
|
||||||
|
import { CommonSettingStorage } from "../../../core/server-storage.js";
|
||||||
|
|
||||||
|
const MODULE_ID = 'summaryConfig';
|
||||||
|
const SUMMARY_CONFIG_KEY = 'storySummaryPanelConfig';
|
||||||
|
|
||||||
|
export function getSettings() {
|
||||||
|
const ext = extension_settings[EXT_ID] ||= {};
|
||||||
|
ext.storySummary ||= { enabled: true };
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILTER_RULES = [
|
||||||
|
{ start: '<think>', end: '</think>' },
|
||||||
|
{ start: '<thinking>', end: '</thinking>' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSummaryPanelConfig() {
|
||||||
|
const defaults = {
|
||||||
|
api: { provider: 'st', url: '', key: '', model: '', modelCache: [] },
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
vector: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('summary_panel_config');
|
||||||
|
if (!raw) return defaults;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
api: { ...defaults.api, ...(parsed.api || {}) },
|
||||||
|
gen: { ...defaults.gen, ...(parsed.gen || {}) },
|
||||||
|
trigger: { ...defaults.trigger, ...(parsed.trigger || {}) },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.trigger.timing === 'manual') result.trigger.enabled = false;
|
||||||
|
if (result.trigger.useStream === undefined) result.trigger.useStream = true;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSummaryPanelConfig(config) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('summary_panel_config', JSON.stringify(config));
|
||||||
|
CommonSettingStorage.set(SUMMARY_CONFIG_KEY, config);
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, '保存面板配置失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 向量配置(简化版 - 只需要 key)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function getVectorConfig() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('summary_panel_config');
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const cfg = parsed.vector || null;
|
||||||
|
|
||||||
|
if (cfg && !cfg.textFilterRules) {
|
||||||
|
cfg.textFilterRules = [...DEFAULT_FILTER_RULES];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化:统一使用硅基
|
||||||
|
if (cfg) {
|
||||||
|
cfg.engine = 'online';
|
||||||
|
cfg.online = cfg.online || {};
|
||||||
|
cfg.online.provider = 'siliconflow';
|
||||||
|
cfg.online.model = 'BAAI/bge-m3';
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextFilterRules() {
|
||||||
|
const cfg = getVectorConfig();
|
||||||
|
return cfg?.textFilterRules || DEFAULT_FILTER_RULES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveVectorConfig(vectorCfg) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('summary_panel_config') || '{}';
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
// 简化配置
|
||||||
|
parsed.vector = {
|
||||||
|
enabled: vectorCfg?.enabled || false,
|
||||||
|
engine: 'online',
|
||||||
|
online: {
|
||||||
|
provider: 'siliconflow',
|
||||||
|
key: vectorCfg?.online?.key || '',
|
||||||
|
model: 'BAAI/bge-m3',
|
||||||
|
},
|
||||||
|
textFilterRules: vectorCfg?.textFilterRules || DEFAULT_FILTER_RULES,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('summary_panel_config', JSON.stringify(parsed));
|
||||||
|
CommonSettingStorage.set(SUMMARY_CONFIG_KEY, parsed);
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, '保存向量配置失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfigFromServer() {
|
||||||
|
try {
|
||||||
|
const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
|
||||||
|
if (savedConfig) {
|
||||||
|
localStorage.setItem('summary_panel_config', JSON.stringify(savedConfig));
|
||||||
|
xbLog.info(MODULE_ID, '已从服务器加载面板配置');
|
||||||
|
return savedConfig;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.warn(MODULE_ID, '加载面板配置失败', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
26
modules/story-summary/db.js
Normal file
26
modules/story-summary/db.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Memory Database (Dexie schema)
|
||||||
|
|
||||||
|
import Dexie from '../../../libs/dexie.mjs';
|
||||||
|
|
||||||
|
const DB_NAME = 'LittleWhiteBox_Memory';
|
||||||
|
const DB_VERSION = 3; // 升级版本
|
||||||
|
|
||||||
|
// Chunk parameters
|
||||||
|
export const CHUNK_MAX_TOKENS = 200;
|
||||||
|
|
||||||
|
const db = new Dexie(DB_NAME);
|
||||||
|
|
||||||
|
db.version(DB_VERSION).stores({
|
||||||
|
meta: 'chatId',
|
||||||
|
chunks: '[chatId+chunkId], chatId, [chatId+floor]',
|
||||||
|
chunkVectors: '[chatId+chunkId], chatId',
|
||||||
|
eventVectors: '[chatId+eventId], chatId',
|
||||||
|
stateVectors: '[chatId+atomId], chatId, [chatId+floor]', // L0 向量表
|
||||||
|
});
|
||||||
|
|
||||||
|
export { db };
|
||||||
|
export const metaTable = db.meta;
|
||||||
|
export const chunksTable = db.chunks;
|
||||||
|
export const chunkVectorsTable = db.chunkVectors;
|
||||||
|
export const eventVectorsTable = db.eventVectors;
|
||||||
|
export const stateVectorsTable = db.stateVectors;
|
||||||
378
modules/story-summary/llm-service.js
Normal file
378
modules/story-summary/llm-service.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Story Summary - 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)
|
||||||
|
</task_settings>
|
||||||
|
---
|
||||||
|
Story Analyst:
|
||||||
|
[Responsibility Definition]
|
||||||
|
\`\`\`yaml
|
||||||
|
analysis_task:
|
||||||
|
title: Incremental Story Summarization
|
||||||
|
Story Analyst:
|
||||||
|
role: Antigravity
|
||||||
|
task: >-
|
||||||
|
To analyze provided dialogue content against existing summary state,
|
||||||
|
extract only NEW plot elements, character developments, relationship
|
||||||
|
changes, and arc progressions, outputting structured JSON for
|
||||||
|
incremental summary database updates.
|
||||||
|
assistant:
|
||||||
|
role: Summary Specialist
|
||||||
|
description: Incremental Story Summary 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,
|
||||||
|
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) 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
|
||||||
|
\`\`\`
|
||||||
|
---
|
||||||
|
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: 仅记录本次新增的关键时刻
|
||||||
|
|
||||||
|
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`,
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"mindful_prelude": {
|
||||||
|
"user_insight": 用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
|
||||||
|
"dedup_analysis": "已有X个事件,本次识别Y个新事件",
|
||||||
|
},
|
||||||
|
"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": "本次新增的关键时刻"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## CRITICAL NOTES
|
||||||
|
- events.id 从 evt-{nextEventId} 开始编号
|
||||||
|
- 仅输出【增量】内容,已有事件绝不重复
|
||||||
|
- keywords 是全局关键词,综合已有+新增
|
||||||
|
- 合法JSON,字符串值内部避免英文双引号
|
||||||
|
- Output single valid JSON only
|
||||||
|
</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
|
||||||
|
└─ Output format: ✓ JSON specification loaded
|
||||||
|
|
||||||
|
[Material Verification]
|
||||||
|
├─ Existing events: Indexed ({existingEventCount} recorded)
|
||||||
|
├─ Character baseline: Mapped
|
||||||
|
├─ Relationship baseline: Mapped
|
||||||
|
├─ Arc progress baseline: Noted
|
||||||
|
└─ 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 buildSummaryMessages(existingSummary, newHistoryText, historyRange, nextEventId, existingEventCount) {
|
||||||
|
// 替换动态内容
|
||||||
|
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</已有总结状态>` },
|
||||||
|
{ 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 {}
|
||||||
|
|
||||||
|
// 提取 JSON 对象
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
newHistoryText,
|
||||||
|
historyRange,
|
||||||
|
nextEventId,
|
||||||
|
existingEventCount
|
||||||
|
);
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
as: 'user',
|
||||||
|
nonstream: useStream ? 'false' : 'true',
|
||||||
|
top64: promptData.top64,
|
||||||
|
bottom64: promptData.bottom64,
|
||||||
|
bottomassistant: promptData.assistantPrefill,
|
||||||
|
id: sessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 配置(非酒馆主 API)
|
||||||
|
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;
|
||||||
|
}
|
||||||
442
modules/story-summary/store.js
Normal file
442
modules/story-summary/store.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
// Story Summary - Store
|
||||||
|
// L2 (events/characters/arcs) + L3 (facts) 统一存储
|
||||||
|
|
||||||
|
import { getContext, saveMetadataDebounced } from "../../../../../../extensions.js";
|
||||||
|
import { chat_metadata } from "../../../../../../../script.js";
|
||||||
|
import { EXT_ID } from "../../../core/constants.js";
|
||||||
|
import { xbLog } from "../../../core/debug-core.js";
|
||||||
|
import { clearEventVectors, deleteEventVectorsByIds } from "../vector/storage/chunk-store.js";
|
||||||
|
|
||||||
|
const MODULE_ID = 'summaryStore';
|
||||||
|
const FACTS_LIMIT_PER_SUBJECT = 10;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 基础存取
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function getSummaryStore() {
|
||||||
|
const { chatId } = getContext();
|
||||||
|
if (!chatId) return null;
|
||||||
|
chat_metadata.extensions ||= {};
|
||||||
|
chat_metadata.extensions[EXT_ID] ||= {};
|
||||||
|
chat_metadata.extensions[EXT_ID].storySummary ||= {};
|
||||||
|
|
||||||
|
const store = chat_metadata.extensions[EXT_ID].storySummary;
|
||||||
|
|
||||||
|
// ★ 自动迁移旧数据
|
||||||
|
if (store.json && !store.json.facts) {
|
||||||
|
const hasOldData = store.json.world?.length || store.json.characters?.relationships?.length;
|
||||||
|
if (hasOldData) {
|
||||||
|
store.json.facts = migrateToFacts(store.json);
|
||||||
|
// 删除旧字段
|
||||||
|
delete store.json.world;
|
||||||
|
if (store.json.characters) {
|
||||||
|
delete store.json.characters.relationships;
|
||||||
|
}
|
||||||
|
store.updatedAt = Date.now();
|
||||||
|
saveSummaryStore();
|
||||||
|
xbLog.info(MODULE_ID, `自动迁移完成: ${store.json.facts.length} 条 facts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSummaryStore() {
|
||||||
|
saveMetadataDebounced?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeepVisibleCount() {
|
||||||
|
const store = getSummaryStore();
|
||||||
|
return store?.keepVisibleCount ?? 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcHideRange(boundary) {
|
||||||
|
if (boundary == null || boundary < 0) return null;
|
||||||
|
|
||||||
|
const keepCount = getKeepVisibleCount();
|
||||||
|
const hideEnd = boundary - keepCount;
|
||||||
|
if (hideEnd < 0) return null;
|
||||||
|
return { start: 0, end: hideEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSummarySnapshot(store, endMesId) {
|
||||||
|
store.summaryHistory ||= [];
|
||||||
|
store.summaryHistory.push({ endMesId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Fact 工具函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为关系类 fact
|
||||||
|
*/
|
||||||
|
export function isRelationFact(f) {
|
||||||
|
return /^对.+的/.test(f.p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 从 facts 提取关系(供关系图 UI 使用)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function extractRelationshipsFromFacts(facts) {
|
||||||
|
return (facts || [])
|
||||||
|
.filter(f => !f.retracted && isRelationFact(f))
|
||||||
|
.map(f => {
|
||||||
|
const match = f.p.match(/^对(.+)的/);
|
||||||
|
const to = match ? match[1] : '';
|
||||||
|
if (!to) return null;
|
||||||
|
return {
|
||||||
|
from: f.s,
|
||||||
|
to,
|
||||||
|
label: f.o,
|
||||||
|
trend: f.trend || '陌生',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 fact 的唯一键(s + p)
|
||||||
|
*/
|
||||||
|
function factKey(f) {
|
||||||
|
return `${f.s}::${f.p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成下一个 fact ID
|
||||||
|
*/
|
||||||
|
function getNextFactId(existingFacts) {
|
||||||
|
let maxId = 0;
|
||||||
|
for (const f of existingFacts || []) {
|
||||||
|
const match = f.id?.match(/^f-(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
maxId = Math.max(maxId, parseInt(match[1], 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Facts 合并(KV 覆盖模型)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function mergeFacts(existingFacts, updates, floor) {
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
for (const f of existingFacts || []) {
|
||||||
|
if (!f.retracted) {
|
||||||
|
map.set(factKey(f), f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = getNextFactId(existingFacts);
|
||||||
|
|
||||||
|
for (const u of updates || []) {
|
||||||
|
if (!u.s || !u.p) continue;
|
||||||
|
|
||||||
|
const key = factKey(u);
|
||||||
|
|
||||||
|
if (u.retracted === true) {
|
||||||
|
map.delete(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!u.o || !String(u.o).trim()) continue;
|
||||||
|
|
||||||
|
const existing = map.get(key);
|
||||||
|
const newFact = {
|
||||||
|
id: existing?.id || `f-${nextId++}`,
|
||||||
|
s: u.s.trim(),
|
||||||
|
p: u.p.trim(),
|
||||||
|
o: String(u.o).trim(),
|
||||||
|
since: floor,
|
||||||
|
_isState: existing?._isState ?? !!u.isState,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRelationFact(newFact) && u.trend) {
|
||||||
|
newFact.trend = u.trend;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing?._addedAt != null) {
|
||||||
|
newFact._addedAt = existing._addedAt;
|
||||||
|
} else {
|
||||||
|
newFact._addedAt = floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.set(key, newFact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const factsBySubject = new Map();
|
||||||
|
for (const f of map.values()) {
|
||||||
|
if (f._isState) continue;
|
||||||
|
const arr = factsBySubject.get(f.s) || [];
|
||||||
|
arr.push(f);
|
||||||
|
factsBySubject.set(f.s, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRemove = new Set();
|
||||||
|
for (const arr of factsBySubject.values()) {
|
||||||
|
if (arr.length > FACTS_LIMIT_PER_SUBJECT) {
|
||||||
|
arr.sort((a, b) => (a._addedAt || 0) - (b._addedAt || 0));
|
||||||
|
for (let i = 0; i < arr.length - FACTS_LIMIT_PER_SUBJECT; i++) {
|
||||||
|
toRemove.add(factKey(arr[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).filter(f => !toRemove.has(factKey(f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 旧数据迁移
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function migrateToFacts(json) {
|
||||||
|
if (!json) return [];
|
||||||
|
|
||||||
|
// 已有 facts 则跳过迁移
|
||||||
|
if (json.facts?.length) return json.facts;
|
||||||
|
|
||||||
|
const facts = [];
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
// 迁移 world(worldUpdate 的持久化结果)
|
||||||
|
for (const w of json.world || []) {
|
||||||
|
if (!w.category || !w.topic || !w.content) continue;
|
||||||
|
|
||||||
|
let s, p;
|
||||||
|
|
||||||
|
// 解析 topic 格式:status/knowledge/relation 用 "::" 分隔
|
||||||
|
if (w.topic.includes('::')) {
|
||||||
|
[s, p] = w.topic.split('::').map(x => x.trim());
|
||||||
|
} else {
|
||||||
|
// inventory/rule 类
|
||||||
|
s = w.topic.trim();
|
||||||
|
p = w.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!s || !p) continue;
|
||||||
|
|
||||||
|
facts.push({
|
||||||
|
id: `f-${nextId++}`,
|
||||||
|
s,
|
||||||
|
p,
|
||||||
|
o: w.content.trim(),
|
||||||
|
since: w.floor ?? w._addedAt ?? 0,
|
||||||
|
_addedAt: w._addedAt ?? w.floor ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移 relationships
|
||||||
|
for (const r of json.characters?.relationships || []) {
|
||||||
|
if (!r.from || !r.to) continue;
|
||||||
|
|
||||||
|
facts.push({
|
||||||
|
id: `f-${nextId++}`,
|
||||||
|
s: r.from,
|
||||||
|
p: `对${r.to}的看法`,
|
||||||
|
o: r.label || '未知',
|
||||||
|
trend: r.trend,
|
||||||
|
since: r._addedAt ?? 0,
|
||||||
|
_addedAt: r._addedAt ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 数据合并(L2 + L3)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function mergeNewData(oldJson, parsed, endMesId) {
|
||||||
|
const merged = structuredClone(oldJson || {});
|
||||||
|
|
||||||
|
// L2 初始化
|
||||||
|
merged.keywords ||= [];
|
||||||
|
merged.events ||= [];
|
||||||
|
merged.characters ||= {};
|
||||||
|
merged.characters.main ||= [];
|
||||||
|
merged.arcs ||= [];
|
||||||
|
|
||||||
|
// L3 初始化(不再迁移,getSummaryStore 已处理)
|
||||||
|
merged.facts ||= [];
|
||||||
|
|
||||||
|
// L2 数据合并
|
||||||
|
if (parsed.keywords?.length) {
|
||||||
|
merged.keywords = parsed.keywords.map(k => ({ ...k, _addedAt: endMesId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
(parsed.events || []).forEach(e => {
|
||||||
|
e._addedAt = endMesId;
|
||||||
|
merged.events.push(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// newCharacters
|
||||||
|
const existingMain = new Set(
|
||||||
|
(merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name)
|
||||||
|
);
|
||||||
|
(parsed.newCharacters || []).forEach(name => {
|
||||||
|
if (!existingMain.has(name)) {
|
||||||
|
merged.characters.main.push({ name, _addedAt: endMesId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// arcUpdates
|
||||||
|
const arcMap = new Map((merged.arcs || []).map(a => [a.name, a]));
|
||||||
|
(parsed.arcUpdates || []).forEach(update => {
|
||||||
|
const existing = arcMap.get(update.name);
|
||||||
|
if (existing) {
|
||||||
|
existing.trajectory = update.trajectory;
|
||||||
|
existing.progress = update.progress;
|
||||||
|
if (update.newMoment) {
|
||||||
|
existing.moments = existing.moments || [];
|
||||||
|
existing.moments.push({ text: update.newMoment, _addedAt: endMesId });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
arcMap.set(update.name, {
|
||||||
|
name: update.name,
|
||||||
|
trajectory: update.trajectory,
|
||||||
|
progress: update.progress,
|
||||||
|
moments: update.newMoment ? [{ text: update.newMoment, _addedAt: endMesId }] : [],
|
||||||
|
_addedAt: endMesId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
merged.arcs = Array.from(arcMap.values());
|
||||||
|
|
||||||
|
// L3 factUpdates 合并
|
||||||
|
merged.facts = mergeFacts(merged.facts, parsed.factUpdates || [], endMesId);
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 回滚
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function rollbackSummaryIfNeeded() {
|
||||||
|
const { chat, chatId } = getContext();
|
||||||
|
const currentLength = Array.isArray(chat) ? chat.length : 0;
|
||||||
|
const store = getSummaryStore();
|
||||||
|
|
||||||
|
if (!store || store.lastSummarizedMesId == null || store.lastSummarizedMesId < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSummarized = store.lastSummarizedMesId;
|
||||||
|
|
||||||
|
if (currentLength <= lastSummarized) {
|
||||||
|
const deletedCount = lastSummarized + 1 - currentLength;
|
||||||
|
|
||||||
|
if (deletedCount < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
xbLog.warn(MODULE_ID, `删除已总结楼层 ${deletedCount} 条,触发回滚`);
|
||||||
|
|
||||||
|
const history = store.summaryHistory || [];
|
||||||
|
let targetEndMesId = -1;
|
||||||
|
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
if (history[i].endMesId < currentLength) {
|
||||||
|
targetEndMesId = history[i].endMesId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeRollback(chatId, store, targetEndMesId, currentLength);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRollback(chatId, store, targetEndMesId, currentLength) {
|
||||||
|
const oldEvents = store.json?.events || [];
|
||||||
|
|
||||||
|
if (targetEndMesId < 0) {
|
||||||
|
store.lastSummarizedMesId = -1;
|
||||||
|
store.json = null;
|
||||||
|
store.summaryHistory = [];
|
||||||
|
store.hideSummarizedHistory = false;
|
||||||
|
|
||||||
|
await clearEventVectors(chatId);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const deletedEventIds = oldEvents
|
||||||
|
.filter(e => (e._addedAt ?? 0) > targetEndMesId)
|
||||||
|
.map(e => e.id);
|
||||||
|
|
||||||
|
const json = store.json || {};
|
||||||
|
|
||||||
|
// L2 回滚
|
||||||
|
json.events = (json.events || []).filter(e => (e._addedAt ?? 0) <= targetEndMesId);
|
||||||
|
json.keywords = (json.keywords || []).filter(k => (k._addedAt ?? 0) <= targetEndMesId);
|
||||||
|
json.arcs = (json.arcs || []).filter(a => (a._addedAt ?? 0) <= targetEndMesId);
|
||||||
|
json.arcs.forEach(a => {
|
||||||
|
a.moments = (a.moments || []).filter(m =>
|
||||||
|
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (json.characters) {
|
||||||
|
json.characters.main = (json.characters.main || []).filter(m =>
|
||||||
|
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L3 facts 回滚
|
||||||
|
json.facts = (json.facts || []).filter(f => (f._addedAt ?? 0) <= targetEndMesId);
|
||||||
|
|
||||||
|
store.json = json;
|
||||||
|
store.lastSummarizedMesId = targetEndMesId;
|
||||||
|
store.summaryHistory = (store.summaryHistory || []).filter(h => h.endMesId <= targetEndMesId);
|
||||||
|
|
||||||
|
if (deletedEventIds.length > 0) {
|
||||||
|
await deleteEventVectorsByIds(chatId, deletedEventIds);
|
||||||
|
xbLog.info(MODULE_ID, `回滚删除 ${deletedEventIds.length} 个事件向量`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updatedAt = Date.now();
|
||||||
|
saveSummaryStore();
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `回滚完成,目标楼层: ${targetEndMesId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSummaryData(chatId) {
|
||||||
|
const store = getSummaryStore();
|
||||||
|
if (store) {
|
||||||
|
delete store.json;
|
||||||
|
store.lastSummarizedMesId = -1;
|
||||||
|
store.updatedAt = Date.now();
|
||||||
|
saveSummaryStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatId) {
|
||||||
|
await clearEventVectors(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, '总结数据已清空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L3 数据读取(供 prompt.js / recall.js 使用)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function getFacts() {
|
||||||
|
const store = getSummaryStore();
|
||||||
|
return (store?.json?.facts || []).filter(f => !f.retracted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNewCharacters() {
|
||||||
|
const store = getSummaryStore();
|
||||||
|
return (store?.json?.characters?.main || []).map(m =>
|
||||||
|
typeof m === 'string' ? m : m.name
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user