Files
LittleWhiteBox/modules/story-summary/generate/prompt.js

975 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Story Summary - Prompt Injection
// 注入格式:世界状态 → 亲身经历(含闪回) → 相关背景(含闪回) → 记忆碎片 → 人物弧光
import { getContext } from "../../../../../../extensions.js";
import {
extension_prompts,
extension_prompt_types,
extension_prompt_roles,
} from "../../../../../../../script.js";
import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory, buildQueryText } from "../vector/recall.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors } from "../vector/chunk-store.js";
const MODULE_ID = "summaryPrompt";
const SUMMARY_PROMPT_KEY = "LittleWhiteBox_StorySummary";
// 预算:只保留主预算与 L3 上限,其它由装配算法决定
const BUDGET = { total: 10000, l3Max: 2000 };
// 你确认的参数
const TARGET_UTILIZATION = 0.8;
const TOP_RELEVANCE_COUNT = 5;
// ─────────────────────────────────────────────────────────────────────────────
// Injection log
// ─────────────────────────────────────────────────────────────────────────────
function pct(n, d) {
return d > 0 ? Math.round((n / d) * 100) : 0;
}
function formatInjectionLog(stats) {
const lines = [
"",
"╔══════════════════════════════════════════════════════════════╗",
"║ Prompt Injection Report ║",
"╠══════════════════════════════════════════════════════════════╣",
`║ Token budget: ${stats.budget.max}`,
"╚══════════════════════════════════════════════════════════════╝",
"",
];
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [Packing] Budget-aware assembly │");
lines.push("└─────────────────────────────────────────────────────────────┘");
if (stats.packing) {
lines.push(
` Target utilization: ${(stats.packing.targetUtilization * 100).toFixed(0)}%`
);
lines.push(
` L2 budget: ${stats.packing.l2Used} / ${stats.packing.l2Max} (${pct(stats.packing.l2Used, stats.packing.l2Max)}%)`
);
lines.push(
` Selected events: ${stats.packing.selectedEvents} (DIRECT: ${stats.packing.selectedDirect}, SIMILAR: ${stats.packing.selectedSimilar})`
);
lines.push(
` Evidence levels: E3=${stats.packing.e3} | E2=${stats.packing.e2} | E1=${stats.packing.e1} | E0=${stats.packing.e0}`
);
lines.push(` Evidence chunks (total): ${stats.packing.evidenceChunks}`);
} else {
lines.push(" (no packing stats)");
}
lines.push("");
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [World] L3 │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` Injected: ${stats.world.count} | Tokens: ${stats.world.tokens}`);
lines.push("");
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [Direct] │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(
` Events: ${stats.direct.recalled} -> ${stats.direct.injected}${stats.direct.recalled > stats.direct.injected ? ` (budget cut ${stats.direct.recalled - stats.direct.injected})` : ""}`
);
lines.push(` Causal: ${stats.direct.causalCount}`);
lines.push(` L1 chunks: ${stats.direct.chunksCount}`);
lines.push(` Tokens: ${stats.direct.tokens}`);
lines.push("");
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [Similar] │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(
` Events: ${stats.similar.recalled} -> ${stats.similar.injected}${stats.similar.recalled > stats.similar.injected ? ` (budget cut ${stats.similar.recalled - stats.similar.injected})` : ""}`
);
lines.push(` Causal: ${stats.similar.causalCount}`);
lines.push(` L1 chunks: ${stats.similar.chunksCount}`);
lines.push(` Tokens: ${stats.similar.tokens}`);
lines.push("");
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [Orphans] │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(
` Chunks: ${stats.orphans.recalled} -> ${stats.orphans.injected}${stats.orphans.recalled > stats.orphans.injected ? ` (budget cut ${stats.orphans.recalled - stats.orphans.injected})` : ""}`
);
lines.push(` Tokens: ${stats.orphans.tokens}`);
lines.push("");
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [Arcs] │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` Injected: ${stats.arcs.count} | Tokens: ${stats.arcs.tokens}`);
lines.push("");
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [Total] │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(
` Tokens: ${stats.budget.used} / ${stats.budget.max} (${Math.round((stats.budget.used / stats.budget.max) * 100)}%)`
);
lines.push("");
return lines.join("\n");
}
// ─────────────────────────────────────────────────────────────────────────────
// 向量工具
// ─────────────────────────────────────────────────────────────────────────────
function cosineSimilarity(a, b) {
if (!a?.length || !b?.length || a.length !== b.length) return 0;
let dot = 0,
nA = 0,
nB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
nA += a[i] * a[i];
nB += b[i] * b[i];
}
return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────────────────────────────────────
function estimateTokens(text) {
if (!text) return 0;
const s = String(text);
const zh = (s.match(/[\u4e00-\u9fff]/g) || []).length;
return Math.ceil(zh + (s.length - zh) / 4);
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function pushWithBudget(lines, text, state) {
const t = estimateTokens(text);
if (state.used + t > state.max) return false;
lines.push(text);
state.used += t;
return true;
}
// 从 summary 解析楼层范围:(#321-322) 或 (#321)
function parseFloorRange(summary) {
if (!summary) return null;
const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/);
if (!match) return null;
// summary 里写的是 #楼层1-basedchunks 里 floor 是消息下标0-based
const start = Math.max(0, parseInt(match[1], 10) - 1);
const end = Math.max(
0,
(match[2] ? parseInt(match[2], 10) : parseInt(match[1], 10)) - 1
);
return { start, end };
}
// 去掉 summary 末尾的楼层标记
function cleanSummary(summary) {
return String(summary || "")
.replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "")
.trim();
}
// ─────────────────────────────────────────────────────────────────────────────
// Evidence Windowing (证据窗口)
// E1: 核心1条 / E2: ±1(约3条) / E3: ±2(约5条)
// 不改chunk切分不做重叠只在注入时补邻域提高语义完整度。
// ─────────────────────────────────────────────────────────────────────────────
const EVIDENCE_LEVEL = {
E0: 0,
E1: 1,
E2: 2,
E3: 3,
};
function getEvidenceWindowRadius(level) {
if (level === EVIDENCE_LEVEL.E3) return 2;
if (level === EVIDENCE_LEVEL.E2) return 1;
return 0;
}
function buildChunksByFloorMap(chunks) {
const map = new Map();
for (const c of chunks || []) {
const f = c.floor;
if (!map.has(f)) map.set(f, []);
map.get(f).push(c);
}
for (const arr of map.values()) {
arr.sort((a, b) => (a.chunkIdx ?? 0) - (b.chunkIdx ?? 0));
}
return map;
}
function pickAnchorChunkIdx(eventItem, floorChunks, recalledChunksInRange = []) {
// 优先用本轮召回的chunks里同楼层且相似度最高的作为anchor
let best = null;
for (const rc of recalledChunksInRange) {
if (rc.floor !== eventItem._evidenceFloor) continue;
if (!best || (rc.similarity || 0) > (best.similarity || 0)) best = rc;
}
if (best && best.chunkIdx != null) return best.chunkIdx;
// 退化该楼层第一个chunk
const first = floorChunks?.[0];
return first?.chunkIdx ?? 0;
}
function getEvidenceChunksForEvent(eventItem, chunksByFloor, recalledChunksInRange, evidenceLevel) {
if (evidenceLevel === EVIDENCE_LEVEL.E0) return [];
const floor = eventItem._evidenceFloor;
const floorChunks = chunksByFloor.get(floor) || [];
if (!floorChunks.length) return [];
const radius = getEvidenceWindowRadius(evidenceLevel);
const anchorIdx = pickAnchorChunkIdx(eventItem, floorChunks, recalledChunksInRange);
// 找到anchor在floorChunks中的位置
const pos = floorChunks.findIndex(c => (c.chunkIdx ?? 0) === anchorIdx);
const anchorPos = pos >= 0 ? pos : 0;
const start = clamp(anchorPos - radius, 0, floorChunks.length - 1);
const end = clamp(anchorPos + radius, 0, floorChunks.length - 1);
const selected = floorChunks.slice(start, end + 1);
// E1只取核心一条
if (evidenceLevel === EVIDENCE_LEVEL.E1) return [floorChunks[anchorPos]];
return selected;
}
function downgrade(level) {
if (level <= EVIDENCE_LEVEL.E0) return EVIDENCE_LEVEL.E0;
return level - 1;
}
function chooseInitialEvidenceLevel(e, isTop) {
if (isTop) return EVIDENCE_LEVEL.E3;
if (e._recallType === "DIRECT") return EVIDENCE_LEVEL.E2;
return EVIDENCE_LEVEL.E1;
}
// ─────────────────────────────────────────────────────────────────────────────
// L1 → L2 归属这里只挂“候选chunks”最终证据窗口在装配阶段决定
// ─────────────────────────────────────────────────────────────────────────────
function attachChunksToEvents(events, chunks) {
const usedChunkIds = new Set();
for (const e of events) {
e._candidateChunks = [];
const range = parseFloorRange(e.event?.summary);
if (!range) continue;
for (const c of chunks) {
if (c.floor >= range.start && c.floor <= range.end) {
if (!usedChunkIds.has(c.chunkId)) {
e._candidateChunks.push(c);
usedChunkIds.add(c.chunkId);
}
}
}
e._candidateChunks.sort(
(a, b) => (a.floor - b.floor) || ((b.similarity || 0) - (a.similarity || 0))
);
}
const orphans = chunks
.filter(c => !usedChunkIds.has(c.chunkId))
.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
return { events, orphans };
}
// ─────────────────────────────────────────────────────────────────────────────
// 因果事件证据补充:用 eventVector 匹配最相关的 chunk
// ─────────────────────────────────────────────────────────────────────────────
async function attachEvidenceToaCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap) {
for (const c of causalEvents) {
c._evidenceChunk = null;
const ev = c.event;
if (!ev?.id) continue;
const evVec = eventVectorMap.get(ev.id);
if (!evVec?.length) continue;
const range = parseFloorRange(ev.summary);
if (!range) continue;
const candidateChunks = [];
for (const [chunkId, chunk] of chunksMap) {
if (chunk.floor >= range.start && chunk.floor <= range.end) {
const vec = chunkVectorMap.get(chunkId);
if (vec?.length) candidateChunks.push({ chunk, vec });
}
}
if (!candidateChunks.length) continue;
let best = null;
let bestSim = -1;
for (const { chunk, vec } of candidateChunks) {
const sim = cosineSimilarity(evVec, vec);
if (sim > bestSim) {
bestSim = sim;
best = chunk;
}
}
if (best && bestSim > 0.3) {
c._evidenceChunk = {
floor: best.floor,
speaker: best.speaker,
text: best.text,
similarity: bestSim,
};
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 格式化函数
// ─────────────────────────────────────────────────────────────────────────────
function formatWorldLines(world) {
return [...(world || [])]
.sort((a, b) => (b.floor || 0) - (a.floor || 0))
.map(w => `- ${w.topic}${w.content}`);
}
function formatChunkLine(c) {
const text = String(c.text || "");
const preview = text.length > 80 ? text.slice(0, 80) + "..." : text;
const speaker = c.isUser ? "{{user}}" : "{{char}}";
return ` #${c.floor + 1} [${speaker}] ${preview}`;
}
function formatEventBlock(e, idx, isHighRelevance = false, evidenceChunks = []) {
const ev = e.event || {};
const time = ev.timeLabel || "";
const title = String(ev.title || "").trim();
const people = (ev.participants || []).join(" / ").trim();
const summary = cleanSummary(ev.summary);
const lines = [];
const displayTitle = title || people || ev.id || "事件";
const marker = isHighRelevance ? "★" : "";
const header = time ? `${marker}${idx}.【${time}${displayTitle}` : `${marker}${idx}. ${displayTitle}`;
lines.push(header);
if (people && displayTitle !== people) {
lines.push(` ${people}`);
}
lines.push(` ${summary}`);
for (const c of evidenceChunks || []) {
lines.push(` ${formatChunkLine(c)}`);
}
return lines.join("\n");
}
function formatCausalEventLine(causalItem, _causalById) {
const ev = causalItem?.event || {};
const depth = Math.max(1, Math.min(9, causalItem?._causalDepth || 1));
const indent = " │" + " ".repeat(depth - 1);
const prefix = `${indent}├─ 前因`;
const time = ev.timeLabel ? `${ev.timeLabel}` : "";
const people = (ev.participants || []).join(" / ");
const summary = cleanSummary(ev.summary);
const r = parseFloorRange(ev.summary);
const floorHint = r ? `(#${r.start + 1}${r.end !== r.start ? `-${r.end + 1}` : ""})` : "";
const lines = [];
lines.push(`${prefix}${time}${people ? ` ${people}` : ""}`);
const body = `${summary}${floorHint ? ` ${floorHint}` : ""}`.trim();
lines.push(`${indent} ${body}`);
const evidence = causalItem._evidenceChunk;
if (evidence) {
const speaker = evidence.speaker || "角色";
const preview = evidence.text.length > 60 ? evidence.text.slice(0, 60) + "..." : evidence.text;
lines.push(`${indent} #${evidence.floor + 1} [${speaker}] ${preview}`);
}
return lines.join("\n");
}
function formatArcLine(a) {
const moments = (a.moments || [])
.map(m => (typeof m === "string" ? m : m.text))
.filter(Boolean);
if (moments.length) {
return `- ${a.name}${moments.join(" → ")}(当前:${a.trajectory}`;
}
return `- ${a.name}${a.trajectory}`;
}
function buildTopKIdSet(directEvents, similarEvents) {
return new Set(
[...directEvents, ...similarEvents]
.sort((a, b) => (b.similarity || 0) - (a.similarity || 0))
.slice(0, TOP_RELEVANCE_COUNT)
.map(e => e.event?.id)
.filter(Boolean)
);
}
function computeEventTextCost(e, isTop, evidenceChunks = []) {
const tmp = formatEventBlock(e, 1, isTop, evidenceChunks);
return estimateTokens(tmp);
}
// ─────────────────────────────────────────────────────────────────────────────
// 非向量模式:沿用旧行为(简单、快)
// ─────────────────────────────────────────────────────────────────────────────
function buildMemoryPromptVectorDisabled(store) {
const data = store.json || {};
const sections = [];
if (data.world?.length) {
const lines = formatWorldLines(data.world);
sections.push(`[世界状态] 请严格遵守\n${lines.join("\n")}`);
}
if (data.events?.length) {
const lines = data.events.map((ev, i) => {
const time = ev.timeLabel || "";
const title = ev.title || "";
const people = (ev.participants || []).join(" / ");
const summary = cleanSummary(ev.summary);
const header = time ? `${i + 1}.【${time}${title || people}` : `${i + 1}. ${title || people}`;
return `${header}\n ${summary}`;
});
sections.push(`[剧情记忆]\n\n${lines.join("\n\n")}`);
}
if (data.arcs?.length) {
const lines = data.arcs.map(formatArcLine);
sections.push(`[人物弧光]\n${lines.join("\n")}`);
}
if (!sections.length) return { promptText: "", injectionLogText: "", injectionStats: null };
return {
promptText: `<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>`,
injectionLogText: "",
injectionStats: null,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// 预算驱动装配(向量模式)
// ─────────────────────────────────────────────────────────────────────────────
async function buildMemoryPromptVectorEnabled(store, recallResult, causalById, queryEntities = []) {
const data = store.json || {};
const total = { used: 0, max: BUDGET.total };
const sections = [];
const injectionStats = {
budget: { max: BUDGET.total, used: 0 },
world: { count: 0, tokens: 0 },
direct: { recalled: 0, injected: 0, causalCount: 0, chunksCount: 0, tokens: 0 },
similar: { recalled: 0, injected: 0, causalCount: 0, chunksCount: 0, tokens: 0 },
orphans: { recalled: 0, injected: 0, tokens: 0 },
arcs: { count: 0, tokens: 0 },
packing: null,
};
const targetUsed = Math.floor(BUDGET.total * TARGET_UTILIZATION);
// [世界状态]
const worldLines = formatWorldLines(data.world);
if (worldLines.length) {
const l3 = { used: 0, max: Math.min(BUDGET.l3Max, total.max) };
const l3Lines = [];
for (const line of worldLines) {
if (!pushWithBudget(l3Lines, line, l3)) break;
}
if (l3Lines.length) {
sections.push(`[世界状态] 请严格遵守\n${l3Lines.join("\n")}`);
total.used += l3.used;
injectionStats.world.count = l3Lines.length;
injectionStats.world.tokens = l3.used;
}
}
// L1 → L2 归属
const events = recallResult?.events || [];
const chunks = recallResult?.chunks || [];
const { events: eventsWithChunks, orphans } = attachChunksToEvents(events, chunks);
const directEvents = eventsWithChunks.filter(e => e._recallType === "DIRECT");
const similarEvents = eventsWithChunks.filter(e => e._recallType !== "DIRECT");
injectionStats.direct.recalled = directEvents.length;
injectionStats.similar.recalled = similarEvents.length;
const topKIds = buildTopKIdSet(directEvents, similarEvents);
// 证据楼层选择:用事件 range.end 作为 evidenceFloor贴近事件结尾
const evidenceFloors = new Set();
for (const e of eventsWithChunks) {
const r = parseFloorRange(e.event?.summary);
if (!r) continue;
e._evidenceFloor = r.end;
evidenceFloors.add(r.end);
}
// 批量加载这些楼层 chunks用于证据窗口
const { chatId } = getContext();
let chunksByFloor = new Map();
if (chatId && evidenceFloors.size) {
try {
const floorChunks = await getChunksByFloors(chatId, Array.from(evidenceFloors));
chunksByFloor = buildChunksByFloorMap(floorChunks);
} catch (e) {
xbLog.warn(MODULE_ID, "Failed to load floor chunks for evidence windowing", e);
}
}
// ─────────────────────────────────────────────────────────────────────
// 预算装配(不再固定条数)
// ─────────────────────────────────────────────────────────────────────
// L2预算目标 65% 总预算,上限 80%(保守避免 L2 吞满全部)
const l2Target = Math.floor(BUDGET.total * 0.65);
const l2Ceil = Math.floor(BUDGET.total * 0.8);
const l2Budget = {
used: 0,
max: Math.min(l2Ceil, Math.max(0, BUDGET.total - total.used)),
};
const packStats = {
targetUtilization: TARGET_UTILIZATION,
l2Used: 0,
l2Max: l2Budget.max,
selectedEvents: 0,
selectedDirect: 0,
selectedSimilar: 0,
e3: 0,
e2: 0,
e1: 0,
e0: 0,
evidenceChunks: 0,
};
// 候选:按 similarity 降序(更贴近“本轮需要”)
const candidates = [...directEvents, ...similarEvents]
.filter(e => e?.event?.summary)
.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
const selected = []; // { e, evidenceLevel, evidenceChunks, cost, isTop }
const selectedIds = new Set();
for (const e of candidates) {
const id = e.event?.id;
if (!id || selectedIds.has(id)) continue;
const isTop = topKIds.has(id);
let level = chooseInitialEvidenceLevel(e, isTop);
const recalledInRange = e._candidateChunks || [];
// 从高到低降级,直到能塞入 L2 budget
while (true) {
const evChunks = getEvidenceChunksForEvent(e, chunksByFloor, recalledInRange, level);
const cost = computeEventTextCost(e, isTop, evChunks);
if (l2Budget.used + cost <= l2Budget.max) {
selected.push({ e, evidenceLevel: level, evidenceChunks: evChunks, cost, isTop });
selectedIds.add(id);
l2Budget.used += cost;
break;
}
if (level === EVIDENCE_LEVEL.E0) break;
level = downgrade(level);
}
// 达到 L2 目标就先停(后续仍可能做“证据升级”填预算)
if (l2Budget.used >= l2Target) break;
}
// 若总预算仍明显不足目标利用率,做一次“证据升级”填预算(安全填充)
if (total.used + l2Budget.used < targetUsed && l2Budget.used < l2Budget.max && selected.length) {
const upgradable = [...selected].sort((a, b) => {
if (a.isTop !== b.isTop) return a.isTop ? -1 : 1;
return (b.e.similarity || 0) - (a.e.similarity || 0);
});
for (const item of upgradable) {
if (total.used + l2Budget.used >= targetUsed) break;
if (l2Budget.used >= l2Budget.max) break;
const cur = item.evidenceLevel;
const next = cur >= EVIDENCE_LEVEL.E3 ? cur : cur + 1;
if (next === cur) continue;
const recalledInRange = item.e._candidateChunks || [];
const nextChunks = getEvidenceChunksForEvent(item.e, chunksByFloor, recalledInRange, next);
const nextCost = computeEventTextCost(item.e, item.isTop, nextChunks);
const delta = nextCost - item.cost;
if (delta <= 0) continue;
if (l2Budget.used + delta <= l2Budget.max) {
item.evidenceLevel = next;
item.evidenceChunks = nextChunks;
item.cost = nextCost;
l2Budget.used += delta;
}
}
}
// packing stats 汇总
packStats.l2Used = l2Budget.used;
for (const item of selected) {
packStats.selectedEvents++;
if (item.e._recallType === "DIRECT") packStats.selectedDirect++;
else packStats.selectedSimilar++;
if (item.evidenceLevel === EVIDENCE_LEVEL.E3) packStats.e3++;
else if (item.evidenceLevel === EVIDENCE_LEVEL.E2) packStats.e2++;
else if (item.evidenceLevel === EVIDENCE_LEVEL.E1) packStats.e1++;
else packStats.e0++;
packStats.evidenceChunks += item.evidenceChunks?.length || 0;
}
injectionStats.packing = packStats;
// 最终输出仍按时间线:按事件 summary 里的楼层范围 start 排序
function getEventFloorStart(ev) {
const r = parseFloorRange(ev?.summary);
return r?.start ?? Number.POSITIVE_INFINITY;
}
const selectedEventsOrdered = selected.sort(
(a, b) => getEventFloorStart(a.e.event) - getEventFloorStart(b.e.event)
);
// ─────────────────────────────────────────────────────────────────────
// [亲身经历] DIRECT
// ─────────────────────────────────────────────────────────────────────
{
const directLines = [];
let idx = 1;
let injectedCount = 0;
let causalCount = 0;
let chunksCount = 0;
for (const item of selectedEventsOrdered) {
if (item.e._recallType !== "DIRECT") continue;
const block = formatEventBlock(item.e, idx, item.isTop, item.evidenceChunks);
directLines.push(block);
injectedCount++;
chunksCount += item.evidenceChunks?.length || 0;
for (const cid of item.e.event?.causedBy || []) {
const c = causalById.get(cid);
if (!c) continue;
directLines.push(formatCausalEventLine(c, causalById));
causalCount++;
}
idx++;
}
if (directLines.length) {
const text = `[亲身经历]\n\n${directLines.join("\n\n")}`;
const t = estimateTokens(text);
if (total.used + t <= total.max) {
sections.push(text);
total.used += t;
injectionStats.direct.injected = injectedCount;
injectionStats.direct.causalCount = causalCount;
injectionStats.direct.chunksCount = chunksCount;
injectionStats.direct.tokens = t;
}
}
}
// ─────────────────────────────────────────────────────────────────────
// [相关背景] SIMILAR
// ─────────────────────────────────────────────────────────────────────
{
const similarLines = [];
let idx = (injectionStats.direct.injected || 0) + 1;
let injectedCount = 0;
let causalCount = 0;
let chunksCount = 0;
for (const item of selectedEventsOrdered) {
if (item.e._recallType === "DIRECT") continue;
const block = formatEventBlock(item.e, idx, item.isTop, item.evidenceChunks);
similarLines.push(block);
injectedCount++;
chunksCount += item.evidenceChunks?.length || 0;
for (const cid of item.e.event?.causedBy || []) {
const c = causalById.get(cid);
if (!c) continue;
similarLines.push(formatCausalEventLine(c, causalById));
causalCount++;
}
idx++;
}
if (similarLines.length) {
const text = `[相关背景]\n\n${similarLines.join("\n\n")}`;
const t = estimateTokens(text);
if (total.used + t <= total.max) {
sections.push(text);
total.used += t;
injectionStats.similar.injected = injectedCount;
injectionStats.similar.causalCount = causalCount;
injectionStats.similar.chunksCount = chunksCount;
injectionStats.similar.tokens = t;
}
}
}
// ─────────────────────────────────────────────────────────────────────
// [记忆碎片] Orphans按剩余预算自然装入仍受预算约束按时间排序
// ─────────────────────────────────────────────────────────────────────
if (orphans.length && total.used < total.max) {
const l1 = { used: 0, max: total.max - total.used };
const lines = [];
injectionStats.orphans.recalled = orphans.length;
orphans.sort((a, b) => a.floor - b.floor);
for (const c of orphans) {
const line = formatChunkLine(c);
if (!pushWithBudget(lines, line, l1)) break;
injectionStats.orphans.injected++;
}
if (lines.length) {
sections.push(`[记忆碎片]\n${lines.join("\n")}`);
total.used += l1.used;
injectionStats.orphans.tokens = l1.used;
}
}
// ─────────────────────────────────────────────────────────────────────
// [人物弧光]:只保留 USER + queryEntities
// ─────────────────────────────────────────────────────────────────────
if (data.arcs?.length && total.used < total.max) {
const { name1 } = getContext();
const userName = String(name1 || "").trim();
const relevantEntities = new Set(
[userName, ...(queryEntities || [])]
.map(s => String(s || "").trim())
.filter(Boolean)
);
const filteredArcs = (data.arcs || []).filter(a => {
const arcName = String(a?.name || "").trim();
return arcName && relevantEntities.has(arcName);
});
if (filteredArcs.length) {
const arcLines = filteredArcs.map(formatArcLine);
const arcText = `[人物弧光]\n${arcLines.join("\n")}`;
const arcTokens = estimateTokens(arcText);
if (total.used + arcTokens <= total.max) {
sections.push(arcText);
total.used += arcTokens;
injectionStats.arcs.count = filteredArcs.length;
injectionStats.arcs.tokens = arcTokens;
}
}
}
// 组装
if (!sections.length) {
injectionStats.budget.used = total.used;
return { promptText: "", injectionLogText: "", injectionStats };
}
injectionStats.budget.used = total.used;
const promptText = `<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>`;
const injectionLogText = formatInjectionLog(injectionStats);
return { promptText, injectionLogText, injectionStats };
}
// ─────────────────────────────────────────────────────────────────────────────
// Exported API
// ─────────────────────────────────────────────────────────────────────────────
export async function formatPromptWithMemory(store, recallResult, causalById, queryEntities = []) {
const vectorCfg = getVectorConfig();
return vectorCfg?.enabled
? await buildMemoryPromptVectorEnabled(store, recallResult, causalById, queryEntities)
: buildMemoryPromptVectorDisabled(store);
}
export async function recallAndInjectPrompt(excludeLastAi = false, postToFrame = null) {
if (!getSettings().storySummary?.enabled) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const { chat } = getContext();
const store = getSummaryStore();
if (!store?.json) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const allEvents = store.json.events || [];
const lastIdx = store.lastSummarizedMesId ?? 0;
const length = chat?.length || 0;
if (lastIdx >= length) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const vectorCfg = getVectorConfig();
let recallResult = { events: [], chunks: [], causalEvents: [], queryEntities: [] };
let causalById = new Map();
if (vectorCfg?.enabled) {
try {
const queryText = buildQueryText(chat, 2, excludeLastAi);
recallResult = await recallMemory(queryText, allEvents, vectorCfg, { excludeLastAi });
// Attach evidence chunks for causal events
const causalEvents = recallResult.causalEvents || [];
if (causalEvents.length > 0) {
const { chatId } = getContext();
if (chatId) {
try {
const floors = new Set();
for (const c of causalEvents) {
const r = parseFloorRange(c.event?.summary);
if (!r) continue;
for (let f = r.start; f <= r.end; f++) floors.add(f);
}
const [chunks, chunkVecs, eventVecs] = await Promise.all([
getChunksByFloors(chatId, Array.from(floors)),
getAllChunkVectors(chatId),
getAllEventVectors(chatId),
]);
const chunksMap = new Map(chunks.map(c => [c.chunkId, c]));
const chunkVectorMap = new Map(chunkVecs.map(v => [v.chunkId, v.vector]));
const eventVectorMap = new Map(eventVecs.map(v => [v.eventId, v.vector]));
await attachEvidenceToaCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap);
} catch (e) {
xbLog.warn(MODULE_ID, "Causal evidence attachment failed", e);
}
}
}
causalById = new Map(
(recallResult.causalEvents || [])
.map(c => [c?.event?.id, c])
.filter(x => x[0])
);
} catch (e) {
xbLog.error(MODULE_ID, "召回失败", e);
}
}
const result = await injectPrompt(
store,
recallResult,
chat,
causalById,
recallResult?.queryEntities || []
);
if (postToFrame) {
const recallLog = recallResult.logText || "";
const injectionLog = result?.injectionLogText || "";
postToFrame({ type: "RECALL_LOG", text: recallLog + injectionLog });
}
}
export function updateSummaryExtensionPrompt() {
if (!getSettings().storySummary?.enabled) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
const { chat } = getContext();
const store = getSummaryStore();
if (!store?.json || (store.lastSummarizedMesId ?? 0) >= (chat?.length || 0)) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return;
}
// 注意:这里保持“快速注入”以降低频繁触发时的开销(不做预算装配/DB批量拉取
// 真正的预算驱动装配在 recallAndInjectPrompt() 中执行。
injectPrompt(store, { events: [], chunks: [], causalEvents: [], queryEntities: [] }, chat, new Map(), []);
}
async function injectPrompt(store, recallResult, chat, causalById, queryEntities = []) {
const length = chat?.length || 0;
const result = await formatPromptWithMemory(store, recallResult, causalById, queryEntities);
let text = result?.promptText || "";
const injectionLogText = result?.injectionLogText || "";
const cfg = getSummaryPanelConfig();
if (cfg.trigger?.wrapperHead) text = cfg.trigger.wrapperHead + "\n" + text;
if (cfg.trigger?.wrapperTail) text = text + "\n" + cfg.trigger.wrapperTail;
if (!text.trim()) {
delete extension_prompts[SUMMARY_PROMPT_KEY];
return { injectionLogText: "" };
}
const lastIdx = store.lastSummarizedMesId ?? 0;
let depth = length - lastIdx - 1;
if (depth < 0) depth = 0;
if (cfg.trigger?.forceInsertAtEnd) depth = 10000;
extension_prompts[SUMMARY_PROMPT_KEY] = {
value: text,
position: extension_prompt_types.IN_CHAT,
depth,
role: extension_prompt_roles.SYSTEM,
};
return { injectionLogText };
}
export function clearSummaryExtensionPrompt() {
delete extension_prompts[SUMMARY_PROMPT_KEY];
}