From 4043e120ae6449a90d6e8f2c693b4e3158788cae Mon Sep 17 00:00:00 2001 From: bielie Date: Tue, 27 Jan 2026 16:04:57 +0800 Subject: [PATCH] Improve vector recall error handling --- modules/story-summary/generate/prompt.js | 1395 ++++++++++------------ modules/story-summary/story-summary.js | 429 +++---- modules/story-summary/vector/recall.js | 1314 ++++++++++---------- 3 files changed, 1452 insertions(+), 1686 deletions(-) diff --git a/modules/story-summary/generate/prompt.js b/modules/story-summary/generate/prompt.js index 0ad781d..d6a80ce 100644 --- a/modules/story-summary/generate/prompt.js +++ b/modules/story-summary/generate/prompt.js @@ -1,5 +1,10 @@ -// Story Summary - Prompt Injection -// 注入格式:世界状态 → 亲身经历(含闪回) → 相关背景(含闪回) → 记忆碎片 → 人物弧光 +// ═══════════════════════════════════════════════════════════════════════════ +// Story Summary - Prompt Injection (Final Clean Version) +// - 注入只在 GENERATION_STARTED 发生(由 story-summary.js 调用) +// - 向量关闭:注入全量总结(世界/事件/弧光) +// - 向量开启:召回 + 预算装配注入 +// - 没有“快速注入”写入 extension_prompts,避免覆盖/残留/竞态 +// ═══════════════════════════════════════════════════════════════════════════ import { getContext } from "../../../../../../extensions.js"; import { @@ -16,124 +21,27 @@ import { getChunksByFloors, getAllChunkVectors, getAllEventVectors } from "../ve 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; -} +let lastRecallFailAt = 0; +const RECALL_FAIL_COOLDOWN_MS = 10_000; -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 canNotifyRecallFail() { + const now = Date.now(); + if (now - lastRecallFailAt < RECALL_FAIL_COOLDOWN_MS) return false; + lastRecallFailAt = now; + return true; } // ───────────────────────────────────────────────────────────────────────────── -// 向量工具 +// 预算常量(向量模式使用) // ───────────────────────────────────────────────────────────────────────────── -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; -} +const BUDGET = { total: 10000 }; +const L3_MAX = Math.floor(BUDGET.total * 0.20); // 2000 +const ARCS_MAX = Math.floor(BUDGET.total * 0.15); // 1500 // ───────────────────────────────────────────────────────────────────────────── // 工具函数 @@ -146,10 +54,6 @@ function estimateTokens(text) { 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; @@ -158,23 +62,28 @@ function pushWithBudget(lines, text, state) { return true; } +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; +} + // 从 summary 解析楼层范围:(#321-322) 或 (#321) function parseFloorRange(summary) { if (!summary) return null; const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/); if (!match) return null; - - // summary 里写的是 #楼层(1-based),chunks 里 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 - ); - + const end = Math.max(0, (match[2] ? parseInt(match[2], 10) : parseInt(match[1], 10)) - 1); return { start, end }; } -// 去掉 summary 末尾的楼层标记 +// 去掉 summary 末尾楼层标记(按你要求:事件本体不显示楼层范围) function cleanSummary(summary) { return String(summary || "") .replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "") @@ -182,123 +91,482 @@ function cleanSummary(summary) { } // ───────────────────────────────────────────────────────────────────────────── -// 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 buildSystemPreamble() { + return [ + "以上内容为因上下文窗口限制保留的可见历史", + "【剧情记忆】为对以上可见、不可见历史的总结", + "1) 【世界状态】属于硬约束", + "2) 【事件/证据/碎片/人物弧光】可用于补全上下文与动机。", + "", + "请阅读并内化以下剧情记忆:", + ].join("\n"); } -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; +function buildPostscript() { + return [ + "", + "——", + ].join("\n"); } // ───────────────────────────────────────────────────────────────────────────── -// L1 → L2 归属:这里只挂“候选chunks”,最终证据窗口在装配阶段决定 +// 格式化函数 // ───────────────────────────────────────────────────────────────────────────── -function attachChunksToEvents(events, chunks) { +function formatWorldLines(world) { + return [...(world || [])] + .sort((a, b) => (b.floor || 0) - (a.floor || 0)) + .map(w => `- ${w.topic}:${w.content}`); +} + +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}`; +} + +// 完整 chunk 输出(不截断) +function formatChunkFullLine(c) { + const speaker = c.isUser ? "{{user}}" : "{{char}}"; + return `› #${c.floor + 1} [${speaker}] ${String(c.text || "").trim()}`; +} + +// 因果事件格式(仅作为“前因线索”展示,仍保留楼层提示) +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 = String(evidence.text || ""); + const clip = preview.length > 60 ? preview.slice(0, 60) + "..." : preview; + lines.push(`${indent} › #${evidence.floor + 1} [${speaker}] ${clip}`); + } + + return lines.join("\n"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 装配日志(开发调试用) +// ───────────────────────────────────────────────────────────────────────────── + +function formatInjectionLog(stats, details) { + const pct = (n, d) => (d > 0 ? Math.round((n / d) * 100) : 0); + + const lines = [ + "", + "╔══════════════════════════════════════════════════════════════╗", + "║ Prompt 装配报告 ║", + "╠══════════════════════════════════════════════════════════════╣", + `║ 总预算: ${stats.budget.max} tokens`, + `║ 已使用: ${stats.budget.used} tokens (${pct(stats.budget.used, stats.budget.max)}%)`, + `║ 剩余: ${stats.budget.max - stats.budget.used} tokens`, + "╚══════════════════════════════════════════════════════════════╝", + "", + ]; + + // 世界状态 + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ [1] 世界状态 (上限 20% = 2000) │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + lines.push(` 注入: ${stats.world.count} 条 | ${stats.world.tokens} tokens`); + lines.push(""); + + // 事件 + lines.push("┌─────────────────────────────────────────────────────────────┐"); + 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}`); + if (details.eventList?.length) { + lines.push(" ────────────────────────────────────────"); + details.eventList.slice(0, 20).forEach((ev, i) => { + const type = ev.isDirect ? "D" : "S"; + 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)})`); + }); + if (details.eventList.length > 20) lines.push(` ... 还有 ${details.eventList.length - 20} 条`); + } + lines.push(""); + + // 碎片 + lines.push("┌─────────────────────────────────────────────────────────────┐"); + 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("└─────────────────────────────────────────────────────────────┘"); + lines.push(` 注入: ${stats.arcs.count} 条 | ${stats.arcs.tokens} tokens`); + lines.push(""); + + // 预算条形 + lines.push("┌─────────────────────────────────────────────────────────────┐"); + lines.push("│ 【预算分布】 │"); + lines.push("└─────────────────────────────────────────────────────────────┘"); + const total = stats.budget.max; + const bar = (tokens, label) => { + const width = Math.round((tokens / total) * 40); + 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.evidence.tokens, "证据")); + lines.push(bar(stats.orphans.tokens, "碎片")); + lines.push(bar(stats.arcs.tokens, "弧光")); + lines.push(bar(stats.budget.max - stats.budget.used, "剩余")); + lines.push(""); + + return lines.join("\n"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 非向量模式:全量总结注入(世界 + 事件 + 弧光) +// 仅在 GENERATION_STARTED 调用 +// ───────────────────────────────────────────────────────────────────────────── + +function buildNonVectorPrompt(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 ""; + + return ( + `${buildSystemPreamble()}\n` + + `<剧情记忆>\n\n${sections.join("\n\n")}\n\n\n` + + `${buildPostscript()}` + ); +} + +export async function injectNonVectorPrompt(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; + } + + let text = buildNonVectorPrompt(store); + if (!text.trim()) { + delete extension_prompts[SUMMARY_PROMPT_KEY]; + return; + } + + // wrapper(沿用面板设置) + const cfg = getSummaryPanelConfig(); + if (cfg.trigger?.wrapperHead) text = cfg.trigger.wrapperHead + "\n" + text; + if (cfg.trigger?.wrapperTail) text = text + "\n" + cfg.trigger.wrapperTail; + + const lastIdx = store.lastSummarizedMesId ?? 0; + let depth = (chat?.length || 0) - 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, + }; + + if (postToFrame) { + postToFrame({ type: "RECALL_LOG", text: "\n[Non-vector] Injected full summary prompt.\n" }); + } +} + +// ───────────────────────────────────────────────────────────── +// 向量模式:预算装配(世界 → 事件(带证据) → 碎片 → 弧光) +// ───────────────────────────────────────────────────────────── + +async function buildVectorPrompt(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 }, + arcs: { count: 0, tokens: 0 }, + events: { selected: 0, tokens: 0 }, + evidence: { attached: 0, tokens: 0 }, + orphans: { injected: 0, tokens: 0 }, + }; + + const details = { + eventList: [], + directCount: 0, + similarCount: 0, + }; + + // [世界状态](20%) + const worldLines = formatWorldLines(data.world); + if (worldLines.length) { + const l3 = { used: 0, max: Math.min(L3_MAX, total.max - total.used) }; + const lines = []; + for (const line of worldLines) { + if (!pushWithBudget(lines, line, l3)) break; + } + if (lines.length) { + sections.push(`[世界状态] 请严格遵守\n${lines.join("\n")}`); + total.used += l3.used; + injectionStats.world.count = lines.length; + injectionStats.world.tokens = l3.used; + } + } + + // 事件(含证据) + const recalledEvents = (recallResult?.events || []).filter(e => e?.event?.summary); + const chunks = recallResult?.chunks || []; const usedChunkIds = new Set(); - for (const e of events) { - e._candidateChunks = []; - const range = parseFloorRange(e.event?.summary); - if (!range) continue; + function pickBestChunkForEvent(eventObj) { + const range = parseFloorRange(eventObj?.summary); + if (!range) return null; + let best = null; 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); - } + if (usedChunkIds.has(c.chunkId)) continue; + if (c.floor < range.start || c.floor > range.end) continue; + if (!best || (c.similarity || 0) > (best.similarity || 0)) best = c; + } + return best; + } + + function formatEventWithEvidence(e, idx, chunk) { + 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 displayTitle = title || people || ev.id || "事件"; + const header = time ? `${idx}.【${time}】${displayTitle}` : `${idx}. ${displayTitle}`; + + const lines = [header]; + if (people && displayTitle !== people) lines.push(` ${people}`); + lines.push(` ${summary}`); + + for (const cid of ev.causedBy || []) { + const c = causalById?.get(cid); + if (c) lines.push(formatCausalEventLine(c, causalById)); + } + + if (chunk) { + lines.push(` ${formatChunkFullLine(chunk)}`); + } + + return lines.join("\n"); + } + + // 候选按相似度从高到低(保证高分优先拥有证据) + const candidates = [...recalledEvents].sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); + + let idxDirect = 1; + let idxSimilar = 1; + const selectedDirectTexts = []; + const selectedSimilarTexts = []; + + for (const e of candidates) { + if (total.used >= total.max) break; + + const isDirect = e._recallType === "DIRECT"; + const idx = isDirect ? idxDirect : idxSimilar; + + const bestChunk = pickBestChunkForEvent(e.event); + + // 先尝试“带证据” + let text = formatEventWithEvidence(e, idx, bestChunk); + let cost = estimateTokens(text); + let hasEvidence = !!bestChunk; + + // 塞不下就退化成“不带证据” + if (total.used + cost > total.max) { + text = formatEventWithEvidence(e, idx, null); + cost = estimateTokens(text); + hasEvidence = false; + + if (total.used + cost > total.max) { + continue; } } - e._candidateChunks.sort( - (a, b) => (a.floor - b.floor) || ((b.similarity || 0) - (a.similarity || 0)) - ); + // 写入 + if (isDirect) { + selectedDirectTexts.push(text); + idxDirect++; + } else { + selectedSimilarTexts.push(text); + idxSimilar++; + } + + injectionStats.events.selected++; + total.used += cost; + + // tokens 拆分记账(事件本体 vs 证据) + if (hasEvidence && bestChunk) { + const chunkLine = formatChunkFullLine(bestChunk); + const ct = estimateTokens(chunkLine); + injectionStats.evidence.attached++; + injectionStats.evidence.tokens += ct; + usedChunkIds.add(bestChunk.chunkId); + + // 事件本体 tokens = cost - ct(粗略但够调试) + injectionStats.events.tokens += Math.max(0, cost - ct); + } else { + injectionStats.events.tokens += cost; + } + + details.eventList.push({ + title: e.event?.title || e.event?.id, + isDirect, + hasEvidence, + tokens: cost, + similarity: e.similarity || 0, + }); } - const orphans = chunks - .filter(c => !usedChunkIds.has(c.chunkId)) - .sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); + details.directCount = selectedDirectTexts.length; + details.similarCount = selectedSimilarTexts.length; - return { events, orphans }; + if (selectedDirectTexts.length) { + sections.push(`[亲身经历]\n\n${selectedDirectTexts.join("\n\n")}`); + } + if (selectedSimilarTexts.length) { + sections.push(`[相关背景]\n\n${selectedSimilarTexts.join("\n\n")}`); + } + + // [记忆碎片]:orphans 按楼层从早到晚,完整 chunk + if (chunks.length && total.used < total.max) { + const orphans = chunks + .filter(c => !usedChunkIds.has(c.chunkId)) + .sort((a, b) => (a.floor - b.floor) || ((a.chunkIdx ?? 0) - (b.chunkIdx ?? 0))); + + const l1 = { used: 0, max: total.max - total.used }; + const lines = []; + + for (const c of orphans) { + const line = formatChunkFullLine(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; + } + } + + // [人物弧光]:放最底,且上限 15%(只保留 user + queryEntities) + 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) }; + const arcLines = []; + for (const a of filtered) { + const line = formatArcLine(a); + if (!pushWithBudget(arcLines, line, arcBudget)) break; + } + + if (arcLines.length) { + sections.push(`[人物弧光]\n${arcLines.join("\n")}`); + total.used += arcBudget.used; + injectionStats.arcs.count = arcLines.length; + injectionStats.arcs.tokens = arcBudget.used; + } + } + } + + injectionStats.budget.used = total.used; + + if (!sections.length) { + return { promptText: "", injectionLogText: "", injectionStats }; + } + + const promptText = + `${buildSystemPreamble()}\n` + + `<剧情记忆>\n\n${sections.join("\n\n")}\n\n\n` + + `${buildPostscript()}`; + + const injectionLogText = formatInjectionLog(injectionStats, details); + + return { promptText, injectionLogText, injectionStats }; } // ───────────────────────────────────────────────────────────────────────────── -// 因果事件证据补充:用 eventVector 匹配最相关的 chunk +// 因果证据补充(给 causalEvents 挂 evidence chunk) // ───────────────────────────────────────────────────────────────────────────── -async function attachEvidenceToaCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap) { +async function attachEvidenceToCausalEvents(causalEvents, eventVectorMap, chunkVectorMap, chunksMap) { for (const c of causalEvents) { c._evidenceChunk = null; @@ -342,496 +610,11 @@ async function attachEvidenceToaCausalEvents(causalEvents, eventVectorMap, chunk } // ───────────────────────────────────────────────────────────────────────────── -// 格式化函数 +// ✅ 向量模式:召回 + 注入(供 story-summary.js 在 GENERATION_STARTED 调用) // ───────────────────────────────────────────────────────────────────────────── -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) { +export async function recallAndInjectPrompt(excludeLastAi = false, hooks = {}) { + const { postToFrame = null, echo = null } = hooks; if (!getSettings().storySummary?.enabled) { delete extension_prompts[SUMMARY_PROMPT_KEY]; return; @@ -855,120 +638,158 @@ export async function recallAndInjectPrompt(excludeLastAi = false, postToFrame = } 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); - } + if (!vectorCfg?.enabled) { + // 向量没开,不该走这条 + return; } - const result = await injectPrompt( + let recallResult = null; + let causalById = new Map(); + + try { + const queryText = buildQueryText(chat, 2, excludeLastAi); + recallResult = await recallMemory(queryText, allEvents, vectorCfg, { excludeLastAi }); + + recallResult = { + ...recallResult, + events: recallResult?.events || [], + chunks: recallResult?.chunks || [], + causalEvents: recallResult?.causalEvents || [], + queryEntities: recallResult?.queryEntities || [], + logText: recallResult?.logText || "", + }; + + // 给因果事件挂证据(用于因果行展示) + 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 attachEvidenceToCausalEvents(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); + + // 显式提示(节流) + if (echo && canNotifyRecallFail()) { + const msg = String(e?.message || "未知错误").replace(/\s+/g, " ").slice(0, 200); + await echo(`/echo severity=warning 向量召回失败:${msg}`); + } + + // iframe 日志也写一份 + if (postToFrame) { + postToFrame({ + type: "RECALL_LOG", + text: `\n[Vector Recall Failed]\n${String(e?.stack || e?.message || e)}\n`, + }); + } + + // 清空本次注入,避免残留误导 + delete extension_prompts[SUMMARY_PROMPT_KEY]; + return; + } + + // 成功但结果为空:也提示,并清空注入(不降级) + const hasUseful = + (recallResult?.events?.length || 0) > 0 || + (recallResult?.chunks?.length || 0) > 0 || + (recallResult?.causalEvents?.length || 0) > 0; + + if (!hasUseful) { + if (echo && canNotifyRecallFail()) { + await echo( + "/echo severity=warning 向量召回失败:没有可用召回结果(请先在面板中生成向量,或检查指纹不匹配)" + ); + } + if (postToFrame) { + postToFrame({ + type: "RECALL_LOG", + text: "\n[Vector Recall Empty]\nNo recall candidates / vectors not ready.\n", + }); + } + delete extension_prompts[SUMMARY_PROMPT_KEY]; + return; + } + + // 拼装向量 prompt + const { promptText, injectionLogText } = await buildVectorPrompt( store, recallResult, - chat, causalById, recallResult?.queryEntities || [] ); + // 写入 extension_prompts(真正注入) + await writePromptToExtensionPrompts(promptText, store, chat); + + // 发给涌现窗口:召回报告 + 装配报告 if (postToFrame) { const recallLog = recallResult.logText || ""; - const injectionLog = result?.injectionLogText || ""; - postToFrame({ type: "RECALL_LOG", text: recallLog + injectionLog }); + postToFrame({ type: "RECALL_LOG", text: recallLog + (injectionLogText || "") }); } } -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 || ""; +// ───────────────────────────────────────────────────────────────────────────── +// 写入 extension_prompts(统一入口) +// ───────────────────────────────────────────────────────────────────────────── +async function writePromptToExtensionPrompts(text, store, chat) { const cfg = getSummaryPanelConfig(); - if (cfg.trigger?.wrapperHead) text = cfg.trigger.wrapperHead + "\n" + text; - if (cfg.trigger?.wrapperTail) text = text + "\n" + cfg.trigger.wrapperTail; + let finalText = String(text || ""); - if (!text.trim()) { + if (cfg.trigger?.wrapperHead) finalText = cfg.trigger.wrapperHead + "\n" + finalText; + if (cfg.trigger?.wrapperTail) finalText = finalText + "\n" + cfg.trigger.wrapperTail; + + if (!finalText.trim()) { delete extension_prompts[SUMMARY_PROMPT_KEY]; - return { injectionLogText: "" }; + return; } const lastIdx = store.lastSummarizedMesId ?? 0; - let depth = length - lastIdx - 1; + let depth = (chat?.length || 0) - lastIdx - 1; if (depth < 0) depth = 0; if (cfg.trigger?.forceInsertAtEnd) depth = 10000; extension_prompts[SUMMARY_PROMPT_KEY] = { - value: text, + value: finalText, position: extension_prompt_types.IN_CHAT, depth, role: extension_prompt_roles.SYSTEM, }; - - return { injectionLogText }; } +// ───────────────────────────────────────────────────────────────────────────── +// 清理 prompt(供 story-summary.js 调用) +// ───────────────────────────────────────────────────────────────────────────── + export function clearSummaryExtensionPrompt() { delete extension_prompts[SUMMARY_PROMPT_KEY]; } diff --git a/modules/story-summary/story-summary.js b/modules/story-summary/story-summary.js index 6675d0b..596a9aa 100644 --- a/modules/story-summary/story-summary.js +++ b/modules/story-summary/story-summary.js @@ -1,6 +1,9 @@ // ═══════════════════════════════════════════════════════════════════════════ -// Story Summary - 主入口 -// UI 交互、事件监听、iframe 通讯 +// Story Summary - 主入口(干净版) +// - 注入只在 GENERATION_STARTED 发生 +// - 向量关闭:注入全量总结(L3+L2+Arcs) +// - 向量开启:召回 + 1万预算装配注入 +// - 删除所有 updateSummaryExtensionPrompt() 调用,避免覆盖/残留/竞态 // ═══════════════════════════════════════════════════════════════════════════ import { getContext } from "../../../../../extensions.js"; @@ -10,7 +13,7 @@ import { xbLog, CacheRegistry } from "../../core/debug-core.js"; import { postToIframe, isTrustedMessage } from "../../core/iframe-messaging.js"; import { CommonSettingStorage } from "../../core/server-storage.js"; -// 拆分模块 +// config/store import { getSettings, getSummaryPanelConfig, getVectorConfig, saveVectorConfig } from "./data/config.js"; import { getSummaryStore, @@ -20,15 +23,17 @@ import { clearSummaryData, } from "./data/store.js"; +// prompt injection (ONLY on generation started) import { recallAndInjectPrompt, - updateSummaryExtensionPrompt, clearSummaryExtensionPrompt, + injectNonVectorPrompt, } from "./generate/prompt.js"; +// summary generation import { runSummaryGeneration } from "./generate/generator.js"; -// 向量服务 +// vector service import { embed, getEngineFingerprint, @@ -68,11 +73,11 @@ import { // 常量 // ═══════════════════════════════════════════════════════════════════════════ -const MODULE_ID = 'storySummary'; -const SUMMARY_CONFIG_KEY = 'storySummaryPanelConfig'; +const MODULE_ID = "storySummary"; +const SUMMARY_CONFIG_KEY = "storySummaryPanelConfig"; const iframePath = `${extensionFolderPath}/modules/story-summary/story-summary.html`; -const VALID_SECTIONS = ['keywords', 'events', 'characters', 'arcs', 'world']; -const MESSAGE_EVENT = 'message'; +const VALID_SECTIONS = ["keywords", "events", "characters", "arcs", "world"]; +const MESSAGE_EVENT = "message"; // ═══════════════════════════════════════════════════════════════════════════ // 状态变量 @@ -87,20 +92,22 @@ let eventsRegistered = false; let vectorGenerating = false; let vectorCancelled = false; -// ═══════════════════════════════════════════════════════════════════════════ -// 工具函数 -// ═══════════════════════════════════════════════════════════════════════════ +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const sleep = ms => new Promise(r => setTimeout(r, ms)); +// ═══════════════════════════════════════════════════════════════════════════ +// 工具:执行斜杠命令 +// ═══════════════════════════════════════════════════════════════════════════ async function executeSlashCommand(command) { try { - const executeCmd = window.executeSlashCommands - || window.executeSlashCommandsOnChatInput - || (typeof SillyTavern !== 'undefined' && SillyTavern.getContext()?.executeSlashCommands); + const executeCmd = + window.executeSlashCommands || + window.executeSlashCommandsOnChatInput || + (typeof SillyTavern !== "undefined" && SillyTavern.getContext()?.executeSlashCommands); + if (executeCmd) { await executeCmd(command); - } else if (typeof window.STscript === 'function') { + } else if (typeof window.STscript === "function") { await window.STscript(command); } } catch (e) { @@ -138,17 +145,17 @@ function flushPendingFrameMessages() { if (!frameReady) return; const iframe = document.getElementById("xiaobaix-story-summary-iframe"); if (!iframe?.contentWindow) return; - pendingFrameMessages.forEach(p => postToIframe(iframe, p, "LittleWhiteBox")); + pendingFrameMessages.forEach((p) => postToIframe(iframe, p, "LittleWhiteBox")); pendingFrameMessages = []; } // ═══════════════════════════════════════════════════════════════════════════ -// 向量功能 +// 向量功能:UI 交互/状态 // ═══════════════════════════════════════════════════════════════════════════ function sendVectorConfigToFrame() { const cfg = getVectorConfig(); - postToFrame({ type: 'VECTOR_CONFIG', config: cfg }); + postToFrame({ type: "VECTOR_CONFIG", config: cfg }); } async function sendVectorStatsToFrame() { @@ -170,7 +177,7 @@ async function sendVectorStatsToFrame() { } postToFrame({ - type: 'VECTOR_STATS', + type: "VECTOR_STATS", stats: { eventCount, eventVectors: stats.eventVectors, @@ -179,7 +186,7 @@ async function sendVectorStatsToFrame() { totalFloors: chunkStatus.totalFloors, totalMessages, }, - mismatch + mismatch, }); } @@ -190,66 +197,66 @@ async function sendLocalModelStatusToFrame(modelId) { } const status = await checkLocalModelStatus(modelId); postToFrame({ - type: 'VECTOR_LOCAL_MODEL_STATUS', + type: "VECTOR_LOCAL_MODEL_STATUS", status: status.status, - message: status.message + message: status.message, }); } async function handleDownloadLocalModel(modelId) { try { - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'downloading', message: '下载中...' }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "downloading", message: "下载中..." }); await downloadLocalModel(modelId, (percent) => { - postToFrame({ type: 'VECTOR_LOCAL_MODEL_PROGRESS', percent }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_PROGRESS", percent }); }); - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'ready', message: '已就绪' }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "ready", message: "已就绪" }); } catch (e) { - if (e.message === '下载已取消') { - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'not_downloaded', message: '已取消' }); + if (e.message === "下载已取消") { + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "not_downloaded", message: "已取消" }); } else { - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'error', message: e.message }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "error", message: e.message }); } } } function handleCancelDownload() { cancelDownload(); - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'not_downloaded', message: '已取消' }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "not_downloaded", message: "已取消" }); } async function handleDeleteLocalModel(modelId) { try { await deleteLocalModelCache(modelId); - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'not_downloaded', message: '未下载' }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "not_downloaded", message: "未下载" }); } catch (e) { - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'error', message: e.message }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "error", message: e.message }); } } async function handleTestOnlineService(provider, config) { try { - postToFrame({ type: 'VECTOR_ONLINE_STATUS', status: 'downloading', message: '连接中...' }); + postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "downloading", message: "连接中..." }); const result = await testOnlineService(provider, config); postToFrame({ - type: 'VECTOR_ONLINE_STATUS', - status: 'success', - message: `连接成功 (${result.dims}维)` + type: "VECTOR_ONLINE_STATUS", + status: "success", + message: `连接成功 (${result.dims}维)`, }); } catch (e) { - postToFrame({ type: 'VECTOR_ONLINE_STATUS', status: 'error', message: e.message }); + postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "error", message: e.message }); } } async function handleFetchOnlineModels(config) { try { - postToFrame({ type: 'VECTOR_ONLINE_STATUS', status: 'downloading', message: '拉取中...' }); + postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "downloading", message: "拉取中..." }); const models = await fetchOnlineModels(config); - postToFrame({ type: 'VECTOR_ONLINE_MODELS', models }); - postToFrame({ type: 'VECTOR_ONLINE_STATUS', status: 'success', message: `找到 ${models.length} 个模型` }); + postToFrame({ type: "VECTOR_ONLINE_MODELS", models }); + postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "success", message: `找到 ${models.length} 个模型` }); } catch (e) { - postToFrame({ type: 'VECTOR_ONLINE_STATUS', status: 'error', message: e.message }); + postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "error", message: e.message }); } } @@ -257,34 +264,34 @@ async function handleGenerateVectors(vectorCfg) { if (vectorGenerating) return; if (!vectorCfg?.enabled) { - postToFrame({ type: 'VECTOR_GEN_PROGRESS', phase: 'L1', current: -1, total: 0 }); - postToFrame({ type: 'VECTOR_GEN_PROGRESS', phase: 'L2', current: -1, total: 0 }); + postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: -1, total: 0 }); + postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: -1, total: 0 }); return; } const { chatId, chat } = getContext(); if (!chatId || !chat?.length) return; - if (vectorCfg.engine === 'online') { + if (vectorCfg.engine === "online") { if (!vectorCfg.online?.key || !vectorCfg.online?.model) { - postToFrame({ type: 'VECTOR_ONLINE_STATUS', status: 'error', message: '请配置在线服务 API' }); + postToFrame({ type: "VECTOR_ONLINE_STATUS", status: "error", message: "请配置在线服务 API" }); return; } } - if (vectorCfg.engine === 'local') { + if (vectorCfg.engine === "local") { const modelId = vectorCfg.local?.modelId || DEFAULT_LOCAL_MODEL; const status = await checkLocalModelStatus(modelId); - if (status.status !== 'ready') { - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'downloading', message: '正在加载模型...' }); + if (status.status !== "ready") { + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "downloading", message: "正在加载模型..." }); try { await downloadLocalModel(modelId, (percent) => { - postToFrame({ type: 'VECTOR_LOCAL_MODEL_PROGRESS', percent }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_PROGRESS", percent }); }); - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'ready', message: '已就绪' }); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "ready", message: "已就绪" }); } catch (e) { - xbLog.error(MODULE_ID, '模型加载失败', e); - postToFrame({ type: 'VECTOR_LOCAL_MODEL_STATUS', status: 'error', message: e.message }); + xbLog.error(MODULE_ID, "模型加载失败", e); + postToFrame({ type: "VECTOR_LOCAL_MODEL_STATUS", status: "error", message: e.message }); return; } } @@ -294,7 +301,7 @@ async function handleGenerateVectors(vectorCfg) { vectorCancelled = false; const fingerprint = getEngineFingerprint(vectorCfg); - const isLocal = vectorCfg.engine === 'local'; + const isLocal = vectorCfg.engine === "local"; const batchSize = isLocal ? 5 : 20; const concurrency = isLocal ? 1 : 2; @@ -311,11 +318,11 @@ async function handleGenerateVectors(vectorCfg) { await saveChunks(chatId, allChunks); } - const l1Texts = allChunks.map(c => c.text); + const l1Texts = allChunks.map((c) => c.text); const l1Batches = []; for (let i = 0; i < l1Texts.length; i += batchSize) { l1Batches.push({ - phase: 'L1', + phase: "L1", texts: l1Texts.slice(i, i + batchSize), startIdx: i, }); @@ -326,20 +333,20 @@ async function handleGenerateVectors(vectorCfg) { await ensureFingerprintMatch(chatId, fingerprint); const existingVectors = await getAllEventVectors(chatId); - const existingIds = new Set(existingVectors.map(v => v.eventId)); + const existingIds = new Set(existingVectors.map((v) => v.eventId)); const l2Pairs = events - .filter(e => !existingIds.has(e.id)) - .map(e => ({ id: e.id, text: `${e.title || ''} ${e.summary || ''}`.trim() })) - .filter(p => p.text); + .filter((e) => !existingIds.has(e.id)) + .map((e) => ({ id: e.id, text: `${e.title || ""} ${e.summary || ""}`.trim() })) + .filter((p) => p.text); const l2Batches = []; for (let i = 0; i < l2Pairs.length; i += batchSize) { const batch = l2Pairs.slice(i, i + batchSize); l2Batches.push({ - phase: 'L2', - texts: batch.map(p => p.text), - ids: batch.map(p => p.id), + phase: "L2", + texts: batch.map((p) => p.text), + ids: batch.map((p) => p.id), startIdx: i, }); } @@ -349,8 +356,8 @@ async function handleGenerateVectors(vectorCfg) { let l1Completed = 0; let l2Completed = existingIds.size; - postToFrame({ type: 'VECTOR_GEN_PROGRESS', phase: 'L1', current: 0, total: l1Total }); - postToFrame({ type: 'VECTOR_GEN_PROGRESS', phase: 'L2', current: l2Completed, total: l2Total }); + postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: 0, total: l1Total }); + postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: l2Completed, total: l2Total }); const allTasks = [...l1Batches, ...l2Batches]; const l1Vectors = new Array(l1Texts.length); @@ -370,14 +377,14 @@ async function handleGenerateVectors(vectorCfg) { try { const vectors = await embed(task.texts, vectorCfg); - if (task.phase === 'L1') { + if (task.phase === "L1") { for (let j = 0; j < vectors.length; j++) { l1Vectors[task.startIdx + j] = vectors[j]; } l1Completed += task.texts.length; postToFrame({ - type: 'VECTOR_GEN_PROGRESS', - phase: 'L1', + type: "VECTOR_GEN_PROGRESS", + phase: "L1", current: Math.min(l1Completed, l1Total), total: l1Total, }); @@ -387,8 +394,8 @@ async function handleGenerateVectors(vectorCfg) { } l2Completed += task.texts.length; postToFrame({ - type: 'VECTOR_GEN_PROGRESS', - phase: 'L2', + type: "VECTOR_GEN_PROGRESS", + phase: "L2", current: Math.min(l2Completed, l2Total), total: l2Total, }); @@ -407,7 +414,7 @@ async function handleGenerateVectors(vectorCfg) { if (allChunks.length > 0 && l1Vectors.filter(Boolean).length > 0) { const chunkVectorItems = allChunks - .map((chunk, idx) => l1Vectors[idx] ? { chunkId: chunk.chunkId, vector: l1Vectors[idx] } : null) + .map((chunk, idx) => (l1Vectors[idx] ? { chunkId: chunk.chunkId, vector: l1Vectors[idx] } : null)) .filter(Boolean); await saveChunkVectors(chatId, chunkVectorItems, fingerprint); await updateMeta(chatId, { lastChunkFloor: chat.length - 1 }); @@ -417,8 +424,8 @@ async function handleGenerateVectors(vectorCfg) { await saveEventVectorsToDb(chatId, l2VectorItems, fingerprint); } - postToFrame({ type: 'VECTOR_GEN_PROGRESS', phase: 'L1', current: -1, total: 0 }); - postToFrame({ type: 'VECTOR_GEN_PROGRESS', phase: 'L2', current: -1, total: 0 }); + postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L1", current: -1, total: 0 }); + postToFrame({ type: "VECTOR_GEN_PROGRESS", phase: "L2", current: -1, total: 0 }); await sendVectorStatsToFrame(); vectorGenerating = false; @@ -435,61 +442,30 @@ async function handleClearVectors() { await clearAllChunks(chatId); await updateMeta(chatId, { lastChunkFloor: -1 }); await sendVectorStatsToFrame(); - xbLog.info(MODULE_ID, '向量数据已清除'); + xbLog.info(MODULE_ID, "向量数据已清除"); } -async function autoVectorizeNewEvents(newEventIds) { +async function maybeAutoBuildChunks() { const cfg = getVectorConfig(); - if (!cfg?.enabled || !newEventIds?.length) return; + if (!cfg?.enabled) return; - await sleep(3000); + const { chat, chatId } = getContext(); + if (!chatId || !chat?.length) return; - const { chatId } = getContext(); - if (!chatId) return; + const status = await getChunkBuildStatus(); + if (status.pending <= 0) return; - const store = getSummaryStore(); - const events = store?.json?.events || []; - - const fingerprint = getEngineFingerprint(cfg); - const meta = await getMeta(chatId); - if (meta.fingerprint && meta.fingerprint !== fingerprint) { - xbLog.warn(MODULE_ID, '引擎不匹配,跳过自动向量化'); - return; - } - - if (cfg.engine === 'local') { + if (cfg.engine === "local") { const modelId = cfg.local?.modelId || DEFAULT_LOCAL_MODEL; - if (!isLocalModelLoaded(modelId)) { - xbLog.warn(MODULE_ID, '本地模型未加载,跳过自动向量化'); - return; - } + if (!isLocalModelLoaded(modelId)) return; } - const toVectorize = newEventIds.filter(id => events.some(e => e.id === id)); - if (toVectorize.length === 0) return; - - const texts = toVectorize.map(id => { - const event = events.find(e => e.id === id); - return event ? `${event.title || ''} ${event.summary || ''}`.trim() : ''; - }).filter(t => t); - - if (!texts.length) return; + xbLog.info(MODULE_ID, `auto L1 chunks: pending=${status.pending}`); try { - const vectors = await embed(texts, cfg); - const newVectorItems = []; - for (let i = 0; i < toVectorize.length && i < vectors.length; i++) { - newVectorItems.push({ - eventId: toVectorize[i], - vector: vectors[i], - }); - } - if (newVectorItems.length > 0) { - await saveEventVectorsToDb(chatId, newVectorItems, fingerprint); - } - xbLog.info(MODULE_ID, `自动向量化 ${toVectorize.length} 个新事件`); + await buildIncrementalChunks({ vectorConfig: cfg }); } catch (e) { - xbLog.error(MODULE_ID, '自动向量化失败', e); + xbLog.error(MODULE_ID, "自动 L1 构建失败", e); } } @@ -502,8 +478,8 @@ function createOverlay() { overlayCreated = true; const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent); - const isNarrow = window.matchMedia?.('(max-width: 768px)').matches; - const overlayHeight = (isMobile || isNarrow) ? '92.5vh' : '100vh'; + const isNarrow = window.matchMedia?.("(max-width: 768px)").matches; + const overlayHeight = (isMobile || isNarrow) ? "92.5vh" : "100vh"; const $overlay = $(`
{ + btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); if (!getSettings().storySummary?.enabled) return; @@ -575,10 +551,12 @@ function createSummaryBtn(mesId) { function addSummaryBtnToMessage(mesId) { if (!getSettings().storySummary?.enabled) return; const msg = document.querySelector(`#chat .mes[mesid="${mesId}"]`); - if (!msg || msg.querySelector('.xiaobaix-story-summary-btn')) return; + if (!msg || msg.querySelector(".xiaobaix-story-summary-btn")) return; + const btn = createSummaryBtn(mesId); if (window.registerButtonToSubContainer?.(mesId, btn)) return; - msg.querySelector('.flex-container.flex1.alignitemscenter')?.appendChild(btn); + + msg.querySelector(".flex-container.flex1.alignitemscenter")?.appendChild(btn); } function initButtonsForAll() { @@ -598,10 +576,10 @@ async function sendSavedConfigToFrame() { const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null); if (savedConfig) { postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig }); - xbLog.info(MODULE_ID, '已从服务器加载面板配置'); + xbLog.info(MODULE_ID, "已从服务器加载面板配置"); } } catch (e) { - xbLog.warn(MODULE_ID, '加载面板配置失败', e); + xbLog.warn(MODULE_ID, "加载面板配置失败", e); } } @@ -646,54 +624,25 @@ function sendFrameFullData(store, totalFloors) { function openPanelForMessage(mesId) { createOverlay(); showOverlay(); + const { chat } = getContext(); const store = getSummaryStore(); const totalFloors = chat.length; + sendFrameBaseData(store, totalFloors); sendFrameFullData(store, totalFloors); setSummaryGenerating(summaryGenerating); sendVectorConfigToFrame(); sendVectorStatsToFrame(); + const cfg = getVectorConfig(); const modelId = cfg?.local?.modelId || DEFAULT_LOCAL_MODEL; sendLocalModelStatusToFrame(modelId); } -function notifyFrameAfterRollback(store) { - const { chat } = getContext(); - const totalFloors = Array.isArray(chat) ? chat.length : 0; - const lastSummarized = store.lastSummarizedMesId ?? -1; - - if (store.json) { - postToFrame({ - type: "SUMMARY_FULL_DATA", - payload: { - keywords: store.json.keywords || [], - events: store.json.events || [], - characters: store.json.characters || { main: [], relationships: [] }, - arcs: store.json.arcs || [], - world: store.json.world || [], - lastSummarizedMesId: lastSummarized, - }, - }); - } else { - postToFrame({ type: "SUMMARY_CLEARED", payload: { totalFloors } }); - } - - postToFrame({ - type: "SUMMARY_BASE_DATA", - stats: { - totalFloors, - summarizedUpTo: lastSummarized + 1, - eventsCount: store.json?.events?.length || 0, - pendingFloors: totalFloors - lastSummarized - 1, - }, - }); -} - // ═══════════════════════════════════════════════════════════════════════════ -// 自动触发总结 +// 自动总结(保持原逻辑;不做 prompt 注入) // ═══════════════════════════════════════════════════════════════════════════ async function maybeAutoRunSummary(reason) { @@ -704,10 +653,10 @@ async function maybeAutoRunSummary(reason) { const cfgAll = getSummaryPanelConfig(); const trig = cfgAll.trigger || {}; - if (trig.timing === 'manual') return; + if (trig.timing === "manual") return; if (!trig.enabled) return; - if (trig.timing === 'after_ai' && reason !== 'after_ai') return; - if (trig.timing === 'before_user' && reason !== 'before_user') return; + if (trig.timing === "after_ai" && reason !== "after_ai") return; + if (trig.timing === "before_user" && reason !== "before_user") return; if (isSummaryGenerating()) return; @@ -720,30 +669,6 @@ async function maybeAutoRunSummary(reason) { await autoRunSummaryWithRetry(chat.length - 1, { api: cfgAll.api, gen: cfgAll.gen, trigger: trig }); } -async function maybeAutoBuildChunks() { - const cfg = getVectorConfig(); - if (!cfg?.enabled) return; - - const { chat, chatId } = getContext(); - if (!chatId || !chat?.length) return; - - const status = await getChunkBuildStatus(); - if (status.pending <= 0) return; - - if (cfg.engine === 'local') { - const modelId = cfg.local?.modelId || DEFAULT_LOCAL_MODEL; - if (!isLocalModelLoaded(modelId)) return; - } - - xbLog.info(MODULE_ID, `auto L1 chunks: pending=${status.pending}`); - - try { - await buildIncrementalChunks({ vectorConfig: cfg }); - } catch (e) { - xbLog.error(MODULE_ID, '自动 L1 构建失败', e); - } -} - async function autoRunSummaryWithRetry(targetMesId, configForRun) { setSummaryGenerating(true); @@ -751,7 +676,7 @@ async function autoRunSummaryWithRetry(targetMesId, configForRun) { const result = await runSummaryGeneration(targetMesId, configForRun, { onStatus: (text) => postToFrame({ type: "SUMMARY_STATUS", statusText: text }), onError: (msg) => postToFrame({ type: "SUMMARY_ERROR", message: msg }), - onComplete: ({ merged, endMesId, newEventIds }) => { + onComplete: ({ merged, endMesId }) => { postToFrame({ type: "SUMMARY_FULL_DATA", payload: { @@ -759,7 +684,7 @@ async function autoRunSummaryWithRetry(targetMesId, configForRun) { events: merged.events || [], characters: merged.characters || { main: [], relationships: [] }, arcs: merged.arcs || [], - world: merged.world || [], + world: merged.world || [], lastSummarizedMesId: endMesId, }, }); @@ -768,8 +693,6 @@ async function autoRunSummaryWithRetry(targetMesId, configForRun) { statusText: `已更新至 ${endMesId + 1} 楼 · ${merged.events?.length || 0} 个事件`, }); updateFrameStatsAfterSummary(endMesId, merged); - updateSummaryExtensionPrompt(); - autoVectorizeNewEvents(newEventIds); }, }); @@ -782,8 +705,8 @@ async function autoRunSummaryWithRetry(targetMesId, configForRun) { } setSummaryGenerating(false); - xbLog.error(MODULE_ID, '自动总结失败(已重试3次)'); - await executeSlashCommand('/echo severity=error 剧情总结失败(已自动重试 3 次)。请稍后再试。'); + xbLog.error(MODULE_ID, "自动总结失败(已重试3次)"); + await executeSlashCommand("/echo severity=error 剧情总结失败(已自动重试 3 次)。请稍后再试。"); } function updateFrameStatsAfterSummary(endMesId, merged) { @@ -812,22 +735,23 @@ function updateFrameStatsAfterSummary(endMesId, merged) { function handleFrameMessage(event) { const iframe = document.getElementById("xiaobaix-story-summary-iframe"); if (!isTrustedMessage(event, iframe, "LittleWhiteBox-StoryFrame")) return; + const data = event.data; switch (data.type) { - case "FRAME_READY": + case "FRAME_READY": { frameReady = true; flushPendingFrameMessages(); setSummaryGenerating(summaryGenerating); sendSavedConfigToFrame(); sendVectorConfigToFrame(); sendVectorStatsToFrame(); - { - const cfg = getVectorConfig(); - const modelId = cfg?.local?.modelId || DEFAULT_LOCAL_MODEL; - sendLocalModelStatusToFrame(modelId); - } + + const cfg = getVectorConfig(); + const modelId = cfg?.local?.modelId || DEFAULT_LOCAL_MODEL; + sendLocalModelStatusToFrame(modelId); break; + } case "SETTINGS_OPENED": case "FULLSCREEN_OPENED": @@ -849,11 +773,12 @@ function handleFrameMessage(event) { } case "REQUEST_CANCEL": - window.xiaobaixStreamingGeneration?.cancel?.('xb9'); + window.xiaobaixStreamingGeneration?.cancel?.("xb9"); setSummaryGenerating(false); postToFrame({ type: "SUMMARY_STATUS", statusText: "已停止" }); break; + // vector UI case "VECTOR_DOWNLOAD_MODEL": handleDownloadLocalModel(data.modelId); break; @@ -880,10 +805,12 @@ function handleFrameMessage(event) { case "VECTOR_GENERATE": if (data.config) saveVectorConfig(data.config); + clearSummaryExtensionPrompt(); // 防残留 handleGenerateVectors(data.config); break; case "VECTOR_CLEAR": + clearSummaryExtensionPrompt(); // 防残留 handleClearVectors(); break; @@ -891,10 +818,11 @@ function handleFrameMessage(event) { vectorCancelled = true; break; + // summary actions case "REQUEST_CLEAR": { const { chat, chatId } = getContext(); clearSummaryData(chatId); - clearSummaryExtensionPrompt(); + clearSummaryExtensionPrompt(); // 防残留 postToFrame({ type: "SUMMARY_CLEARED", payload: { totalFloors: Array.isArray(chat) ? chat.length : 0 }, @@ -915,7 +843,6 @@ function handleFrameMessage(event) { } store.updatedAt = Date.now(); saveSummaryStore(); - updateSummaryExtensionPrompt(); break; } @@ -924,8 +851,10 @@ function handleFrameMessage(event) { if (!store) break; const lastSummarized = store.lastSummarizedMesId ?? -1; if (lastSummarized < 0) break; + store.hideSummarizedHistory = !!data.enabled; saveSummaryStore(); + if (data.enabled) { const range = calcHideRange(lastSummarized); if (range) executeSlashCommand(`/hide ${range.start}-${range.end}`); @@ -953,9 +882,7 @@ function handleFrameMessage(event) { (async () => { await executeSlashCommand(`/unhide 0-${lastSummarized}`); const range = calcHideRange(lastSummarized); - if (range) { - await executeSlashCommand(`/hide ${range.start}-${range.end}`); - } + if (range) await executeSlashCommand(`/hide ${range.start}-${range.end}`); const { chat } = getContext(); sendFrameBaseData(store, Array.isArray(chat) ? chat.length : 0); })(); @@ -969,7 +896,8 @@ function handleFrameMessage(event) { case "SAVE_PANEL_CONFIG": if (data.config) { CommonSettingStorage.set(SUMMARY_CONFIG_KEY, data.config); - xbLog.info(MODULE_ID, '面板配置已保存到服务器'); + clearSummaryExtensionPrompt(); // 配置变化立即清除注入,避免残留 + xbLog.info(MODULE_ID, "面板配置已保存到服务器"); } break; @@ -979,6 +907,10 @@ function handleFrameMessage(event) { } } +// ═══════════════════════════════════════════════════════════════════════════ +// 手动总结 +// ═══════════════════════════════════════════════════════════════════════════ + async function handleManualGenerate(mesId, config) { if (isSummaryGenerating()) { postToFrame({ type: "SUMMARY_STATUS", statusText: "上一轮总结仍在进行中..." }); @@ -991,7 +923,7 @@ async function handleManualGenerate(mesId, config) { await runSummaryGeneration(mesId, config, { onStatus: (text) => postToFrame({ type: "SUMMARY_STATUS", statusText: text }), onError: (msg) => postToFrame({ type: "SUMMARY_ERROR", message: msg }), - onComplete: ({ merged, endMesId, newEventIds }) => { + onComplete: ({ merged, endMesId }) => { const store = getSummaryStore(); postToFrame({ @@ -1011,18 +943,14 @@ async function handleManualGenerate(mesId, config) { statusText: `已更新至 ${endMesId + 1} 楼 · ${merged.events?.length || 0} 个事件`, }); - // 处理隐藏逻辑 + // 隐藏逻辑(与注入无关) const lastSummarized = store?.lastSummarizedMesId ?? -1; if (store?.hideSummarizedHistory && lastSummarized >= 0) { const range = calcHideRange(lastSummarized); - if (range) { - executeSlashCommand(`/hide ${range.start}-${range.end}`); - } + if (range) executeSlashCommand(`/hide ${range.start}-${range.end}`); } updateFrameStatsAfterSummary(endMesId, merged); - updateSummaryExtensionPrompt(); - autoVectorizeNewEvents(newEventIds); }, }); @@ -1030,7 +958,7 @@ async function handleManualGenerate(mesId, config) { } // ═══════════════════════════════════════════════════════════════════════════ -// 事件处理器 +// 事件处理器(不做 prompt 注入) // ═══════════════════════════════════════════════════════════════════════════ async function handleChatChanged() { @@ -1039,7 +967,6 @@ async function handleChatChanged() { await rollbackSummaryIfNeeded(); initButtonsForAll(); - updateSummaryExtensionPrompt(); const store = getSummaryStore(); const lastSummarized = store?.lastSummarizedMesId ?? -1; @@ -1052,7 +979,6 @@ async function handleChatChanged() { if (frameReady) { sendFrameBaseData(store, newLength); sendFrameFullData(store, newLength); - notifyFrameAfterRollback(store); } } @@ -1061,9 +987,6 @@ async function handleMessageDeleted() { const newLength = chat?.length || 0; await rollbackSummaryIfNeeded(); - updateSummaryExtensionPrompt(); - - // L1 同步 await syncOnMessageDeleted(chatId, newLength); } @@ -1071,10 +994,7 @@ async function handleMessageSwiped() { const { chat, chatId } = getContext(); const lastFloor = (chat?.length || 1) - 1; - // L1 同步 await syncOnMessageSwiped(chatId, lastFloor); - - updateSummaryExtensionPrompt(); initButtonsForAll(); } @@ -1084,41 +1004,52 @@ async function handleMessageReceived() { const message = chat?.[lastFloor]; const vectorConfig = getVectorConfig(); - updateSummaryExtensionPrompt(); initButtonsForAll(); - // L1 同步 await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig); await maybeAutoBuildChunks(); - setTimeout(() => maybeAutoRunSummary('after_ai'), 1000); + setTimeout(() => maybeAutoRunSummary("after_ai"), 1000); } function handleMessageSent() { - updateSummaryExtensionPrompt(); initButtonsForAll(); - setTimeout(() => maybeAutoRunSummary('before_user'), 1000); + setTimeout(() => maybeAutoRunSummary("before_user"), 1000); } async function handleMessageUpdated() { await rollbackSummaryIfNeeded(); - updateSummaryExtensionPrompt(); initButtonsForAll(); } function handleMessageRendered(data) { const mesId = data?.element ? $(data.element).attr("mesid") : data?.messageId; - if (mesId != null) { - addSummaryBtnToMessage(mesId); - } else { - initButtonsForAll(); - } + if (mesId != null) addSummaryBtnToMessage(mesId); + else initButtonsForAll(); } +// ═══════════════════════════════════════════════════════════════════════════ +// ✅ 唯一注入入口:GENERATION_STARTED +// ═══════════════════════════════════════════════════════════════════════════ + async function handleGenerationStarted(type, _params, isDryRun) { if (isDryRun) return; - const excludeLastAi = type === 'swipe' || type === 'regenerate'; - await recallAndInjectPrompt(excludeLastAi, postToFrame); + if (!getSettings().storySummary?.enabled) return; + + const excludeLastAi = type === "swipe" || type === "regenerate"; + const vectorCfg = getVectorConfig(); + + // 向量模式:召回 + 预算装配 + if (vectorCfg?.enabled) { + await recallAndInjectPrompt(excludeLastAi, { + postToFrame, + echo: executeSlashCommand, // recall failure notification + }); + return; + } + + // 非向量模式:全量总结注入(不召回) + await injectNonVectorPrompt(postToFrame); } // ═══════════════════════════════════════════════════════════════════════════ @@ -1129,14 +1060,17 @@ function registerEvents() { if (eventsRegistered) return; eventsRegistered = true; - xbLog.info(MODULE_ID, '模块初始化'); + xbLog.info(MODULE_ID, "模块初始化"); CacheRegistry.register(MODULE_ID, { - name: '待发送消息队列', + name: "待发送消息队列", getSize: () => pendingFrameMessages.length, getBytes: () => { - try { return JSON.stringify(pendingFrameMessages || []).length * 2; } - catch { return 0; } + try { + return JSON.stringify(pendingFrameMessages || []).length * 2; + } catch { + return 0; + } }, clear: () => { pendingFrameMessages = []; @@ -1153,17 +1087,22 @@ function registerEvents() { eventSource.on(event_types.MESSAGE_SWIPED, () => setTimeout(handleMessageSwiped, 100)); eventSource.on(event_types.MESSAGE_UPDATED, () => setTimeout(handleMessageUpdated, 100)); eventSource.on(event_types.MESSAGE_EDITED, () => setTimeout(handleMessageUpdated, 100)); - eventSource.on(event_types.USER_MESSAGE_RENDERED, data => setTimeout(() => handleMessageRendered(data), 50)); - eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, data => setTimeout(() => handleMessageRendered(data), 50)); + eventSource.on(event_types.USER_MESSAGE_RENDERED, (data) => setTimeout(() => handleMessageRendered(data), 50)); + eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => setTimeout(() => handleMessageRendered(data), 50)); + + // ✅ 只在生成开始时注入 eventSource.on(event_types.GENERATION_STARTED, handleGenerationStarted); } function unregisterEvents() { - xbLog.info(MODULE_ID, '模块清理'); + xbLog.info(MODULE_ID, "模块清理"); CacheRegistry.unregister(MODULE_ID); eventsRegistered = false; + $(".xiaobaix-story-summary-btn").remove(); hideOverlay(); + + // 禁用时清理注入,避免残留 clearSummaryExtensionPrompt(); } @@ -1175,7 +1114,9 @@ $(document).on("xiaobaix:storySummary:toggle", (_e, enabled) => { if (enabled) { registerEvents(); initButtonsForAll(); - updateSummaryExtensionPrompt(); + + // 开启时清一次,防止旧注入残留 + clearSummaryExtensionPrompt(); } else { unregisterEvents(); } @@ -1191,5 +1132,7 @@ jQuery(() => { return; } registerEvents(); - updateSummaryExtensionPrompt(); + + // 初始化也清一次,保证干净(注入只在生成开始发生) + clearSummaryExtensionPrompt(); }); diff --git a/modules/story-summary/vector/recall.js b/modules/story-summary/vector/recall.js index f91a7a7..de8bd92 100644 --- a/modules/story-summary/vector/recall.js +++ b/modules/story-summary/vector/recall.js @@ -1,321 +1,321 @@ -// Story Summary - Recall Engine -// L1 chunk + L2 event 召回 -// - 全量向量打分 -// - 指数衰减加权 Query Embedding -// - 实体/参与者加分 -// - MMR 去重 -// - floor 稀疏去重 - -import { getAllEventVectors, getAllChunkVectors, getChunksByFloors, getMeta } from './chunk-store.js'; -import { embed, getEngineFingerprint } from './embedder.js'; -import { xbLog } from '../../../core/debug-core.js'; -import { getContext } from '../../../../../../extensions.js'; -import { getSummaryStore } from '../data/store.js'; - -const MODULE_ID = 'recall'; - -const CONFIG = { - QUERY_MSG_COUNT: 5, - QUERY_DECAY_BETA: 0.7, - QUERY_MAX_CHARS: 600, - QUERY_CONTEXT_CHARS: 240, - - // 因果链 - CAUSAL_CHAIN_MAX_DEPTH: 10, // 放宽跳数,让图自然终止 - CAUSAL_INJECT_MAX: 30, // 放宽上限,由 prompt token 预算最终控制 - - CANDIDATE_CHUNKS: 200, - CANDIDATE_EVENTS: 150, - - MAX_CHUNKS: 40, +// Story Summary - Recall Engine +// L1 chunk + L2 event 召回 +// - 全量向量打分 +// - 指数衰减加权 Query Embedding +// - 实体/参与者加分 +// - MMR 去重 +// - floor 稀疏去重 + +import { getAllEventVectors, getAllChunkVectors, getChunksByFloors, getMeta } from './chunk-store.js'; +import { embed, getEngineFingerprint } from './embedder.js'; +import { xbLog } from '../../../core/debug-core.js'; +import { getContext } from '../../../../../../extensions.js'; +import { getSummaryStore } from '../data/store.js'; + +const MODULE_ID = 'recall'; + +const CONFIG = { + QUERY_MSG_COUNT: 5, + QUERY_DECAY_BETA: 0.7, + QUERY_MAX_CHARS: 600, + QUERY_CONTEXT_CHARS: 240, + + // 因果链 + CAUSAL_CHAIN_MAX_DEPTH: 10, // 放宽跳数,让图自然终止 + CAUSAL_INJECT_MAX: 30, // 放宽上限,由 prompt token 预算最终控制 + + CANDIDATE_CHUNKS: 200, + CANDIDATE_EVENTS: 150, + + MAX_CHUNKS: 40, MAX_EVENTS: 120, - - MIN_SIMILARITY_CHUNK: 0.55, - MIN_SIMILARITY_EVENT: 0.6, - MMR_LAMBDA: 0.72, - - BONUS_PARTICIPANT_HIT: 0.08, - BONUS_TEXT_HIT: 0.05, - BONUS_WORLD_TOPIC_HIT: 0.06, - - FLOOR_LIMIT: 1, -}; - -// ═══════════════════════════════════════════════════════════════════════════ -// 工具函数 -// ═══════════════════════════════════════════════════════════════════════════ - -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 normalizeVec(v) { - let s = 0; - for (let i = 0; i < v.length; i++) s += v[i] * v[i]; - s = Math.sqrt(s) || 1; - return v.map(x => x / s); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 因果链追溯(Graph-augmented retrieval) -// - 从已召回事件出发,沿 causedBy 向上追溯祖先事件 -// - 记录边:chainFrom = 哪个召回事件需要它 -// - 不在这里决定“是否额外注入”,只负责遍历与结构化结果 -// ═══════════════════════════════════════════════════════════════════════════ - -function buildEventIndex(allEvents) { - const map = new Map(); - for (const e of allEvents || []) { - if (e?.id) map.set(e.id, e); - } - return map; -} - -/** - * @returns {Map} - */ -function traceCausalAncestors(recalledEvents, eventIndex, maxDepth = CONFIG.CAUSAL_CHAIN_MAX_DEPTH) { - const out = new Map(); - const idRe = /^evt-\d+$/; - - function visit(parentId, depth, chainFrom) { - if (depth > maxDepth) return; - if (!idRe.test(parentId)) return; - - const ev = eventIndex.get(parentId); - if (!ev) return; - - // 如果同一个祖先被多个召回事件引用:保留更“近”的深度或追加来源 - const existed = out.get(parentId); - if (!existed) { - out.set(parentId, { event: ev, depth, chainFrom: [chainFrom] }); - } else { - if (depth < existed.depth) existed.depth = depth; - if (!existed.chainFrom.includes(chainFrom)) existed.chainFrom.push(chainFrom); - } - - for (const next of (ev.causedBy || [])) { - visit(String(next || '').trim(), depth + 1, chainFrom); - } - } - - for (const r of recalledEvents || []) { - const rid = r?.event?.id; - if (!rid) continue; - for (const cid of (r.event?.causedBy || [])) { - visit(String(cid || '').trim(), 1, rid); - } - } - - return out; -} - -/** - * 因果事件排序:引用数 > 深度 > 编号 - */ -function sortCausalEvents(causalArray) { - return causalArray.sort((a, b) => { - // 1. 被多条召回链引用的优先 - const refDiff = b.chainFrom.length - a.chainFrom.length; - if (refDiff !== 0) return refDiff; - - // 2. 深度浅的优先 - const depthDiff = a.depth - b.depth; - if (depthDiff !== 0) return depthDiff; - - // 3. 事件编号排序 - return String(a.event.id).localeCompare(String(b.event.id)); - }); -} - -function normalize(s) { - return String(s || '').normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '').trim(); -} - -function stripNoise(text) { - return String(text || '') - .replace(/[\s\S]*?<\/think>/gi, '') - .replace(/[\s\S]*?<\/thinking>/gi, '') - .replace(/\[tts:[^\]]*\]/gi, '') - .trim(); -} - -function buildExpDecayWeights(n, beta) { - const last = n - 1; - const w = Array.from({ length: n }, (_, i) => Math.exp(beta * (i - last))); - const sum = w.reduce((a, b) => a + b, 0) || 1; - return w.map(x => x / sum); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Query 构建 -// ═══════════════════════════════════════════════════════════════════════════ - -function buildQuerySegments(chat, count, excludeLastAi) { - if (!chat?.length) return []; - - let messages = chat; - if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) { - messages = messages.slice(0, -1); - } - - return messages.slice(-count).map((m, idx, arr) => { - const speaker = m.name || (m.is_user ? '用户' : '角色'); - const clean = stripNoise(m.mes); - if (!clean) return ''; - const limit = idx === arr.length - 1 ? CONFIG.QUERY_MAX_CHARS : CONFIG.QUERY_CONTEXT_CHARS; - return `${speaker}: ${clean.slice(0, limit)}`; - }).filter(Boolean); -} - -async function embedWeightedQuery(segments, vectorConfig) { - if (!segments?.length) return null; - - const weights = buildExpDecayWeights(segments.length, CONFIG.QUERY_DECAY_BETA); - const vecs = await embed(segments, vectorConfig); - const dims = vecs?.[0]?.length || 0; - if (!dims) return null; - - const out = new Array(dims).fill(0); - for (let i = 0; i < vecs.length; i++) { - for (let j = 0; j < dims; j++) out[j] += (vecs[i][j] || 0) * weights[i]; - } - - return { vector: normalizeVec(out), weights }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 实体抽取 -// ═══════════════════════════════════════════════════════════════════════════ - -function buildEntityLexicon(store, allEvents) { - const { name1 } = getContext(); - const userName = normalize(name1); - const set = new Set(); - - for (const e of allEvents || []) { - for (const p of e.participants || []) { - const s = normalize(p); - if (s) set.add(s); - } - } - - 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 || []) { - 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) - .filter(s => s.length >= 2 && !stop.has(s) && !/^[\s\p{P}\p{S}]+$/u.test(s) && !/<[^>]+>/.test(s)) - .slice(0, 5000); -} - -function extractEntities(text, lexicon) { - const t = normalize(text); - if (!t || !lexicon?.length) return []; - - const sorted = [...lexicon].sort((a, b) => b.length - a.length); - const hits = []; - for (const e of sorted) { - if (t.includes(e)) hits.push(e); - if (hits.length >= 20) break; - } - return hits; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// MMR -// ═══════════════════════════════════════════════════════════════════════════ - -function mmrSelect(candidates, k, lambda, getVector, getScore) { - const selected = []; - const ids = new Set(); - - while (selected.length < k && candidates.length) { - let best = null, bestScore = -Infinity; - - for (const c of candidates) { - if (ids.has(c._id)) continue; - - const rel = getScore(c); - let div = 0; - - if (selected.length) { - const vC = getVector(c); - if (vC?.length) { - for (const s of selected) { - const sim = cosineSimilarity(vC, getVector(s)); - if (sim > div) div = sim; - } - } - } - - const score = lambda * rel - (1 - lambda) * div; - if (score > bestScore) { - bestScore = score; - best = c; - } - } - - if (!best) break; - selected.push(best); - ids.add(best._id); - } - - return selected; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// L1 Chunks 检索 -// ═══════════════════════════════════════════════════════════════════════════ - -async function searchChunks(queryVector, vectorConfig) { - const { chatId } = getContext(); - if (!chatId || !queryVector?.length) return []; - - const meta = await getMeta(chatId); - const fp = getEngineFingerprint(vectorConfig); - if (meta.fingerprint && meta.fingerprint !== fp) return []; - - const chunkVectors = await getAllChunkVectors(chatId); - if (!chunkVectors.length) return []; - + + MIN_SIMILARITY_CHUNK: 0.6, + MIN_SIMILARITY_EVENT: 0.65, + MMR_LAMBDA: 0.72, + + BONUS_PARTICIPANT_HIT: 0.08, + BONUS_TEXT_HIT: 0.05, + BONUS_WORLD_TOPIC_HIT: 0.06, + + FLOOR_LIMIT: 1, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// 工具函数 +// ═══════════════════════════════════════════════════════════════════════════ + +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 normalizeVec(v) { + let s = 0; + for (let i = 0; i < v.length; i++) s += v[i] * v[i]; + s = Math.sqrt(s) || 1; + return v.map(x => x / s); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 因果链追溯(Graph-augmented retrieval) +// - 从已召回事件出发,沿 causedBy 向上追溯祖先事件 +// - 记录边:chainFrom = 哪个召回事件需要它 +// - 不在这里决定“是否额外注入”,只负责遍历与结构化结果 +// ═══════════════════════════════════════════════════════════════════════════ + +function buildEventIndex(allEvents) { + const map = new Map(); + for (const e of allEvents || []) { + if (e?.id) map.set(e.id, e); + } + return map; +} + +/** + * @returns {Map} + */ +function traceCausalAncestors(recalledEvents, eventIndex, maxDepth = CONFIG.CAUSAL_CHAIN_MAX_DEPTH) { + const out = new Map(); + const idRe = /^evt-\d+$/; + + function visit(parentId, depth, chainFrom) { + if (depth > maxDepth) return; + if (!idRe.test(parentId)) return; + + const ev = eventIndex.get(parentId); + if (!ev) return; + + // 如果同一个祖先被多个召回事件引用:保留更“近”的深度或追加来源 + const existed = out.get(parentId); + if (!existed) { + out.set(parentId, { event: ev, depth, chainFrom: [chainFrom] }); + } else { + if (depth < existed.depth) existed.depth = depth; + if (!existed.chainFrom.includes(chainFrom)) existed.chainFrom.push(chainFrom); + } + + for (const next of (ev.causedBy || [])) { + visit(String(next || '').trim(), depth + 1, chainFrom); + } + } + + for (const r of recalledEvents || []) { + const rid = r?.event?.id; + if (!rid) continue; + for (const cid of (r.event?.causedBy || [])) { + visit(String(cid || '').trim(), 1, rid); + } + } + + return out; +} + +/** + * 因果事件排序:引用数 > 深度 > 编号 + */ +function sortCausalEvents(causalArray) { + return causalArray.sort((a, b) => { + // 1. 被多条召回链引用的优先 + const refDiff = b.chainFrom.length - a.chainFrom.length; + if (refDiff !== 0) return refDiff; + + // 2. 深度浅的优先 + const depthDiff = a.depth - b.depth; + if (depthDiff !== 0) return depthDiff; + + // 3. 事件编号排序 + return String(a.event.id).localeCompare(String(b.event.id)); + }); +} + +function normalize(s) { + return String(s || '').normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '').trim(); +} + +function stripNoise(text) { + return String(text || '') + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/[\s\S]*?<\/thinking>/gi, '') + .replace(/\[tts:[^\]]*\]/gi, '') + .trim(); +} + +function buildExpDecayWeights(n, beta) { + const last = n - 1; + const w = Array.from({ length: n }, (_, i) => Math.exp(beta * (i - last))); + const sum = w.reduce((a, b) => a + b, 0) || 1; + return w.map(x => x / sum); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Query 构建 +// ═══════════════════════════════════════════════════════════════════════════ + +function buildQuerySegments(chat, count, excludeLastAi) { + if (!chat?.length) return []; + + let messages = chat; + if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) { + messages = messages.slice(0, -1); + } + + return messages.slice(-count).map((m, idx, arr) => { + const speaker = m.name || (m.is_user ? '用户' : '角色'); + const clean = stripNoise(m.mes); + if (!clean) return ''; + const limit = idx === arr.length - 1 ? CONFIG.QUERY_MAX_CHARS : CONFIG.QUERY_CONTEXT_CHARS; + return `${speaker}: ${clean.slice(0, limit)}`; + }).filter(Boolean); +} + +async function embedWeightedQuery(segments, vectorConfig) { + if (!segments?.length) return null; + + const weights = buildExpDecayWeights(segments.length, CONFIG.QUERY_DECAY_BETA); + const vecs = await embed(segments, vectorConfig); + const dims = vecs?.[0]?.length || 0; + if (!dims) return null; + + const out = new Array(dims).fill(0); + for (let i = 0; i < vecs.length; i++) { + for (let j = 0; j < dims; j++) out[j] += (vecs[i][j] || 0) * weights[i]; + } + + return { vector: normalizeVec(out), weights }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 实体抽取 +// ═══════════════════════════════════════════════════════════════════════════ + +function buildEntityLexicon(store, allEvents) { + const { name1 } = getContext(); + const userName = normalize(name1); + const set = new Set(); + + for (const e of allEvents || []) { + for (const p of e.participants || []) { + const s = normalize(p); + if (s) set.add(s); + } + } + + 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 || []) { + 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) + .filter(s => s.length >= 2 && !stop.has(s) && !/^[\s\p{P}\p{S}]+$/u.test(s) && !/<[^>]+>/.test(s)) + .slice(0, 5000); +} + +function extractEntities(text, lexicon) { + const t = normalize(text); + if (!t || !lexicon?.length) return []; + + const sorted = [...lexicon].sort((a, b) => b.length - a.length); + const hits = []; + for (const e of sorted) { + if (t.includes(e)) hits.push(e); + if (hits.length >= 20) break; + } + return hits; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MMR +// ═══════════════════════════════════════════════════════════════════════════ + +function mmrSelect(candidates, k, lambda, getVector, getScore) { + const selected = []; + const ids = new Set(); + + while (selected.length < k && candidates.length) { + let best = null, bestScore = -Infinity; + + for (const c of candidates) { + if (ids.has(c._id)) continue; + + const rel = getScore(c); + let div = 0; + + if (selected.length) { + const vC = getVector(c); + if (vC?.length) { + for (const s of selected) { + const sim = cosineSimilarity(vC, getVector(s)); + if (sim > div) div = sim; + } + } + } + + const score = lambda * rel - (1 - lambda) * div; + if (score > bestScore) { + bestScore = score; + best = c; + } + } + + if (!best) break; + selected.push(best); + ids.add(best._id); + } + + return selected; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// L1 Chunks 检索 +// ═══════════════════════════════════════════════════════════════════════════ + +async function searchChunks(queryVector, vectorConfig) { + const { chatId } = getContext(); + if (!chatId || !queryVector?.length) return []; + + const meta = await getMeta(chatId); + const fp = getEngineFingerprint(vectorConfig); + if (meta.fingerprint && meta.fingerprint !== fp) return []; + + const chunkVectors = await getAllChunkVectors(chatId); + if (!chunkVectors.length) return []; + const scored = chunkVectors.map(cv => { const match = String(cv.chunkId).match(/c-(\d+)-(\d+)/); return { _id: cv.chunkId, chunkId: cv.chunkId, - floor: match ? parseInt(match[1], 10) : 0, - chunkIdx: match ? parseInt(match[2], 10) : 0, - similarity: cosineSimilarity(queryVector, cv.vector), + floor: match ? parseInt(match[1], 10) : 0, + chunkIdx: match ? parseInt(match[2], 10) : 0, + similarity: cosineSimilarity(queryVector, cv.vector), vector: cv.vector, }; }); @@ -338,40 +338,42 @@ async function searchChunks(queryVector, vectorConfig) { .filter(s => s.similarity >= CONFIG.MIN_SIMILARITY_CHUNK) .sort((a, b) => b.similarity - a.similarity) .slice(0, CONFIG.CANDIDATE_CHUNKS); - - // 动态 K:质量不够就少拿 - const dynamicK = Math.min(CONFIG.MAX_CHUNKS, candidates.length); - - const selected = mmrSelect( - candidates, - dynamicK, - CONFIG.MMR_LAMBDA, - c => c.vector, - c => c.similarity - ); - - // floor 稀疏去重 - const floorCount = new Map(); - const sparse = []; - for (const s of selected.sort((a, b) => b.similarity - a.similarity)) { - const cnt = floorCount.get(s.floor) || 0; - if (cnt >= CONFIG.FLOOR_LIMIT) continue; - floorCount.set(s.floor, cnt + 1); - sparse.push(s); + + // 动态 K:质量不够就少拿 + const dynamicK = Math.min(CONFIG.MAX_CHUNKS, candidates.length); + + const selected = mmrSelect( + candidates, + dynamicK, + CONFIG.MMR_LAMBDA, + c => c.vector, + c => c.similarity + ); + + // floor 稀疏去重:每个楼层只保留该楼层相似度最高的那条 + const bestByFloor = new Map(); + for (const s of selected) { + const prev = bestByFloor.get(s.floor); + if (!prev || s.similarity > prev.similarity) { + bestByFloor.set(s.floor, s); + } } - const floors = [...new Set(sparse.map(c => c.floor))]; - const chunks = await getChunksByFloors(chatId, floors); - const chunkMap = new Map(chunks.map(c => [c.chunkId, c])); + // 最终结果按相似度降序 + const sparse = Array.from(bestByFloor.values()).sort((a, b) => b.similarity - a.similarity); + const floors = [...new Set(sparse.map(c => c.floor))]; + const chunks = await getChunksByFloors(chatId, floors); + const chunkMap = new Map(chunks.map(c => [c.chunkId, c])); + const results = sparse.map(item => { const chunk = chunkMap.get(item.chunkId); if (!chunk) return null; return { chunkId: item.chunkId, floor: item.floor, - chunkIdx: item.chunkIdx, - speaker: chunk.speaker, + chunkIdx: item.chunkIdx, + speaker: chunk.speaker, isUser: chunk.isUser, text: chunk.text, similarity: item.similarity, @@ -385,94 +387,94 @@ async function searchChunks(queryVector, vectorConfig) { return results; } - -// ═══════════════════════════════════════════════════════════════════════════ -// L2 Events 检索 -// ═══════════════════════════════════════════════════════════════════════════ - -async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities) { - const { chatId, name1 } = getContext(); - if (!chatId || !queryVector?.length) { - console.warn('[searchEvents] 早期返回: chatId或queryVector为空'); - return []; - } - - const meta = await getMeta(chatId); - const fp = getEngineFingerprint(vectorConfig); - console.log('[searchEvents] fingerprint检查:', { - metaFp: meta.fingerprint, - currentFp: fp, - match: meta.fingerprint === fp || !meta.fingerprint, - }); - if (meta.fingerprint && meta.fingerprint !== fp) return []; - - const eventVectors = await getAllEventVectors(chatId); - const vectorMap = new Map(eventVectors.map(v => [v.eventId, v.vector])); - console.log('[searchEvents] 向量数据:', { - eventVectorsCount: eventVectors.length, - vectorMapSize: vectorMap.size, - allEventsCount: allEvents?.length, - }); - if (!vectorMap.size) return []; - - const userName = normalize(name1); - const querySet = new Set((queryEntities || []).map(normalize)); - - // 只取硬约束类的 world topic - const worldTopics = (store?.json?.world || []) - .filter(w => ['inventory', 'rule', 'knowledge'].includes(String(w.category).toLowerCase())) - .map(w => normalize(w.topic)) - .filter(Boolean); - - const scored = (allEvents || []).map((event, idx) => { - const v = vectorMap.get(event.id); - const sim = v ? cosineSimilarity(queryVector, v) : 0; - - let bonus = 0; - const reasons = []; - - // participants 命中 - const participants = (event.participants || []).map(normalize).filter(Boolean); - if (participants.some(p => p !== userName && querySet.has(p))) { - bonus += CONFIG.BONUS_PARTICIPANT_HIT; - reasons.push('participant'); - } - - // text 命中 - const text = normalize(`${event.title || ''} ${event.summary || ''}`); - if ((queryEntities || []).some(e => text.includes(normalize(e)))) { - bonus += CONFIG.BONUS_TEXT_HIT; - reasons.push('text'); - } - - // world topic 命中 - if (worldTopics.some(topic => querySet.has(topic) && text.includes(topic))) { - bonus += CONFIG.BONUS_WORLD_TOPIC_HIT; - reasons.push('world'); - } - - return { - _id: event.id, - _idx: idx, - event, - similarity: sim, - bonus, - finalScore: sim + bonus, - reasons, - isDirect: reasons.includes('participant'), - vector: v, - }; - }); - - // 相似度分布日志 - const simValues = scored.map(s => s.similarity).sort((a, b) => b - a); - console.log('[searchEvents] 相似度分布(前20):', simValues.slice(0, 20)); - console.log('[searchEvents] 相似度分布(后20):', simValues.slice(-20)); - console.log('[searchEvents] 有向量的事件数:', scored.filter(s => s.similarity > 0).length); - console.log('[searchEvents] sim >= 0.6:', scored.filter(s => s.similarity >= 0.6).length); - console.log('[searchEvents] sim >= 0.5:', scored.filter(s => s.similarity >= 0.5).length); - console.log('[searchEvents] sim >= 0.3:', scored.filter(s => s.similarity >= 0.3).length); - + +// ═══════════════════════════════════════════════════════════════════════════ +// L2 Events 检索 +// ═══════════════════════════════════════════════════════════════════════════ + +async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities) { + const { chatId, name1 } = getContext(); + if (!chatId || !queryVector?.length) { + console.warn('[searchEvents] 早期返回: chatId或queryVector为空'); + return []; + } + + const meta = await getMeta(chatId); + const fp = getEngineFingerprint(vectorConfig); + console.log('[searchEvents] fingerprint检查:', { + metaFp: meta.fingerprint, + currentFp: fp, + match: meta.fingerprint === fp || !meta.fingerprint, + }); + if (meta.fingerprint && meta.fingerprint !== fp) return []; + + const eventVectors = await getAllEventVectors(chatId); + const vectorMap = new Map(eventVectors.map(v => [v.eventId, v.vector])); + console.log('[searchEvents] 向量数据:', { + eventVectorsCount: eventVectors.length, + vectorMapSize: vectorMap.size, + allEventsCount: allEvents?.length, + }); + if (!vectorMap.size) return []; + + const userName = normalize(name1); + const querySet = new Set((queryEntities || []).map(normalize)); + + // 只取硬约束类的 world topic + const worldTopics = (store?.json?.world || []) + .filter(w => ['inventory', 'rule', 'knowledge'].includes(String(w.category).toLowerCase())) + .map(w => normalize(w.topic)) + .filter(Boolean); + + const scored = (allEvents || []).map((event, idx) => { + const v = vectorMap.get(event.id); + const sim = v ? cosineSimilarity(queryVector, v) : 0; + + let bonus = 0; + const reasons = []; + + // participants 命中 + const participants = (event.participants || []).map(normalize).filter(Boolean); + if (participants.some(p => p !== userName && querySet.has(p))) { + bonus += CONFIG.BONUS_PARTICIPANT_HIT; + reasons.push('participant'); + } + + // text 命中 + const text = normalize(`${event.title || ''} ${event.summary || ''}`); + if ((queryEntities || []).some(e => text.includes(normalize(e)))) { + bonus += CONFIG.BONUS_TEXT_HIT; + reasons.push('text'); + } + + // world topic 命中 + if (worldTopics.some(topic => querySet.has(topic) && text.includes(topic))) { + bonus += CONFIG.BONUS_WORLD_TOPIC_HIT; + reasons.push('world'); + } + + return { + _id: event.id, + _idx: idx, + event, + similarity: sim, + bonus, + finalScore: sim + bonus, + reasons, + isDirect: reasons.includes('participant'), + vector: v, + }; + }); + + // 相似度分布日志 + const simValues = scored.map(s => s.similarity).sort((a, b) => b - a); + console.log('[searchEvents] 相似度分布(前20):', simValues.slice(0, 20)); + console.log('[searchEvents] 相似度分布(后20):', simValues.slice(-20)); + console.log('[searchEvents] 有向量的事件数:', scored.filter(s => s.similarity > 0).length); + console.log('[searchEvents] sim >= 0.6:', scored.filter(s => s.similarity >= 0.6).length); + console.log('[searchEvents] sim >= 0.5:', scored.filter(s => s.similarity >= 0.5).length); + console.log('[searchEvents] sim >= 0.3:', scored.filter(s => s.similarity >= 0.3).length); + // ★ 记录过滤前的分布(用 finalScore,与显示一致) const preFilterDistribution = { total: scored.length, @@ -490,114 +492,114 @@ async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEn .filter(s => s.finalScore >= CONFIG.MIN_SIMILARITY_EVENT) .sort((a, b) => b.finalScore - a.finalScore) .slice(0, CONFIG.CANDIDATE_EVENTS); - console.log('[searchEvents] 过滤后candidates:', candidates.length); - - // 动态 K:质量不够就少拿 - const dynamicK = Math.min(CONFIG.MAX_EVENTS, candidates.length); - - const selected = mmrSelect( - candidates, - dynamicK, - CONFIG.MMR_LAMBDA, - c => c.vector, - c => c.finalScore - ); - - return selected - .sort((a, b) => b.finalScore - a.finalScore) - .map(s => ({ - event: s.event, - similarity: s.finalScore, - _recallType: s.isDirect ? 'DIRECT' : 'SIMILAR', - _recallReason: s.reasons.length ? s.reasons.join('+') : '相似', - _preFilterDistribution: preFilterDistribution, - })); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 日志:因果树格式化 -// ═══════════════════════════════════════════════════════════════════════════ - -function formatCausalTree(causalEvents, recalledEvents) { - if (!causalEvents?.length) return ''; - - const lines = [ - '', - '┌─────────────────────────────────────────────────────────────┐', - '│ 【因果链追溯】 │', - '└─────────────────────────────────────────────────────────────┘', - ]; - - // 按 chainFrom 分组展示 - const bySource = new Map(); - for (const c of causalEvents) { - for (const src of c.chainFrom || []) { - if (!bySource.has(src)) bySource.set(src, []); - bySource.get(src).push(c); - } - } - - for (const [sourceId, ancestors] of bySource) { - const sourceEvent = recalledEvents.find(e => e.event?.id === sourceId); - const sourceTitle = sourceEvent?.event?.title || sourceId; - lines.push(` ${sourceId} "${sourceTitle}" 的前因链:`); - - // 按深度排序 - ancestors.sort((a, b) => a.depth - b.depth); - - for (const c of ancestors) { - const indent = ' ' + ' '.repeat(c.depth - 1); - const ev = c.event; - const title = ev.title || '(无标题)'; - const refs = c.chainFrom.length > 1 ? ` [被${c.chainFrom.length}条链引用]` : ''; - lines.push(`${indent}└─ [depth=${c.depth}] ${ev.id} "${title}"${refs}`); - } - } - - lines.push(''); - return lines.join('\n'); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 日志:主报告 -// ═══════════════════════════════════════════════════════════════════════════ - + console.log('[searchEvents] 过滤后candidates:', candidates.length); + + // 动态 K:质量不够就少拿 + const dynamicK = Math.min(CONFIG.MAX_EVENTS, candidates.length); + + const selected = mmrSelect( + candidates, + dynamicK, + CONFIG.MMR_LAMBDA, + c => c.vector, + c => c.finalScore + ); + + return selected + .sort((a, b) => b.finalScore - a.finalScore) + .map(s => ({ + event: s.event, + similarity: s.finalScore, + _recallType: s.isDirect ? 'DIRECT' : 'SIMILAR', + _recallReason: s.reasons.length ? s.reasons.join('+') : '相似', + _preFilterDistribution: preFilterDistribution, + })); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 日志:因果树格式化 +// ═══════════════════════════════════════════════════════════════════════════ + +function formatCausalTree(causalEvents, recalledEvents) { + if (!causalEvents?.length) return ''; + + const lines = [ + '', + '┌─────────────────────────────────────────────────────────────┐', + '│ 【因果链追溯】 │', + '└─────────────────────────────────────────────────────────────┘', + ]; + + // 按 chainFrom 分组展示 + const bySource = new Map(); + for (const c of causalEvents) { + for (const src of c.chainFrom || []) { + if (!bySource.has(src)) bySource.set(src, []); + bySource.get(src).push(c); + } + } + + for (const [sourceId, ancestors] of bySource) { + const sourceEvent = recalledEvents.find(e => e.event?.id === sourceId); + const sourceTitle = sourceEvent?.event?.title || sourceId; + lines.push(` ${sourceId} "${sourceTitle}" 的前因链:`); + + // 按深度排序 + ancestors.sort((a, b) => a.depth - b.depth); + + for (const c of ancestors) { + const indent = ' ' + ' '.repeat(c.depth - 1); + const ev = c.event; + const title = ev.title || '(无标题)'; + const refs = c.chainFrom.length > 1 ? ` [被${c.chainFrom.length}条链引用]` : ''; + lines.push(`${indent}└─ [depth=${c.depth}] ${ev.id} "${title}"${refs}`); + } + } + + lines.push(''); + return lines.join('\n'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 日志:主报告 +// ═══════════════════════════════════════════════════════════════════════════ + function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResults, allEvents, queryEntities, causalEvents = [], chunkPreFilterStats = null }) { - const lines = [ - '╔══════════════════════════════════════════════════════════════╗', - '║ 记忆召回报告 ║', - '╠══════════════════════════════════════════════════════════════╣', - `║ 耗时: ${elapsed}ms`, - '╚══════════════════════════════════════════════════════════════╝', - '', - '┌─────────────────────────────────────────────────────────────┐', - '│ 【查询构建】最近 5 条消息,指数衰减加权 (β=0.7) │', - '│ 权重越高 = 对召回方向影响越大 │', - '└─────────────────────────────────────────────────────────────┘', - ]; - - // 按权重从高到低排序显示 - const segmentsSorted = segments.map((s, i) => ({ - idx: i + 1, - weight: weights?.[i] ?? 0, - text: s, - })).sort((a, b) => b.weight - a.weight); - - segmentsSorted.forEach((s, rank) => { - const bar = '█'.repeat(Math.round(s.weight * 20)); - const preview = s.text.length > 60 ? s.text.slice(0, 60) + '...' : s.text; - const marker = rank === 0 ? ' ◀ 主导' : ''; - lines.push(` ${(s.weight * 100).toFixed(1).padStart(5)}% ${bar.padEnd(12)} ${preview}${marker}`); - }); - - lines.push(''); - lines.push('┌─────────────────────────────────────────────────────────────┐'); - lines.push('│ 【提取实体】用于判断"亲身经历"(DIRECT) │'); - lines.push('└─────────────────────────────────────────────────────────────┘'); - lines.push(` ${queryEntities?.length ? queryEntities.join('、') : '(无)'}`); - - lines.push(''); - lines.push('┌─────────────────────────────────────────────────────────────┐'); + const lines = [ + '╔══════════════════════════════════════════════════════════════╗', + '║ 记忆召回报告 ║', + '╠══════════════════════════════════════════════════════════════╣', + `║ 耗时: ${elapsed}ms`, + '╚══════════════════════════════════════════════════════════════╝', + '', + '┌─────────────────────────────────────────────────────────────┐', + '│ 【查询构建】最近 5 条消息,指数衰减加权 (β=0.7) │', + '│ 权重越高 = 对召回方向影响越大 │', + '└─────────────────────────────────────────────────────────────┘', + ]; + + // 按权重从高到低排序显示 + const segmentsSorted = segments.map((s, i) => ({ + idx: i + 1, + weight: weights?.[i] ?? 0, + text: s, + })).sort((a, b) => b.weight - a.weight); + + segmentsSorted.forEach((s, rank) => { + const bar = '█'.repeat(Math.round(s.weight * 20)); + const preview = s.text.length > 60 ? s.text.slice(0, 60) + '...' : s.text; + const marker = rank === 0 ? ' ◀ 主导' : ''; + lines.push(` ${(s.weight * 100).toFixed(1).padStart(5)}% ${bar.padEnd(12)} ${preview}${marker}`); + }); + + lines.push(''); + lines.push('┌─────────────────────────────────────────────────────────────┐'); + lines.push('│ 【提取实体】用于判断"亲身经历"(DIRECT) │'); + lines.push('└─────────────────────────────────────────────────────────────┘'); + lines.push(` ${queryEntities?.length ? queryEntities.join('、') : '(无)'}`); + + lines.push(''); + lines.push('┌─────────────────────────────────────────────────────────────┐'); lines.push('│ 【L1 原文片段】 │'); lines.push('└─────────────────────────────────────────────────────────────┘'); @@ -613,39 +615,39 @@ function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResult lines.push(` 召回: ${chunkResults.length} 条`); } - chunkResults.slice(0, 15).forEach((c, i) => { - const preview = c.text.length > 50 ? c.text.slice(0, 50) + '...' : c.text; - lines.push(` ${String(i + 1).padStart(2)}. #${String(c.floor).padStart(3)} [${c.speaker}] ${preview}`); - lines.push(` 相似度: ${c.similarity.toFixed(3)}`); - }); - - if (chunkResults.length > 15) { - lines.push(` ... 还有 ${chunkResults.length - 15} 条`); - } - - lines.push(''); - lines.push('┌─────────────────────────────────────────────────────────────┐'); + chunkResults.slice(0, 15).forEach((c, i) => { + const preview = c.text.length > 50 ? c.text.slice(0, 50) + '...' : c.text; + lines.push(` ${String(i + 1).padStart(2)}. #${String(c.floor).padStart(3)} [${c.speaker}] ${preview}`); + lines.push(` 相似度: ${c.similarity.toFixed(3)}`); + }); + + if (chunkResults.length > 15) { + lines.push(` ... 还有 ${chunkResults.length - 15} 条`); + } + + lines.push(''); + lines.push('┌─────────────────────────────────────────────────────────────┐'); lines.push('│ 【L2 事件记忆】 │'); lines.push('│ DIRECT=亲身经历 SIMILAR=相关背景 │'); lines.push('└─────────────────────────────────────────────────────────────┘'); - - eventResults.forEach((e, i) => { - const type = e._recallType === 'DIRECT' ? '★ DIRECT ' : ' SIMILAR'; - const title = e.event.title || '(无标题)'; - lines.push(` ${String(i + 1).padStart(2)}. ${type} ${title}`); - lines.push(` 相似度: ${e.similarity.toFixed(3)} | 原因: ${e._recallReason}`); - }); - - // 统计 - const directCount = eventResults.filter(e => e._recallType === 'DIRECT').length; - const similarCount = eventResults.filter(e => e._recallType === 'SIMILAR').length; - const preFilterDist = eventResults[0]?._preFilterDistribution || {}; - - lines.push(''); - lines.push('┌─────────────────────────────────────────────────────────────┐'); - lines.push('│ 【统计】 │'); - lines.push('└─────────────────────────────────────────────────────────────┘'); - lines.push(` L1 片段: ${chunkResults.length} 条`); + + eventResults.forEach((e, i) => { + const type = e._recallType === 'DIRECT' ? '★ DIRECT ' : ' SIMILAR'; + const title = e.event.title || '(无标题)'; + lines.push(` ${String(i + 1).padStart(2)}. ${type} ${title}`); + lines.push(` 相似度: ${e.similarity.toFixed(3)} | 原因: ${e._recallReason}`); + }); + + // 统计 + const directCount = eventResults.filter(e => e._recallType === 'DIRECT').length; + const similarCount = eventResults.filter(e => e._recallType === 'SIMILAR').length; + const preFilterDist = eventResults[0]?._preFilterDistribution || {}; + + lines.push(''); + lines.push('┌─────────────────────────────────────────────────────────────┐'); + lines.push('│ 【统计】 │'); + lines.push('└─────────────────────────────────────────────────────────────┘'); + lines.push(` L1 片段: ${chunkResults.length} 条`); lines.push(` L2 事件: ${eventResults.length} / ${allEvents.length} 条 (DIRECT: ${directCount}, SIMILAR: ${similarCount})`); if (preFilterDist.total) { lines.push(` L2 过滤前分布(${preFilterDist.total} 条,含bonus):`); @@ -654,47 +656,47 @@ function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResult lines.push(` 通过阈值(>=${preFilterDist.threshold || 0.6}): ${preFilterDist.passThreshold || 0} 条`); } lines.push(` 实体命中: ${queryEntities?.length || 0} 个`); - if (causalEvents.length) lines.push(` 因果链追溯: ${causalEvents.length} 条`); - lines.push(''); - - // 追加因果树详情 - lines.push(formatCausalTree(causalEvents, eventResults)); - - return lines.join('\n'); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 主入口 -// ═══════════════════════════════════════════════════════════════════════════ - -export async function recallMemory(queryText, allEvents, vectorConfig, options = {}) { - const T0 = performance.now(); - const { chat } = getContext(); - const store = getSummaryStore(); - - if (!allEvents?.length) { - return { events: [], chunks: [], elapsed: 0, logText: 'No events.' }; - } - - const segments = buildQuerySegments(chat, CONFIG.QUERY_MSG_COUNT, !!options.excludeLastAi); - - let queryVector, weights; - try { - const result = await embedWeightedQuery(segments, vectorConfig); - queryVector = result?.vector; - weights = result?.weights; - } catch (e) { - xbLog.error(MODULE_ID, '查询向量生成失败', e); - return { events: [], chunks: [], elapsed: Math.round(performance.now() - T0), logText: 'Query embedding failed.' }; - } - - if (!queryVector?.length) { - return { events: [], chunks: [], elapsed: Math.round(performance.now() - T0), logText: 'Empty query vector.' }; - } - - const lexicon = buildEntityLexicon(store, allEvents); - const queryEntities = extractEntities([queryText, ...segments].join('\n'), lexicon); - + if (causalEvents.length) lines.push(` 因果链追溯: ${causalEvents.length} 条`); + lines.push(''); + + // 追加因果树详情 + lines.push(formatCausalTree(causalEvents, eventResults)); + + return lines.join('\n'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主入口 +// ═══════════════════════════════════════════════════════════════════════════ + +export async function recallMemory(queryText, allEvents, vectorConfig, options = {}) { + const T0 = performance.now(); + const { chat } = getContext(); + const store = getSummaryStore(); + + if (!allEvents?.length) { + return { events: [], chunks: [], elapsed: 0, logText: 'No events.' }; + } + + const segments = buildQuerySegments(chat, CONFIG.QUERY_MSG_COUNT, !!options.excludeLastAi); + + let queryVector, weights; + try { + const result = await embedWeightedQuery(segments, vectorConfig); + queryVector = result?.vector; + weights = result?.weights; + } catch (e) { + xbLog.error(MODULE_ID, '查询向量生成失败', e); + return { events: [], chunks: [], elapsed: Math.round(performance.now() - T0), logText: 'Query embedding failed.' }; + } + + if (!queryVector?.length) { + return { events: [], chunks: [], elapsed: Math.round(performance.now() - T0), logText: 'Empty query vector.' }; + } + + const lexicon = buildEntityLexicon(store, allEvents); + const queryEntities = extractEntities([queryText, ...segments].join('\n'), lexicon); + const [chunkResults, eventResults] = await Promise.all([ searchChunks(queryVector, vectorConfig), searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities), @@ -702,64 +704,64 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options = const chunkPreFilterStats = chunkResults._preFilterStats || null; - // ───────────────────────────────────────────────────────────────────── - // 因果链追溯:从 eventResults 出发找祖先事件 - // 注意:是否“额外注入”要去重(如果祖先事件本来已召回,就不额外注入) - // ───────────────────────────────────────────────────────────────────── - const eventIndex = buildEventIndex(allEvents); - const causalMap = traceCausalAncestors(eventResults, eventIndex); - - const recalledIdSet = new Set(eventResults.map(x => x?.event?.id).filter(Boolean)); - const causalEvents = Array.from(causalMap.values()) - .filter(x => x?.event?.id && !recalledIdSet.has(x.event.id)) - .map(x => ({ - event: x.event, - similarity: 0, - _recallType: 'CAUSAL', - _recallReason: `因果链(${x.chainFrom.join(',')})`, - _causalDepth: x.depth, - _chainFrom: x.chainFrom, - chainFrom: x.chainFrom, - depth: x.depth, - })); - - // 排序:引用数 > 深度 > 编号,然后截断 - sortCausalEvents(causalEvents); - const causalEventsTruncated = causalEvents.slice(0, CONFIG.CAUSAL_INJECT_MAX); - - const elapsed = Math.round(performance.now() - T0); - const logText = formatRecallLog({ - elapsed, - queryText, - segments, - weights, - chunkResults, + // ───────────────────────────────────────────────────────────────────── + // 因果链追溯:从 eventResults 出发找祖先事件 + // 注意:是否“额外注入”要去重(如果祖先事件本来已召回,就不额外注入) + // ───────────────────────────────────────────────────────────────────── + const eventIndex = buildEventIndex(allEvents); + const causalMap = traceCausalAncestors(eventResults, eventIndex); + + const recalledIdSet = new Set(eventResults.map(x => x?.event?.id).filter(Boolean)); + const causalEvents = Array.from(causalMap.values()) + .filter(x => x?.event?.id && !recalledIdSet.has(x.event.id)) + .map(x => ({ + event: x.event, + similarity: 0, + _recallType: 'CAUSAL', + _recallReason: `因果链(${x.chainFrom.join(',')})`, + _causalDepth: x.depth, + _chainFrom: x.chainFrom, + chainFrom: x.chainFrom, + depth: x.depth, + })); + + // 排序:引用数 > 深度 > 编号,然后截断 + sortCausalEvents(causalEvents); + const causalEventsTruncated = causalEvents.slice(0, CONFIG.CAUSAL_INJECT_MAX); + + const elapsed = Math.round(performance.now() - T0); + const logText = formatRecallLog({ + elapsed, + queryText, + segments, + weights, + chunkResults, eventResults, allEvents, queryEntities, causalEvents: causalEventsTruncated, chunkPreFilterStats, }); - - console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold'); - console.log(`Elapsed: ${elapsed}ms | Entities: ${queryEntities.join(', ') || '(none)'}`); - console.log(`L1: ${chunkResults.length} | L2: ${eventResults.length}/${allEvents.length} | Causal: ${causalEventsTruncated.length}`); - console.groupEnd(); - + + console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold'); + console.log(`Elapsed: ${elapsed}ms | Entities: ${queryEntities.join(', ') || '(none)'}`); + console.log(`L1: ${chunkResults.length} | L2: ${eventResults.length}/${allEvents.length} | Causal: ${causalEventsTruncated.length}`); + console.groupEnd(); + return { events: eventResults, causalEvents: causalEventsTruncated, chunks: chunkResults, elapsed, logText, queryEntities }; } - -export function buildQueryText(chat, count = 2, excludeLastAi = false) { - if (!chat?.length) return ''; - - let messages = chat; - if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) { - messages = messages.slice(0, -1); - } - - return messages.slice(-count).map(m => { - const text = stripNoise(m.mes); - const speaker = m.name || (m.is_user ? '用户' : '角色'); - return `${speaker}: ${text.slice(0, 500)}`; - }).filter(Boolean).join('\n'); -} + +export function buildQueryText(chat, count = 2, excludeLastAi = false) { + if (!chat?.length) return ''; + + let messages = chat; + if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) { + messages = messages.slice(0, -1); + } + + return messages.slice(-count).map(m => { + const text = stripNoise(m.mes); + const speaker = m.name || (m.is_user ? '用户' : '角色'); + return `${speaker}: ${text.slice(0, 500)}`; + }).filter(Boolean).join('\n'); +}