Improve story summary assembly and UI

This commit is contained in:
2026-01-27 22:51:44 +08:00
parent 4043e120ae
commit 1dfa9f95f2
5 changed files with 266 additions and 124 deletions

View File

@@ -16,7 +16,7 @@ import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory, buildQueryText } from "../vector/recall.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors } from "../vector/chunk-store.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/chunk-store.js";
const MODULE_ID = "summaryPrompt";
const SUMMARY_PROMPT_KEY = "LittleWhiteBox_StorySummary";
@@ -39,9 +39,11 @@ function canNotifyRecallFail() {
// 预算常量(向量模式使用)
// ─────────────────────────────────────────────────────────────────────────────
const BUDGET = { total: 10000 };
const L3_MAX = Math.floor(BUDGET.total * 0.20); // 2000
const ARCS_MAX = Math.floor(BUDGET.total * 0.15); // 1500
const MAIN_BUDGET_MAX = 10000; // 主装配预算(世界/事件/远期/弧光)
const RECENT_ORPHAN_MAX = 5000; // [待整理] 独立预算
const TOTAL_BUDGET_MAX = 15000; // 总预算(用于日志显示)
const L3_MAX = 2000;
const ARCS_MAX = 1500;
// ─────────────────────────────────────────────────────────────────────────────
// 工具函数
@@ -173,7 +175,7 @@ function formatCausalEventLine(causalItem, causalById) {
// 装配日志(开发调试用)
// ─────────────────────────────────────────────────────────────────────────────
function formatInjectionLog(stats, details) {
function formatInjectionLog(stats, details, recentOrphanStats = null) {
const pct = (n, d) => (d > 0 ? Math.round((n / d) * 100) : 0);
const lines = [
@@ -190,45 +192,52 @@ function formatInjectionLog(stats, details) {
// 世界状态
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [1] 世界状态 (上限 20% = 2000) │");
lines.push("│ [1] 世界约束 (上限 2000) │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 注入: ${stats.world.count} 条 | ${stats.world.tokens} tokens`);
lines.push("");
// 事件
// 核心经历 + 过往背景
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [2] 事件(含证据) │");
lines.push("│ [2] 核心经历 + 过往背景(含证据) │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 选入: ${stats.events.selected} 条 | 事件本体: ${stats.events.tokens} tokens`);
lines.push(` 挂载证据: ${stats.evidence.attached} 条 | 证据: ${stats.evidence.tokens} tokens`);
lines.push(` DIRECT: ${details.directCount || 0} | SIMILAR: ${details.similarCount || 0}`);
lines.push(` 核心: ${details.directCount || 0} | 过往: ${details.similarCount || 0}`);
if (details.eventList?.length) {
lines.push(" ────────────────────────────────────────");
details.eventList.slice(0, 20).forEach((ev, i) => {
const type = ev.isDirect ? "D" : "S";
const type = ev.isDirect ? "核心" : "过往";
const hasE = ev.hasEvidence ? " +E" : "";
const title = (ev.title || "(无标题)").slice(0, 32);
lines.push(` ${String(i + 1).padStart(2)}. [${type}${hasE}] ${title} (${ev.tokens} tok, sim=${ev.similarity.toFixed(3)})`);
lines.push(` ${String(i + 1).padStart(2)}. [${type}${hasE}] ${title} (${ev.tokens}tok)`);
});
if (details.eventList.length > 20) lines.push(` ... 还有 ${details.eventList.length - 20}`);
}
lines.push("");
// 碎片
// 远期片段
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [3] 记忆碎片(按楼层从早到晚) │");
lines.push("│ [3] 远期片段(已总结范围) │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 注入: ${stats.orphans.injected} 条 | ${stats.orphans.tokens} tokens`);
lines.push("");
// 弧光
// 待整理
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [4] 人物弧光(上限 15% = 1500放在最底 │");
lines.push("│ [4] 待整理(未总结范围,独立预算 5000 │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 注入: ${recentOrphanStats?.injected || 0} 条 | ${recentOrphanStats?.tokens || 0} tokens`);
lines.push(` 楼层范围: ${recentOrphanStats?.floorRange || "N/A"}`);
lines.push("");
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [5] 人物弧光(上限 1500 │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 注入: ${stats.arcs.count} 条 | ${stats.arcs.tokens} tokens`);
lines.push("");
// 预算条形
// 预算条形
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ 【预算分布】 │");
lines.push("└─────────────────────────────────────────────────────────────┘");
@@ -238,10 +247,11 @@ function formatInjectionLog(stats, details) {
const pctStr = pct(tokens, total) + "%";
return ` ${label.padEnd(6)} ${"█".repeat(width).padEnd(40)} ${String(tokens).padStart(5)} (${pctStr})`;
};
lines.push(bar(stats.world.tokens, "世界"));
lines.push(bar(stats.events.tokens, "事件"));
lines.push(bar(stats.world.tokens, "约束"));
lines.push(bar(stats.events.tokens, "经历"));
lines.push(bar(stats.evidence.tokens, "证据"));
lines.push(bar(stats.orphans.tokens, "碎片"));
lines.push(bar(stats.orphans.tokens, "远期"));
lines.push(bar(recentOrphanStats?.tokens || 0, "待整理"));
lines.push(bar(stats.arcs.tokens, "弧光"));
lines.push(bar(stats.budget.max - stats.budget.used, "剩余"));
lines.push("");
@@ -260,7 +270,7 @@ function buildNonVectorPrompt(store) {
if (data.world?.length) {
const lines = formatWorldLines(data.world);
sections.push(`[世界状态] 请严格遵守\n${lines.join("\n")}`);
sections.push(`[世界约束] 规则手册,请严格遵守\n${lines.join("\n")}`);
}
if (data.events?.length) {
@@ -334,13 +344,24 @@ export async function injectNonVectorPrompt(postToFrame = null) {
// 向量模式:预算装配(世界 → 事件(带证据) → 碎片 → 弧光)
// ─────────────────────────────────────────────────────────────
async function buildVectorPrompt(store, recallResult, causalById, queryEntities = []) {
async function buildVectorPrompt(store, recallResult, causalById, queryEntities = [], meta = null) {
const data = store.json || {};
const total = { used: 0, max: BUDGET.total };
const sections = [];
const total = { used: 0, max: MAIN_BUDGET_MAX };
// ═══════════════════════════════════════════════════════════════════
// 预装配各层内容(先计算预算,后按顺序拼接)
// ═══════════════════════════════════════════════════════════════════
const assembled = {
world: { lines: [], tokens: 0 },
arcs: { lines: [], tokens: 0 },
events: { direct: [], similar: [] },
orphans: { lines: [], tokens: 0 },
recentOrphans: { lines: [], tokens: 0 },
};
const injectionStats = {
budget: { max: BUDGET.total, used: 0 },
budget: { max: TOTAL_BUDGET_MAX, used: 0 },
world: { count: 0, tokens: 0 },
arcs: { count: 0, tokens: 0 },
events: { selected: 0, tokens: 0 },
@@ -348,29 +369,67 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
orphans: { injected: 0, tokens: 0 },
};
const recentOrphanStats = {
injected: 0,
tokens: 0,
floorRange: "N/A",
};
const details = {
eventList: [],
directCount: 0,
similarCount: 0,
};
// [世界状态]20%
// ═══════════════════════════════════════════════════════════════════
// [优先级 1] 世界约束 - 最高优先级
// ═══════════════════════════════════════════════════════════════════
const worldLines = formatWorldLines(data.world);
if (worldLines.length) {
const l3 = { used: 0, max: Math.min(L3_MAX, total.max - total.used) };
const lines = [];
const l3Budget = { used: 0, max: Math.min(L3_MAX, total.max - total.used) };
for (const line of worldLines) {
if (!pushWithBudget(lines, line, l3)) break;
if (!pushWithBudget(assembled.world.lines, line, l3Budget)) break;
}
if (lines.length) {
sections.push(`[世界状态] 请严格遵守\n${lines.join("\n")}`);
total.used += l3.used;
injectionStats.world.count = lines.length;
injectionStats.world.tokens = l3.used;
assembled.world.tokens = l3Budget.used;
total.used += l3Budget.used;
injectionStats.world.count = assembled.world.lines.length;
injectionStats.world.tokens = l3Budget.used;
}
// ═══════════════════════════════════════════════════════════════════
// [优先级 2] 人物弧光 - 预留预算(稍后再拼接到末尾)
// ═══════════════════════════════════════════════════════════════════
if (data.arcs?.length && total.used < total.max) {
const { name1 } = getContext();
const userName = String(name1 || "").trim();
const relevant = new Set(
[userName, ...(queryEntities || [])]
.map(s => String(s || "").trim())
.filter(Boolean)
);
const filtered = (data.arcs || []).filter(a => {
const n = String(a?.name || "").trim();
return n && relevant.has(n);
});
if (filtered.length) {
const arcBudget = { used: 0, max: Math.min(ARCS_MAX, total.max - total.used) };
for (const a of filtered) {
const line = formatArcLine(a);
if (!pushWithBudget(assembled.arcs.lines, line, arcBudget)) break;
}
assembled.arcs.tokens = arcBudget.used;
total.used += arcBudget.used;
injectionStats.arcs.count = assembled.arcs.lines.length;
injectionStats.arcs.tokens = arcBudget.used;
}
}
// 事件(含证据)
// ═══════════════════════════════════════════════════════════════════
// [优先级 3] 事件 + 证据
// ═══════════════════════════════════════════════════════════════════
const recalledEvents = (recallResult?.events || []).filter(e => e?.event?.summary);
const chunks = recallResult?.chunks || [];
const usedChunkIds = new Set();
@@ -483,70 +542,104 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
details.directCount = selectedDirectTexts.length;
details.similarCount = selectedSimilarTexts.length;
assembled.events.direct = selectedDirectTexts;
assembled.events.similar = selectedSimilarTexts;
if (selectedDirectTexts.length) {
sections.push(`[亲身经历]\n\n${selectedDirectTexts.join("\n\n")}`);
}
if (selectedSimilarTexts.length) {
sections.push(`[相关背景]\n\n${selectedSimilarTexts.join("\n\n")}`);
}
// ═══════════════════════════════════════════════════════════════════
// [优先级 4] 远期片段(已总结范围的 orphan chunks
// ═══════════════════════════════════════════════════════════════════
const lastSummarized = store.lastSummarizedMesId ?? -1;
const lastChunkFloor = meta?.lastChunkFloor ?? -1;
const keepVisible = store.keepVisibleCount ?? 3;
// [记忆碎片]orphans 按楼层从早到晚,完整 chunk
if (chunks.length && total.used < total.max) {
const orphans = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.filter(c => c.floor <= lastSummarized)
.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0)));
const l1 = { used: 0, max: total.max - total.used };
const lines = [];
const l1Budget = { used: 0, max: total.max - total.used };
for (const c of orphans) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(lines, line, l1)) break;
if (!pushWithBudget(assembled.orphans.lines, line, l1Budget)) break;
injectionStats.orphans.injected++;
}
if (lines.length) {
sections.push(`[记忆碎片]\n${lines.join("\n")}`);
total.used += l1.used;
injectionStats.orphans.tokens = l1.used;
}
assembled.orphans.tokens = l1Budget.used;
total.used += l1Budget.used;
injectionStats.orphans.tokens = l1Budget.used;
}
// [人物弧光]:放最底,且上限 15%(只保留 user + queryEntities
if (data.arcs?.length && total.used < total.max) {
const { name1 } = getContext();
const userName = String(name1 || "").trim();
// ═══════════════════════════════════════════════════════════════════
// [独立预算] 待整理(未总结范围,独立 5000
// ═══════════════════════════════════════════════════════════════════
const relevant = new Set(
[userName, ...(queryEntities || [])]
.map(s => String(s || "").trim())
.filter(Boolean)
);
// 近期范围:(lastSummarized, lastChunkFloor - keepVisible]
const recentStart = lastSummarized + 1;
const recentEnd = lastChunkFloor - keepVisible;
const filtered = (data.arcs || []).filter(a => {
const n = String(a?.name || "").trim();
return n && relevant.has(n);
});
if (chunks.length && recentEnd >= recentStart) {
const recentOrphans = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.filter(c => c.floor >= recentStart && c.floor <= recentEnd)
.sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0)));
if (filtered.length) {
const arcBudget = { used: 0, max: Math.min(ARCS_MAX, total.max - total.used) };
const arcLines = [];
for (const a of filtered) {
const line = formatArcLine(a);
if (!pushWithBudget(arcLines, line, arcBudget)) break;
}
const recentBudget = { used: 0, max: RECENT_ORPHAN_MAX };
if (arcLines.length) {
sections.push(`[人物弧光]\n${arcLines.join("\n")}`);
total.used += arcBudget.used;
injectionStats.arcs.count = arcLines.length;
injectionStats.arcs.tokens = arcBudget.used;
}
for (const c of recentOrphans) {
const line = formatChunkFullLine(c);
if (!pushWithBudget(assembled.recentOrphans.lines, line, recentBudget)) break;
recentOrphanStats.injected++;
}
assembled.recentOrphans.tokens = recentBudget.used;
recentOrphanStats.tokens = recentBudget.used;
recentOrphanStats.floorRange = `${recentStart + 1}~${recentEnd + 1}`;
}
injectionStats.budget.used = total.used;
// ═══════════════════════════════════════════════════════════════════
// 按注入顺序拼接 sections
// ═══════════════════════════════════════════════════════════════════
const sections = [];
// 1. 世界约束
if (assembled.world.lines.length) {
sections.push(`[世界约束] 规则手册,请严格遵守\n${assembled.world.lines.join("\n")}`);
}
// 2. 核心经历
if (assembled.events.direct.length) {
sections.push(`[核心经历] 深刻的记忆\n\n${assembled.events.direct.join("\n\n")}`);
}
// 3. 过往背景
if (assembled.events.similar.length) {
sections.push(`[过往背景] 听别人说起或比较模糊的往事\n\n${assembled.events.similar.join("\n\n")}`);
}
// 4. 远期片段
if (assembled.orphans.lines.length) {
sections.push(`[远期片段] 记忆里残留的一些老画面\n${assembled.orphans.lines.join("\n")}`);
}
// 5. 待整理
if (assembled.recentOrphans.lines.length) {
sections.push(`[待整理] 最近发生但尚未梳理的原始记忆\n${assembled.recentOrphans.lines.join("\n")}`);
}
// 6. 人物弧光(最后注入,但预算已在优先级 2 预留)
if (assembled.arcs.lines.length) {
sections.push(`[人物弧光]\n${assembled.arcs.lines.join("\n")}`);
}
// ═══════════════════════════════════════════════════════════════════
// 统计 & 返回
// ═══════════════════════════════════════════════════════════════════
// 总预算 = 主装配 + 待整理
injectionStats.budget.used = total.used + (recentOrphanStats.tokens || 0);
if (!sections.length) {
return { promptText: "", injectionLogText: "", injectionStats };
@@ -557,7 +650,7 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
`${buildPostscript()}`;
const injectionLogText = formatInjectionLog(injectionStats, details);
const injectionLogText = formatInjectionLog(injectionStats, details, recentOrphanStats);
return { promptText, injectionLogText, injectionStats };
}
@@ -643,6 +736,10 @@ export async function recallAndInjectPrompt(excludeLastAi = false, hooks = {}) {
return;
}
const { chatId } = getContext();
// meta 用于 lastChunkFloor供 buildVectorPrompt 分桶)
const meta = chatId ? await getMeta(chatId) : null;
let recallResult = null;
let causalById = new Map();
@@ -743,7 +840,8 @@ export async function recallAndInjectPrompt(excludeLastAi = false, hooks = {}) {
store,
recallResult,
causalById,
recallResult?.queryEntities || []
recallResult?.queryEntities || [],
meta
);
// 写入 extension_prompts真正注入