story-summary: facts migration + recall enhancements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Story Summary - Store
|
||||
// L2 (events/characters/arcs) + L3 (world) 统一存储
|
||||
// L2 (events/characters/arcs) + L3 (facts) 统一存储
|
||||
|
||||
import { getContext, saveMetadataDebounced } from "../../../../../../extensions.js";
|
||||
import { chat_metadata } from "../../../../../../../script.js";
|
||||
@@ -20,7 +20,26 @@ export function getSummaryStore() {
|
||||
chat_metadata.extensions ||= {};
|
||||
chat_metadata.extensions[EXT_ID] ||= {};
|
||||
chat_metadata.extensions[EXT_ID].storySummary ||= {};
|
||||
return 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() {
|
||||
@@ -32,7 +51,6 @@ export function getKeepVisibleCount() {
|
||||
return store?.keepVisibleCount ?? 3;
|
||||
}
|
||||
|
||||
// boundary:隐藏边界(由调用方决定语义:LLM总结边界 or 向量边界)
|
||||
export function calcHideRange(boundary) {
|
||||
if (boundary == null || boundary < 0) return null;
|
||||
|
||||
@@ -48,42 +66,155 @@ export function addSummarySnapshot(store, endMesId) {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// L3 世界状态合并
|
||||
// Fact 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function mergeWorldState(existingList, updates, floor) {
|
||||
/**
|
||||
* 判断是否为关系类 fact
|
||||
*/
|
||||
export function isRelationFact(f) {
|
||||
return /^对.+的/.test(f.p);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 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();
|
||||
|
||||
(existingList || []).forEach(item => {
|
||||
const key = `${item.category}:${item.topic}`;
|
||||
map.set(key, item);
|
||||
});
|
||||
// 加载现有 facts
|
||||
for (const f of existingFacts || []) {
|
||||
if (!f.retracted) {
|
||||
map.set(factKey(f), f);
|
||||
}
|
||||
}
|
||||
|
||||
(updates || []).forEach(up => {
|
||||
if (!up.category || !up.topic) return;
|
||||
// 获取下一个 ID
|
||||
let nextId = getNextFactId(existingFacts);
|
||||
|
||||
const key = `${up.category}:${up.topic}`;
|
||||
// 应用更新
|
||||
for (const u of updates || []) {
|
||||
if (!u.s || !u.p) continue;
|
||||
|
||||
if (up.cleared === true) {
|
||||
const key = factKey(u);
|
||||
|
||||
// 删除操作
|
||||
if (u.retracted === true) {
|
||||
map.delete(key);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = up.content?.trim();
|
||||
if (!content) return;
|
||||
// 无 o 则跳过
|
||||
if (!u.o || !String(u.o).trim()) continue;
|
||||
|
||||
map.set(key, {
|
||||
category: up.category,
|
||||
topic: up.topic,
|
||||
content: content,
|
||||
floor: floor,
|
||||
_addedAt: floor,
|
||||
});
|
||||
});
|
||||
// 覆盖或新增
|
||||
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,
|
||||
};
|
||||
|
||||
// 关系类保留 trend
|
||||
if (isRelationFact(newFact) && u.trend) {
|
||||
newFact.trend = u.trend;
|
||||
}
|
||||
|
||||
// 保留原始 _addedAt(如果是更新)
|
||||
if (existing?._addedAt != null) {
|
||||
newFact._addedAt = existing._addedAt;
|
||||
} else {
|
||||
newFact._addedAt = floor;
|
||||
}
|
||||
|
||||
map.set(key, newFact);
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 旧数据迁移
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -96,11 +227,10 @@ export function mergeNewData(oldJson, parsed, endMesId) {
|
||||
merged.events ||= [];
|
||||
merged.characters ||= {};
|
||||
merged.characters.main ||= [];
|
||||
merged.characters.relationships ||= [];
|
||||
merged.arcs ||= [];
|
||||
|
||||
// L3 初始化
|
||||
merged.world ||= [];
|
||||
// L3 初始化(不再迁移,getSummaryStore 已处理)
|
||||
merged.facts ||= [];
|
||||
|
||||
// L2 数据合并
|
||||
if (parsed.keywords?.length) {
|
||||
@@ -112,6 +242,7 @@ export function mergeNewData(oldJson, parsed, endMesId) {
|
||||
merged.events.push(e);
|
||||
});
|
||||
|
||||
// newCharacters
|
||||
const existingMain = new Set(
|
||||
(merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name)
|
||||
);
|
||||
@@ -121,22 +252,7 @@ export function mergeNewData(oldJson, parsed, endMesId) {
|
||||
}
|
||||
});
|
||||
|
||||
const relMap = new Map(
|
||||
(merged.characters.relationships || []).map(r => [`${r.from}->${r.to}`, r])
|
||||
);
|
||||
(parsed.newRelationships || []).forEach(r => {
|
||||
const key = `${r.from}->${r.to}`;
|
||||
const existing = relMap.get(key);
|
||||
if (existing) {
|
||||
existing.label = r.label;
|
||||
existing.trend = r.trend;
|
||||
} else {
|
||||
r._addedAt = endMesId;
|
||||
relMap.set(key, r);
|
||||
}
|
||||
});
|
||||
merged.characters.relationships = Array.from(relMap.values());
|
||||
|
||||
// arcUpdates
|
||||
const arcMap = new Map((merged.arcs || []).map(a => [a.name, a]));
|
||||
(parsed.arcUpdates || []).forEach(update => {
|
||||
const existing = arcMap.get(update.name);
|
||||
@@ -159,12 +275,8 @@ export function mergeNewData(oldJson, parsed, endMesId) {
|
||||
});
|
||||
merged.arcs = Array.from(arcMap.values());
|
||||
|
||||
// L3 世界状态合并
|
||||
merged.world = mergeWorldState(
|
||||
merged.world || [],
|
||||
parsed.worldUpdate || [],
|
||||
endMesId
|
||||
);
|
||||
// L3 factUpdates 合并
|
||||
merged.facts = mergeFacts(merged.facts, parsed.factUpdates || [], endMesId);
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -242,13 +354,10 @@ export async function executeRollback(chatId, store, targetEndMesId, currentLeng
|
||||
json.characters.main = (json.characters.main || []).filter(m =>
|
||||
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
|
||||
);
|
||||
json.characters.relationships = (json.characters.relationships || []).filter(r =>
|
||||
(r._addedAt ?? 0) <= targetEndMesId
|
||||
);
|
||||
}
|
||||
|
||||
// L3 回滚
|
||||
json.world = (json.world || []).filter(w => (w._addedAt ?? 0) <= targetEndMesId);
|
||||
// L3 facts 回滚
|
||||
json.facts = (json.facts || []).filter(f => (f._addedAt ?? 0) <= targetEndMesId);
|
||||
|
||||
store.json = json;
|
||||
store.lastSummarizedMesId = targetEndMesId;
|
||||
@@ -278,17 +387,24 @@ export async function clearSummaryData(chatId) {
|
||||
if (chatId) {
|
||||
await clearEventVectors(chatId);
|
||||
}
|
||||
|
||||
|
||||
clearEventTextIndex();
|
||||
|
||||
|
||||
xbLog.info(MODULE_ID, '总结数据已清空');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// L3 数据读取(供 prompt.js 使用)
|
||||
// L3 数据读取(供 prompt.js / recall.js 使用)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function getWorldSnapshot() {
|
||||
export function getFacts() {
|
||||
const store = getSummaryStore();
|
||||
return store?.json?.world || [];
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { getContext } from "../../../../../../extensions.js";
|
||||
import { xbLog } from "../../../core/debug-core.js";
|
||||
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData } from "../data/store.js";
|
||||
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData, getFacts } from "../data/store.js";
|
||||
import { generateSummary, parseSummaryJson } from "./llm.js";
|
||||
|
||||
const MODULE_ID = 'summaryGenerator';
|
||||
@@ -11,46 +11,48 @@ const SUMMARY_SESSION_ID = 'xb9';
|
||||
const MAX_CAUSED_BY = 2;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// worldUpdate 清洗
|
||||
// factUpdates 清洗
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function sanitizeWorldUpdate(parsed) {
|
||||
function sanitizeFacts(parsed) {
|
||||
if (!parsed) return;
|
||||
|
||||
const wu = Array.isArray(parsed.worldUpdate) ? parsed.worldUpdate : [];
|
||||
const updates = Array.isArray(parsed.factUpdates) ? parsed.factUpdates : [];
|
||||
const ok = [];
|
||||
|
||||
for (const item of wu) {
|
||||
const category = String(item?.category || '').trim().toLowerCase();
|
||||
const topic = String(item?.topic || '').trim();
|
||||
for (const item of updates) {
|
||||
const s = String(item?.s || '').trim();
|
||||
const p = String(item?.p || '').trim();
|
||||
|
||||
if (!category || !topic) continue;
|
||||
if (!s || !p) continue;
|
||||
|
||||
// status/knowledge/relation 必须包含 "::"
|
||||
if (['status', 'knowledge', 'relation'].includes(category) && !topic.includes('::')) {
|
||||
xbLog.warn(MODULE_ID, `丢弃不合格 worldUpdate: ${category}/${topic}`);
|
||||
// 删除操作
|
||||
if (item.retracted === true) {
|
||||
ok.push({ s, p, retracted: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.cleared === true) {
|
||||
ok.push({ category, topic, cleared: true });
|
||||
continue;
|
||||
const o = String(item?.o || '').trim();
|
||||
if (!o) continue;
|
||||
|
||||
const fact = { s, p, o };
|
||||
|
||||
// 关系类保留 trend
|
||||
if (/^对.+的/.test(p) && item.trend) {
|
||||
const validTrends = ['破裂', '厌恶', '反感', '陌生', '投缘', '亲密', '交融'];
|
||||
if (validTrends.includes(item.trend)) {
|
||||
fact.trend = item.trend;
|
||||
}
|
||||
}
|
||||
|
||||
const content = String(item?.content || '').trim();
|
||||
if (!content) continue;
|
||||
|
||||
ok.push({ category, topic, content });
|
||||
ok.push(fact);
|
||||
}
|
||||
|
||||
parsed.worldUpdate = ok;
|
||||
parsed.factUpdates = ok;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// causedBy 清洗(事件因果边)
|
||||
// - 允许引用:已存在事件 + 本次新输出事件
|
||||
// - 限制长度:0-2
|
||||
// - 去重、剔除非法ID、剔除自引用
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function sanitizeEventsCausality(parsed, existingEventIds) {
|
||||
@@ -61,7 +63,6 @@ function sanitizeEventsCausality(parsed, existingEventIds) {
|
||||
|
||||
const idRe = /^evt-\d+$/;
|
||||
|
||||
// 本次新输出事件ID集合(允许引用)
|
||||
const newIds = new Set(
|
||||
events
|
||||
.map(e => String(e?.id || '').trim())
|
||||
@@ -73,7 +74,6 @@ function sanitizeEventsCausality(parsed, existingEventIds) {
|
||||
for (const e of events) {
|
||||
const selfId = String(e?.id || '').trim();
|
||||
if (!idRe.test(selfId)) {
|
||||
// id 不合格的话,causedBy 直接清空,避免污染
|
||||
e.causedBy = [];
|
||||
continue;
|
||||
}
|
||||
@@ -117,11 +117,6 @@ export function formatExistingSummaryForAI(store) {
|
||||
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)}%)`));
|
||||
@@ -187,7 +182,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
|
||||
onStatus?.(`正在总结 ${slice.range}(${slice.count}楼新内容)...`);
|
||||
|
||||
const existingSummary = formatExistingSummaryForAI(store);
|
||||
const existingWorld = store?.json?.world || [];
|
||||
const existingFacts = getFacts();
|
||||
const nextEventId = getNextEventId(store);
|
||||
const existingEventCount = store?.json?.events?.length || 0;
|
||||
const useStream = config.trigger?.useStream !== false;
|
||||
@@ -196,7 +191,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
|
||||
try {
|
||||
raw = await generateSummary({
|
||||
existingSummary,
|
||||
existingWorld,
|
||||
existingFacts,
|
||||
newHistoryText: slice.text,
|
||||
historyRange: slice.range,
|
||||
nextEventId,
|
||||
@@ -231,7 +226,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
|
||||
return { success: false, error: "parse" };
|
||||
}
|
||||
|
||||
sanitizeWorldUpdate(parsed);
|
||||
sanitizeFacts(parsed);
|
||||
const existingEventIds = new Set((store?.json?.events || []).map(e => e?.id).filter(Boolean));
|
||||
sanitizeEventsCausality(parsed, existingEventIds);
|
||||
|
||||
@@ -245,8 +240,8 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
|
||||
|
||||
xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1} 楼`);
|
||||
|
||||
if (parsed.worldUpdate?.length) {
|
||||
xbLog.info(MODULE_ID, `世界状态更新: ${parsed.worldUpdate.length} 条`);
|
||||
if (parsed.factUpdates?.length) {
|
||||
xbLog.info(MODULE_ID, `Facts 更新: ${parsed.factUpdates.length} 条`);
|
||||
}
|
||||
|
||||
const newEventIds = (parsed.events || []).map(e => e.id);
|
||||
@@ -255,7 +250,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
|
||||
merged,
|
||||
endMesId: slice.endMesId,
|
||||
newEventIds,
|
||||
l3Stats: { worldUpdate: parsed.worldUpdate?.length || 0 },
|
||||
factStats: { updated: parsed.factUpdates?.length || 0 },
|
||||
});
|
||||
|
||||
return { success: true, merged, endMesId: slice.endMesId, newEventIds };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// LLM Service
|
||||
|
||||
const PROVIDER_MAP = {
|
||||
// ...
|
||||
openai: "openai",
|
||||
google: "gemini",
|
||||
gemini: "gemini",
|
||||
@@ -39,43 +38,37 @@ Incremental_Summary_Requirements:
|
||||
- Causal_Chain: 为每个新事件标注直接前因事件ID(causedBy)。仅在因果关系明确(直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个,只填 evt-数字,指向已存在或本次新输出事件。
|
||||
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
|
||||
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
|
||||
- World_State_Tracking: 维护当前世界的硬性约束。解决"什么不能违反"。采用 KV 覆盖模型,追踪生死、物品归属、秘密知情、关系状态、环境规则等不可违背的事实。(覆盖式更新)
|
||||
categories:
|
||||
- status: 角色生死、位置锁定、重大状态
|
||||
- inventory: 重要物品归属
|
||||
- knowledge: 秘密的知情状态
|
||||
- relation: 硬性关系(在一起/决裂)
|
||||
- rule: 环境规则/契约限制
|
||||
- Fact_Tracking: 维护 SPO 三元组知识图谱。追踪生死、物品归属、位置、关系等硬性事实。采用 KV 覆盖模型(s+p 为键)。
|
||||
</task_settings>
|
||||
---
|
||||
Story Analyst:
|
||||
[Responsibility Definition]
|
||||
\`\`\`yaml
|
||||
analysis_task:
|
||||
title: Incremental Story Summarization with World State
|
||||
title: Incremental Story Summarization with Knowledge Graph
|
||||
Story Analyst:
|
||||
role: Antigravity
|
||||
task: >-
|
||||
To analyze provided dialogue content against existing summary state,
|
||||
extract only NEW plot elements, character developments, relationship
|
||||
changes, arc progressions, AND world state changes, outputting
|
||||
changes, arc progressions, AND fact updates, outputting
|
||||
structured JSON for incremental summary database updates.
|
||||
assistant:
|
||||
role: Summary Specialist
|
||||
description: Incremental Story Summary & World State Analyst
|
||||
description: Incremental Story Summary & Knowledge Graph Analyst
|
||||
behavior: >-
|
||||
To compare new dialogue against existing summary, identify genuinely
|
||||
new events and character interactions, classify events by narrative
|
||||
type and weight, track character arc progression with percentage,
|
||||
maintain world state as key-value updates with clear flags,
|
||||
maintain facts as SPO triples with clear semantics,
|
||||
and output structured JSON containing only incremental updates.
|
||||
Must strictly avoid repeating any existing summary content.
|
||||
user:
|
||||
role: Content Provider
|
||||
description: Supplies existing summary state and new dialogue
|
||||
behavior: >-
|
||||
To provide existing summary state (events, characters, relationships,
|
||||
arcs, world state) and new dialogue content for incremental analysis.
|
||||
To provide existing summary state (events, characters, arcs, facts)
|
||||
and new dialogue content for incremental analysis.
|
||||
interaction_mode:
|
||||
type: incremental_analysis
|
||||
output_format: structured_json
|
||||
@@ -84,7 +77,7 @@ execution_context:
|
||||
summary_active: true
|
||||
incremental_only: true
|
||||
memory_album_style: true
|
||||
world_state_tracking: true
|
||||
fact_tracking: true
|
||||
\`\`\`
|
||||
---
|
||||
Summary Specialist:
|
||||
@@ -103,15 +96,17 @@ Acknowledged. Now reviewing the incremental summarization specifications:
|
||||
破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
|
||||
|
||||
[Arc Progress Tracking]
|
||||
├─ trajectory: 完整弧光链描述(30字内)
|
||||
├─ trajectory: 当前阶段描述(15字内)
|
||||
├─ progress: 0.0 to 1.0
|
||||
└─ newMoment: 仅记录本次新增的关键时刻
|
||||
|
||||
[World State Maintenance]
|
||||
├─ 维护方式: Key-Value 覆盖(category + topic 为键)
|
||||
├─ 只输出有变化的条目
|
||||
├─ 清除时使用 cleared: true,不要填 content
|
||||
└─ 不记录情绪、衣着、临时动作
|
||||
[Fact Tracking - SPO Triples]
|
||||
├─ s: 主体(角色名/物品名)
|
||||
├─ p: 谓词(属性名/对X的看法)
|
||||
├─ o: 值(当前状态)
|
||||
├─ trend: 仅关系类填写
|
||||
├─ retracted: 删除标记
|
||||
└─ s+p 为键,相同键会覆盖旧值
|
||||
|
||||
Ready to process incremental summary requests with strict deduplication.`,
|
||||
|
||||
@@ -119,19 +114,19 @@ Ready to process incremental summary requests with strict deduplication.`,
|
||||
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
|
||||
2. Map current character list as baseline
|
||||
3. Note existing arc progress levels
|
||||
4. Identify established keywords
|
||||
5. Review current world state (category + topic baseline)`,
|
||||
5. Review current facts (SPO triples baseline)`,
|
||||
|
||||
assistantAskContent: `
|
||||
Summary Specialist:
|
||||
Existing summary fully analyzed and indexed. I understand:
|
||||
├─ Recorded events: Indexed for deduplication
|
||||
├─ Character relationships: Baseline mapped
|
||||
├─ Character list: Baseline mapped
|
||||
├─ Arc progress: Levels noted
|
||||
├─ Keywords: Current state acknowledged
|
||||
└─ World state: Baseline loaded
|
||||
└─ Facts: SPO baseline loaded
|
||||
|
||||
I will extract only genuinely NEW elements from the upcoming dialogue.
|
||||
Please provide the new dialogue content requiring incremental analysis.`,
|
||||
@@ -152,7 +147,7 @@ Before generating, observe the USER and analyze carefully:
|
||||
- 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)
|
||||
- What facts changed? (status/position/ownership/relationships)
|
||||
|
||||
## Output Format
|
||||
\`\`\`json
|
||||
@@ -160,7 +155,7 @@ Before generating, observe the USER and analyze carefully:
|
||||
"mindful_prelude": {
|
||||
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
|
||||
"dedup_analysis": "已有X个事件,本次识别Y个新事件",
|
||||
"world_changes": "识别到的世界状态变化概述,仅精选不记录则可能导致吃书的硬状态变化"
|
||||
"fact_changes": "识别到的事实变化概述"
|
||||
},
|
||||
"keywords": [
|
||||
{"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
|
||||
@@ -178,45 +173,35 @@ Before generating, observe the USER and analyze carefully:
|
||||
}
|
||||
],
|
||||
"newCharacters": ["仅本次首次出现的角色名"],
|
||||
"newRelationships": [
|
||||
{"from": "A", "to": "B", "label": "基于全局的关系描述", "trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融"}
|
||||
],
|
||||
"arcUpdates": [
|
||||
{"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
|
||||
{"name": "角色名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
|
||||
],
|
||||
"worldUpdate": [
|
||||
"factUpdates": [
|
||||
{
|
||||
"category": "status|inventory|knowledge|relation|rule",
|
||||
"topic": "主体名称(人/物/关系/规则)",
|
||||
"content": "当前状态描述",
|
||||
"cleared": true
|
||||
"s": "主体(角色名/物品名)",
|
||||
"p": "谓词(属性名/对X的看法)",
|
||||
"o": "当前值",
|
||||
"trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融",
|
||||
"retracted": false
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 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 必须包含「::」分隔符
|
||||
- 硬约束才记录,避免叙事化,确保少、硬、稳定、可覆盖
|
||||
- 动态清理:若发现已有条目中存在不适合作为硬约束的内容(如衣着打扮、临时情绪、琐碎动作),本次输出中用 cleared: true 删除
|
||||
## factUpdates 规则
|
||||
- s+p 为键,相同键会覆盖旧值
|
||||
- 状态类:s=角色名, p=属性(生死/位置/状态等), o=值
|
||||
- 关系类:s=角色A, p="对B的看法", o=描述, trend=趋势
|
||||
- 删除:设置 retracted: true(不需要填 o)
|
||||
- 只输出有变化的条目
|
||||
- 硬约束才记录,避免叙事化,确保少、硬、稳定
|
||||
|
||||
## CRITICAL NOTES
|
||||
- events.id 从 evt-{nextEventId} 开始编号
|
||||
- 仅输出【增量】内容,已有事件绝不重复
|
||||
- keywords 是全局关键词,综合已有+新增
|
||||
- causedBy 仅在因果明确时填写,允许为[],0-2个,详见上方 Causal_Chain 规则
|
||||
- worldUpdate 可为空数组
|
||||
- causedBy 仅在因果明确时填写,允许为[],0-2个
|
||||
- factUpdates 可为空数组
|
||||
- 合法JSON,字符串值内部避免英文双引号
|
||||
- 用朴实、白描、有烟火气的笔触记录,避免比喻和意象
|
||||
</meta_protocol>`,
|
||||
@@ -227,15 +212,14 @@ Before generating, observe the USER and analyze carefully:
|
||||
├─ New dialogue received: ✓ Content parsed
|
||||
├─ Deduplication engine: ✓ Active
|
||||
├─ Event classification: ✓ Ready
|
||||
├─ World state tracking: ✓ Enabled
|
||||
├─ Fact 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
|
||||
├─ Facts baseline: Loaded
|
||||
└─ Output specification: ✓ Defined in <meta_protocol>
|
||||
All checks passed. Beginning incremental extraction...
|
||||
{
|
||||
@@ -280,39 +264,23 @@ function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
|
||||
// 提示词构建
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function formatWorldForLLM(worldList) {
|
||||
if (!worldList?.length) {
|
||||
return '(空白,尚无世界状态记录)';
|
||||
function formatFactsForLLM(facts) {
|
||||
if (!facts?.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 lines = facts.map(f => {
|
||||
if (f.trend) {
|
||||
return `- ${f.s} | ${f.p} | ${f.o} [${f.trend}]`;
|
||||
}
|
||||
return `- ${f.s} | ${f.p} | ${f.o}`;
|
||||
});
|
||||
|
||||
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') || '(空白,尚无世界状态记录)';
|
||||
return lines.join('\n') || '(空白,尚无事实记录)';
|
||||
}
|
||||
|
||||
function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, historyRange, nextEventId, existingEventCount) {
|
||||
const worldStateText = formatWorldForLLM(existingWorld);
|
||||
function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, historyRange, nextEventId, existingEventCount) {
|
||||
const factsText = formatFactsForLLM(existingFacts);
|
||||
|
||||
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
|
||||
.replace(/\{nextEventId\}/g, String(nextEventId));
|
||||
@@ -324,7 +292,7 @@ function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, hi
|
||||
{ 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: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前事实图谱>\n${factsText}\n</当前事实图谱>` },
|
||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
|
||||
{ role: 'user', content: `<新对话内容>(${historyRange})\n${newHistoryText}\n</新对话内容>` }
|
||||
];
|
||||
@@ -378,7 +346,7 @@ export function parseSummaryJson(raw) {
|
||||
export async function generateSummary(options) {
|
||||
const {
|
||||
existingSummary,
|
||||
existingWorld,
|
||||
existingFacts,
|
||||
newHistoryText,
|
||||
historyRange,
|
||||
nextEventId,
|
||||
@@ -401,7 +369,7 @@ export async function generateSummary(options) {
|
||||
|
||||
const promptData = buildSummaryMessages(
|
||||
existingSummary,
|
||||
existingWorld,
|
||||
existingFacts,
|
||||
newHistoryText,
|
||||
historyRange,
|
||||
nextEventId,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { getContext } from "../../../../../../extensions.js";
|
||||
import { xbLog } from "../../../core/debug-core.js";
|
||||
import { getSummaryStore } from "../data/store.js";
|
||||
import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
|
||||
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
|
||||
import { recallMemory, buildQueryText } from "../vector/recall.js";
|
||||
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/chunk-store.js";
|
||||
@@ -111,10 +111,18 @@ function buildPostscript() {
|
||||
// 格式化函数
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatWorldLines(world) {
|
||||
return [...(world || [])]
|
||||
.sort((a, b) => (b.floor || 0) - (a.floor || 0))
|
||||
.map(w => `- ${w.topic}:${w.content}`);
|
||||
function formatFactsForInjection(facts) {
|
||||
const activeFacts = (facts || []).filter(f => !f.retracted);
|
||||
if (!activeFacts.length) return [];
|
||||
return activeFacts
|
||||
.sort((a, b) => (b.since || 0) - (a.since || 0))
|
||||
.map(f => {
|
||||
const since = f.since ? ` (#${f.since + 1})` : '';
|
||||
if (isRelationFact(f) && f.trend) {
|
||||
return `- ${f.s} ${f.p}: ${f.o} [${f.trend}]${since}`;
|
||||
}
|
||||
return `- ${f.s}的${f.p}: ${f.o}${since}`;
|
||||
});
|
||||
}
|
||||
|
||||
function formatArcLine(a) {
|
||||
@@ -189,7 +197,7 @@ function formatInjectionLog(stats, details, recentOrphanStats = null) {
|
||||
|
||||
// [1] 世界约束
|
||||
lines.push(` [1] 世界约束 (上限 2000)`);
|
||||
lines.push(` 选入: ${stats.world.count} 条 | 消耗: ${stats.world.tokens} tokens`);
|
||||
lines.push(` 选入: ${stats.facts.count} 条 | 消耗: ${stats.facts.tokens} tokens`);
|
||||
lines.push('');
|
||||
|
||||
// [2] 核心经历 + 过往背景
|
||||
@@ -229,7 +237,7 @@ function formatInjectionLog(stats, details, recentOrphanStats = null) {
|
||||
const pctStr = pct(tokens, total) + '%';
|
||||
return ` ${label.padEnd(6)} ${'█'.repeat(width).padEnd(30)} ${String(tokens).padStart(5)} (${pctStr})`;
|
||||
};
|
||||
lines.push(bar(stats.world.tokens, '约束'));
|
||||
lines.push(bar(stats.facts.tokens, '约束'));
|
||||
lines.push(bar(stats.events.tokens + stats.evidence.tokens, '经历'));
|
||||
lines.push(bar(stats.orphans.tokens, '远期'));
|
||||
lines.push(bar(recentOrphanStats?.tokens || 0, '待整理'));
|
||||
@@ -263,9 +271,9 @@ function buildNonVectorPrompt(store) {
|
||||
const data = store.json || {};
|
||||
const sections = [];
|
||||
|
||||
if (data.world?.length) {
|
||||
const lines = formatWorldLines(data.world);
|
||||
sections.push(`[世界约束] 已确立的事实\n${lines.join("\n")}`);
|
||||
const factLines = formatFactsForInjection(getFacts(store));
|
||||
if (factLines.length) {
|
||||
sections.push(`[定了的事] 已确立的事实\n${factLines.join("\n")}`);
|
||||
}
|
||||
|
||||
if (data.events?.length) {
|
||||
@@ -330,7 +338,7 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
const assembled = {
|
||||
world: { lines: [], tokens: 0 },
|
||||
facts: { lines: [], tokens: 0 },
|
||||
arcs: { lines: [], tokens: 0 },
|
||||
events: { direct: [], similar: [] },
|
||||
orphans: { lines: [], tokens: 0 },
|
||||
@@ -339,7 +347,7 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
|
||||
|
||||
const injectionStats = {
|
||||
budget: { max: TOTAL_BUDGET_MAX, used: 0 },
|
||||
world: { count: 0, tokens: 0 },
|
||||
facts: { count: 0, tokens: 0 },
|
||||
arcs: { count: 0, tokens: 0 },
|
||||
events: { selected: 0, tokens: 0 },
|
||||
evidence: { attached: 0, tokens: 0 },
|
||||
@@ -360,16 +368,16 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// [优先级 1] 世界约束 - 最高优先级
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
const worldLines = formatWorldLines(data.world);
|
||||
if (worldLines.length) {
|
||||
const factLines = formatFactsForInjection(getFacts(store));
|
||||
if (factLines.length) {
|
||||
const l3Budget = { used: 0, max: Math.min(L3_MAX, total.max - total.used) };
|
||||
for (const line of worldLines) {
|
||||
if (!pushWithBudget(assembled.world.lines, line, l3Budget)) break;
|
||||
for (const line of factLines) {
|
||||
if (!pushWithBudget(assembled.facts.lines, line, l3Budget)) break;
|
||||
}
|
||||
assembled.world.tokens = l3Budget.used;
|
||||
assembled.facts.tokens = l3Budget.used;
|
||||
total.used += l3Budget.used;
|
||||
injectionStats.world.count = assembled.world.lines.length;
|
||||
injectionStats.world.tokens = l3Budget.used;
|
||||
injectionStats.facts.count = assembled.facts.lines.length;
|
||||
injectionStats.facts.tokens = l3Budget.used;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
@@ -599,8 +607,8 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const sections = [];
|
||||
// 1. 世界约束 → 定了的事
|
||||
if (assembled.world.lines.length) {
|
||||
sections.push(`[定了的事] 已确立的事实\n${assembled.world.lines.join("\n")}`);
|
||||
if (assembled.facts.lines.length) {
|
||||
sections.push(`[定了的事] 已确立的事实\n${assembled.facts.lines.join("\n")}`);
|
||||
}
|
||||
// 2. 核心经历 → 印象深的事
|
||||
if (assembled.events.direct.length) {
|
||||
@@ -632,6 +640,8 @@ if (!sections.length) {
|
||||
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
|
||||
`${buildPostscript()}`;
|
||||
|
||||
// ★ 修复:先写回预算统计,再生成日志
|
||||
injectionStats.budget.used = total.used + (assembled.recentOrphans.tokens || 0);
|
||||
const injectionLogText = formatInjectionLog(injectionStats, details, recentOrphanStats);
|
||||
|
||||
return { promptText, injectionLogText, injectionStats };
|
||||
@@ -835,4 +845,4 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
|
||||
}
|
||||
|
||||
return { text: finalText, logText: (recallResult.logText || "") + (injectionLogText || "") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
events: { title: '编辑事件时间线', hint: '编辑时,每个事件要素都应完整' },
|
||||
characters: { title: '编辑人物关系', hint: '编辑时,每个要素都应完整' },
|
||||
arcs: { title: '编辑角色弧光', hint: '编辑时,每个要素都应完整' },
|
||||
world: { title: '编辑世界状态', hint: '每行一条:category|topic|content。清除用:category|topic|(留空)或 category|topic|cleared' }
|
||||
facts: { title: '编辑事实图谱', hint: '每行一条:主体|谓词|值|趋势(可选)。删除用:主体|谓词|(留空值)' }
|
||||
};
|
||||
|
||||
const TREND_COLORS = {
|
||||
@@ -116,7 +116,7 @@
|
||||
vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } }
|
||||
};
|
||||
|
||||
let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] };
|
||||
let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], facts: [] };
|
||||
let localGenerating = false;
|
||||
let vectorGenerating = false;
|
||||
let relationChart = null;
|
||||
@@ -1415,9 +1415,14 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
|
||||
|
||||
if (section === 'keywords') {
|
||||
ta.value = summaryData.keywords.map(k => `${k.text}|${k.weight || '一般'}`).join('\n');
|
||||
} else if (section === 'world') {
|
||||
ta.value = (summaryData.world || [])
|
||||
.map(w => `${w.category || ''}|${w.topic || ''}|${w.content || ''}`)
|
||||
} else if (section === 'facts') {
|
||||
ta.value = (summaryData.facts || [])
|
||||
.filter(f => !f.retracted)
|
||||
.map(f => {
|
||||
const parts = [f.s, f.p, f.o];
|
||||
if (f.trend) parts.push(f.trend);
|
||||
return parts.join('|');
|
||||
})
|
||||
.join('\n');
|
||||
} else {
|
||||
ta.classList.add('hidden');
|
||||
@@ -1496,21 +1501,32 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
|
||||
moments
|
||||
}, oldArc);
|
||||
}).filter(a => a.name || a.trajectory || a.moments?.length);
|
||||
} else if (section === 'world') {
|
||||
const oldWorldMap = new Map((summaryData.world || []).map(w => [`${w.category}|${w.topic}`, w]));
|
||||
} else if (section === 'facts') {
|
||||
const oldMap = new Map((summaryData.facts || []).map(f => [`${f.s}::${f.p}`, f]));
|
||||
parsed = ta.value
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const parts = line.split('|').map(s => s.trim());
|
||||
const category = parts[0];
|
||||
const topic = parts[1];
|
||||
const content = parts.slice(2).join('|').trim();
|
||||
if (!category || !topic) return null;
|
||||
if (!content || content.toLowerCase() === 'cleared') return null;
|
||||
const key = `${category}|${topic}`;
|
||||
return preserveAddedAt({ category, topic, content }, oldWorldMap.get(key));
|
||||
const s = parts[0];
|
||||
const p = parts[1];
|
||||
const o = parts[2];
|
||||
const trend = parts[3];
|
||||
if (!s || !p) return null;
|
||||
if (!o) return null;
|
||||
const key = `${s}::${p}`;
|
||||
const old = oldMap.get(key);
|
||||
const fact = {
|
||||
id: old?.id || `f-${Date.now()}`,
|
||||
s, p, o,
|
||||
since: old?.since ?? 0,
|
||||
_addedAt: old?._addedAt ?? 0,
|
||||
};
|
||||
if (/^对.+的/.test(p) && trend) {
|
||||
fact.trend = trend;
|
||||
}
|
||||
return fact;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -1526,7 +1542,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
|
||||
else if (section === 'events') { renderTimeline(parsed); $('stat-events').textContent = parsed.length; }
|
||||
else if (section === 'characters') renderRelations(parsed);
|
||||
else if (section === 'arcs') renderArcs(parsed);
|
||||
else if (section === 'world') renderWorldState(parsed);
|
||||
else if (section === 'facts') renderFacts(parsed);
|
||||
|
||||
closeEditor();
|
||||
}
|
||||
@@ -1565,7 +1581,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
|
||||
if (p.events) renderTimeline(p.events);
|
||||
if (p.characters) renderRelations(p.characters);
|
||||
if (p.arcs) renderArcs(p.arcs);
|
||||
if (p.world) renderWorldState(p.world);
|
||||
if (p.facts) renderFacts(p.facts);
|
||||
$('stat-events').textContent = p.events?.length || 0;
|
||||
if (p.lastSummarizedMesId != null) $('stat-summarized').textContent = p.lastSummarizedMesId + 1;
|
||||
if (p.stats) updateStats(p.stats);
|
||||
@@ -1582,12 +1598,12 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
|
||||
$('stat-summarized').textContent = 0;
|
||||
$('stat-pending').textContent = t;
|
||||
$('summarized-count').textContent = 0;
|
||||
summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] };
|
||||
summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], facts: [] };
|
||||
renderKeywords([]);
|
||||
renderTimeline([]);
|
||||
renderRelations(null);
|
||||
renderArcs([]);
|
||||
renderWorldState([]);
|
||||
renderFacts([]);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1829,7 +1845,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
|
||||
renderKeywords([]);
|
||||
renderTimeline([]);
|
||||
renderArcs([]);
|
||||
renderWorldState([]);
|
||||
renderFacts([]);
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -1845,51 +1861,53 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
|
||||
}
|
||||
|
||||
|
||||
function renderWorldState(world) {
|
||||
summaryData.world = world || [];
|
||||
function renderFacts(facts) {
|
||||
summaryData.facts = facts || [];
|
||||
|
||||
const container = $('world-state-list');
|
||||
if (!container) return;
|
||||
const container = $('facts-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!world?.length) {
|
||||
setHtml(container, '<div class="empty">暂无世界状态</div>');
|
||||
return;
|
||||
}
|
||||
const activeFacts = (facts || []).filter(f => !f.retracted);
|
||||
|
||||
const labels = {
|
||||
status: '状态',
|
||||
inventory: '物品',
|
||||
knowledge: '认知',
|
||||
relation: '关系',
|
||||
rule: '规则'
|
||||
};
|
||||
|
||||
const categoryOrder = ['status', 'inventory', 'relation', 'knowledge', 'rule'];
|
||||
|
||||
const grouped = {};
|
||||
world.forEach(w => {
|
||||
const cat = w.category || 'other';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(w);
|
||||
});
|
||||
|
||||
const html = categoryOrder
|
||||
.filter(cat => grouped[cat]?.length)
|
||||
.map(cat => {
|
||||
const items = grouped[cat].sort((a, b) => (b.floor || 0) - (a.floor || 0));
|
||||
return `
|
||||
<div class="world-group">
|
||||
<div class="world-group-title">${labels[cat] || cat}</div>
|
||||
${items.map(w => `
|
||||
<div class="world-item">
|
||||
<span class="world-topic">${h(w.topic)}</span>
|
||||
<span class="world-content">${h(w.content)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
setHtml(container, html || '<div class="empty">暂无世界状态</div>');
|
||||
if (!activeFacts.length) {
|
||||
setHtml(container, '<div class="empty">暂无事实记录</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const relations = activeFacts.filter(f => /^对.+的/.test(f.p));
|
||||
const states = activeFacts.filter(f => !/^对.+的/.test(f.p));
|
||||
|
||||
let html = '';
|
||||
|
||||
if (states.length) {
|
||||
html += `<div class="fact-group">
|
||||
<div class="fact-group-title">状态/属性</div>
|
||||
${states.map(f => `
|
||||
<div class="fact-item">
|
||||
<span class="fact-subject">${h(f.s)}</span>
|
||||
<span class="fact-predicate">${h(f.p)}</span>
|
||||
<span class="fact-object">${h(f.o)}</span>
|
||||
<span class="fact-since">#${(f.since || 0) + 1}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (relations.length) {
|
||||
html += `<div class="fact-group">
|
||||
<div class="fact-group-title">人物关系</div>
|
||||
${relations.map(f => `
|
||||
<div class="fact-item">
|
||||
<span class="fact-subject">${h(f.s)}</span>
|
||||
<span class="fact-predicate">${h(f.p)}</span>
|
||||
<span class="fact-object">${h(f.o)}</span>
|
||||
${f.trend ? `<span class="fact-trend ${TREND_CLASS[f.trend] || ''}">${h(f.trend)}</span>` : ''}
|
||||
<span class="fact-since">#${(f.since || 0) + 1}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
setHtml(container, html);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -6,6 +6,101 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Facts (替换 World State)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.facts {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.facts-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.fact-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fact-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fact-group-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--txt3);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--bdr2);
|
||||
}
|
||||
|
||||
.fact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--bdr2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fact-item:hover {
|
||||
border-color: var(--bdr);
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
.fact-subject {
|
||||
font-weight: 600;
|
||||
color: var(--txt);
|
||||
}
|
||||
|
||||
.fact-predicate {
|
||||
color: var(--txt3);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.fact-predicate::before {
|
||||
content: '→';
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.fact-object {
|
||||
color: var(--hl);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fact-trend {
|
||||
font-size: 0.6875rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fact-since {
|
||||
font-size: 0.625rem;
|
||||
color: var(--txt3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.facts-list {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.fact-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--bg2: #fff;
|
||||
|
||||
@@ -80,13 +80,13 @@
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<!-- World State -->
|
||||
<section class="card world-state">
|
||||
<!-- Facts -->
|
||||
<section class="card facts">
|
||||
<div class="sec-head">
|
||||
<div class="sec-title">世界状态</div>
|
||||
<button class="sec-btn" data-section="world">编辑</button>
|
||||
<div class="sec-title">事实图谱</div>
|
||||
<button class="sec-btn" data-section="facts">编辑</button>
|
||||
</div>
|
||||
<div class="world-state-list scroll" id="world-state-list"></div>
|
||||
<div class="facts-list scroll" id="facts-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Relations -->
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getAllEventVectors, getAllChunkVectors, getChunksByFloors, getMeta } fr
|
||||
import { embed, getEngineFingerprint } from './embedder.js';
|
||||
import { xbLog } from '../../../core/debug-core.js';
|
||||
import { getContext } from '../../../../../../extensions.js';
|
||||
import { getSummaryStore } from '../data/store.js';
|
||||
import { getSummaryStore, getFacts, getNewCharacters, isRelationFact } from '../data/store.js';
|
||||
import { filterText } from './text-filter.js';
|
||||
import {
|
||||
searchStateAtoms,
|
||||
@@ -258,6 +258,23 @@ function buildEntityLexicon(store, allEvents) {
|
||||
const userName = normalize(name1);
|
||||
const set = new Set();
|
||||
|
||||
const facts = getFacts(store);
|
||||
for (const f of facts) {
|
||||
if (f?.retracted) continue;
|
||||
const s = normalize(f?.s);
|
||||
if (s) set.add(s);
|
||||
if (isRelationFact(f)) {
|
||||
const o = normalize(f?.o);
|
||||
if (o) set.add(o);
|
||||
}
|
||||
}
|
||||
|
||||
const chars = getNewCharacters(store);
|
||||
for (const m of chars || []) {
|
||||
const s = normalize(typeof m === 'string' ? m : m?.name);
|
||||
if (s) set.add(s);
|
||||
}
|
||||
|
||||
for (const e of allEvents || []) {
|
||||
for (const p of e.participants || []) {
|
||||
const s = normalize(p);
|
||||
@@ -265,30 +282,11 @@ function buildEntityLexicon(store, allEvents) {
|
||||
}
|
||||
}
|
||||
|
||||
const json = store?.json || {};
|
||||
|
||||
for (const m of json.characters?.main || []) {
|
||||
const s = normalize(typeof m === 'string' ? m : m?.name);
|
||||
if (s) set.add(s);
|
||||
}
|
||||
|
||||
for (const a of json.arcs || []) {
|
||||
for (const a of store?.json?.arcs || []) {
|
||||
const s = normalize(a?.name);
|
||||
if (s) set.add(s);
|
||||
}
|
||||
|
||||
for (const w of json.world || []) {
|
||||
const t = normalize(w?.topic);
|
||||
if (t && !t.includes('::')) set.add(t);
|
||||
}
|
||||
|
||||
for (const r of json.characters?.relationships || []) {
|
||||
const from = normalize(r?.from);
|
||||
const to = normalize(r?.to);
|
||||
if (from) set.add(from);
|
||||
if (to) set.add(to);
|
||||
}
|
||||
|
||||
const stop = new Set([userName, '我', '你', '他', '她', '它', '用户', '角色', 'assistant'].map(normalize).filter(Boolean));
|
||||
|
||||
return Array.from(set)
|
||||
@@ -296,6 +294,79 @@ function buildEntityLexicon(store, allEvents) {
|
||||
.slice(0, 5000);
|
||||
}
|
||||
|
||||
function buildFactGraph(facts) {
|
||||
const graph = new Map();
|
||||
|
||||
for (const f of facts || []) {
|
||||
if (f?.retracted) continue;
|
||||
if (!isRelationFact(f)) continue;
|
||||
|
||||
const s = normalize(f?.s);
|
||||
const o = normalize(f?.o);
|
||||
if (!s || !o) continue;
|
||||
|
||||
if (!graph.has(s)) graph.set(s, new Set());
|
||||
if (!graph.has(o)) graph.set(o, new Set());
|
||||
graph.get(s).add(o);
|
||||
graph.get(o).add(s);
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
function expandByFacts(presentEntities, facts, maxDepth = 2) {
|
||||
const graph = buildFactGraph(facts);
|
||||
const expanded = new Map();
|
||||
const seeds = Array.from(presentEntities || []).map(normalize).filter(Boolean);
|
||||
|
||||
seeds.forEach(e => expanded.set(e, 1.0));
|
||||
|
||||
let frontier = [...seeds];
|
||||
for (let d = 1; d <= maxDepth && frontier.length; d++) {
|
||||
const next = [];
|
||||
const decay = Math.pow(0.5, d);
|
||||
|
||||
for (const e of frontier) {
|
||||
const neighbors = graph.get(e);
|
||||
if (!neighbors) continue;
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
if (!expanded.has(neighbor)) {
|
||||
expanded.set(neighbor, decay);
|
||||
next.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frontier = next.slice(0, 20);
|
||||
}
|
||||
|
||||
return Array.from(expanded.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 15)
|
||||
.map(([term]) => term);
|
||||
}
|
||||
|
||||
function stripFloorTag(s) {
|
||||
return String(s || '').replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '').trim();
|
||||
}
|
||||
|
||||
export function buildEventEmbeddingText(ev) {
|
||||
const parts = [];
|
||||
|
||||
if (ev?.title) parts.push(ev.title);
|
||||
|
||||
const people = (ev?.participants || []).join(' ');
|
||||
if (people) parts.push(people);
|
||||
|
||||
if (ev?.type) parts.push(ev.type);
|
||||
|
||||
const summary = stripFloorTag(ev?.summary);
|
||||
if (summary) parts.push(summary);
|
||||
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从分段消息中提取实体,继承消息权重
|
||||
* @param {string[]} segments
|
||||
@@ -621,6 +692,7 @@ function formatRecallLog({
|
||||
chunkPreFilterStats = null,
|
||||
l0Results = [],
|
||||
textGapInfo = null,
|
||||
expandedTerms = [],
|
||||
}) {
|
||||
const lines = [
|
||||
'\u2554' + '\u2550'.repeat(62) + '\u2557',
|
||||
@@ -663,6 +735,9 @@ function formatRecallLog({
|
||||
} else {
|
||||
lines.push(' (无)');
|
||||
}
|
||||
if (expandedTerms?.length) {
|
||||
lines.push(` 扩散: ${expandedTerms.join('、')}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('\u250c' + '\u2500'.repeat(61) + '\u2510');
|
||||
@@ -757,12 +832,15 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options =
|
||||
const lexicon = buildEntityLexicon(store, allEvents);
|
||||
const queryEntityWeights = extractEntitiesWithWeights(segments, weights, lexicon);
|
||||
const queryEntities = Array.from(queryEntityWeights.keys());
|
||||
const facts = getFacts(store);
|
||||
const expandedTerms = expandByFacts(queryEntities, facts, 2);
|
||||
|
||||
// 构建文本查询串:最后一条消息 + 实体 + 关键词
|
||||
const lastSeg = segments[segments.length - 1] || '';
|
||||
const queryTextForSearch = [
|
||||
lastSeg,
|
||||
...queryEntities,
|
||||
...expandedTerms,
|
||||
...(store?.json?.keywords || []).slice(0, 5).map(k => k.text),
|
||||
].join(' ');
|
||||
|
||||
@@ -824,6 +902,7 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options =
|
||||
chunkPreFilterStats,
|
||||
l0Results,
|
||||
textGapInfo,
|
||||
expandedTerms,
|
||||
});
|
||||
|
||||
console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold');
|
||||
|
||||
Reference in New Issue
Block a user