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 = $(`