From 356ee6246f6d23d47720052776cf6ba55c1840bb Mon Sep 17 00:00:00 2001 From: X Date: Mon, 16 Feb 2026 09:08:11 +0000 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E5=82=B3=E6=AA=94=E6=A1=88=E5=88=B0?= =?UTF-8?q?=E3=80=8Cmodules/story-summary=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/story-summary/config.js | 141 +++++++++ modules/story-summary/db.js | 26 ++ modules/story-summary/llm-service.js | 378 +++++++++++++++++++++++ modules/story-summary/store.js | 442 +++++++++++++++++++++++++++ 4 files changed, 987 insertions(+) create mode 100644 modules/story-summary/config.js create mode 100644 modules/story-summary/db.js create mode 100644 modules/story-summary/llm-service.js create mode 100644 modules/story-summary/store.js diff --git a/modules/story-summary/config.js b/modules/story-summary/config.js new file mode 100644 index 0000000..77d6832 --- /dev/null +++ b/modules/story-summary/config.js @@ -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: '', end: '' }, + { start: '', end: '' }, +]; + +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; +} diff --git a/modules/story-summary/db.js b/modules/story-summary/db.js new file mode 100644 index 0000000..540f110 --- /dev/null +++ b/modules/story-summary/db.js @@ -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; diff --git a/modules/story-summary/llm-service.js b/modules/story-summary/llm-service.js new file mode 100644 index 0000000..7539d2b --- /dev/null +++ b/modules/story-summary/llm-service.js @@ -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] + +Incremental_Summary_Requirements: + - Incremental_Only: 只提取新对话中的新增要素,绝不重复已有总结 + - Event_Granularity: 记录有叙事价值的事件,而非剧情梗概 + - Memory_Album_Style: 形成有细节、有温度、有记忆点的回忆册 + - Event_Classification: + type: + - 相遇: 人物/事物初次接触 + - 冲突: 对抗、矛盾激化 + - 揭示: 真相、秘密、身份 + - 抉择: 关键决定 + - 羁绊: 关系加深或破裂 + - 转变: 角色/局势改变 + - 收束: 问题解决、和解 + - 日常: 生活片段 + weight: + - 核心: 删掉故事就崩 + - 主线: 推动主要剧情 + - 转折: 改变某条线走向 + - 点睛: 有细节不影响主线 + - 氛围: 纯粹氛围片段 + - Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融) + - Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0) + +--- +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: +`, + + 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: +`, + + 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 +`, + + 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 +All checks passed. Beginning incremental extraction... +{ + "mindful_prelude":`, + + userConfirm: `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容 +`, + + 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; +} diff --git a/modules/story-summary/store.js b/modules/story-summary/store.js new file mode 100644 index 0000000..0429d49 --- /dev/null +++ b/modules/story-summary/store.js @@ -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 + ); +}