diff --git a/modules/story-summary/generate/prompt.js b/modules/story-summary/generate/prompt.js index fa6dc75..95e8724 100644 --- a/modules/story-summary/generate/prompt.js +++ b/modules/story-summary/generate/prompt.js @@ -1,17 +1,15 @@ // ═══════════════════════════════════════════════════════════════════════════ -// Story Summary - Prompt Injection (v5 - Two-Stage: L0 Locate → L1 Evidence) +// Story Summary - Prompt Injection (v6 - EvidenceGroup: per-floor L0 + shared L1) // // 命名规范: // - 存储层用 L0/L1/L2/L3(StateAtom/Chunk/Event/Fact) // - 装配层用语义名称:constraint/event/evidence/arc // -// 架构变更(v4 → v5): -// - L0 和 L1 不再在同一个池子竞争 -// - recall.js 返回 {l0Selected[], l1ByFloor: Map} 而非 evidenceChunks[] -// - 装配层按 L2→L0→L1 层级组织 -// - 预算以"L0 + USER top-1 + AI top-1"为原子单元 -// - 孤立 L1(无对应 L0)丢弃 -// - 孤立 L0(无对应 L1)保留 +// 架构变更(v5 → v6): +// - 同楼层多个 L0 共享一对 L1(EvidenceGroup per-floor) +// - L0 展示文本从 semantic 字段改为从结构字段(type/subject/object/value/location)拼接 +// - 移除 标签和 [tags] theme 标签,输出自然语言短句 +// - 短行分号拼接,长行换行(120字阈值) // // 职责: // - 仅负责"构建注入文本",不负责写入 extension_prompts @@ -24,6 +22,7 @@ import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js"; import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js"; import { recallMemory } from "../vector/retrieval/recall.js"; import { getMeta } from "../vector/storage/chunk-store.js"; +import { getEngineFingerprint } from "../vector/utils/embedder.js"; // Metrics import { formatMetricsLog, detectIssues } from "../vector/retrieval/metrics.js"; @@ -56,6 +55,9 @@ const CONSTRAINT_MAX = 2000; const ARCS_MAX = 1500; const TOP_N_STAR = 5; +// L0 显示文本:分号拼接 vs 多行模式的阈值 +const L0_JOINED_MAX_LENGTH = 120; + // ───────────────────────────────────────────────────────────────────────────── // 工具函数 // ───────────────────────────────────────────────────────────────────────────── @@ -295,16 +297,88 @@ function formatArcLine(arc) { } /** - * 格式化 L0 锚点行 - * @param {object} l0 - L0 对象 - * @returns {string} 格式化后的行 + * 从 atom 结构字段生成可读短句(不依赖 semantic 字段) + * + * 规则: + * - act: 主体+谓词+客体 + * - emo: 主体+谓词+(对客体) + * - rev: 揭示:谓词+(关于客体) + * - dec: 主体+谓词+(对客体) + * - ten: 主体与客体之间:谓词 + * - loc: 场景:地点或谓词 + * - 地点非空且非 loc 类型时后缀 "在{location}" + * + * @param {object} l0 - L0 对象(含 l0.atom) + * @returns {string} 可读短句 */ -function formatL0Line(l0) { - return ` › #${l0.floor + 1} [📌] ${String(l0.text || l0.atom?.semantic || "").trim()}`; +function buildL0DisplayText(l0) { + const atom = l0.atom || l0._atom || {}; + const type = atom.type || 'act'; + const subject = String(atom.subject || '').trim(); + const object = String(atom.object || '').trim(); + const value = String(atom.value || '').trim(); + const location = String(atom.location || '').trim(); + + if (!value && !subject) { + // 兜底:如果结构字段缺失,回退到 semantic 并剥离标签 + const semantic = String(atom.semantic || l0.text || '').trim(); + return semantic + .replace(/^<\w+>\s*/, '') + .replace(/\s*\[[\w/]+\]\s*$/, '') + .trim() || '(未知锚点)'; + } + + let result = ''; + + switch (type) { + case 'emo': + result = `${subject}${value}`; + if (object) result += `(对${object})`; + break; + + case 'act': + result = `${subject}${value}`; + if (object) result += ` → ${object}`; + break; + + case 'rev': + result = `揭示:${value}`; + if (object) result += `(关于${object})`; + break; + + case 'dec': + result = `${subject}${value}`; + if (object) result += `(对${object})`; + break; + + case 'ten': + if (object) { + result = `${subject}与${object}之间:${value}`; + } else { + result = `${subject}:${value}`; + } + break; + + case 'loc': + result = `场景:${location || value}`; + break; + + default: + result = `${subject}${value}`; + if (object) result += ` → ${object}`; + break; + } + + // 地点后缀(loc 类型已包含地点,不重复) + if (location && type !== 'loc') { + result += ` 在${location}`; + } + + return result.trim(); } /** - * 格式化 L1 chunk 行(挂在 L0 下方) + * 格式化 L1 chunk 行 * @param {object} chunk - L1 chunk 对象 * @param {boolean} isContext - 是否为上下文(USER 侧) * @returns {string} 格式化后的行 @@ -344,99 +418,177 @@ function formatCausalEventLine(causalItem) { } // ───────────────────────────────────────────────────────────────────────────── -// L0→L1 证据单元构建 +// L0 按楼层分组 // ───────────────────────────────────────────────────────────────────────────── /** - * @typedef {object} EvidenceUnit - * @property {object} l0 - L0 锚点对象 - * @property {object|null} userL1 - USER 侧 top-1 L1 chunk - * @property {object|null} aiL1 - AI 侧 top-1 L1 chunk - * @property {number} totalTokens - 整个单元的 token 估算 + * 将 L0 列表按楼层分组 + * @param {object[]} l0List - L0 对象列表 + * @returns {Map} floor → L0 数组 + */ +function groupL0ByFloor(l0List) { + const map = new Map(); + for (const l0 of l0List) { + const floor = l0.floor; + if (!map.has(floor)) { + map.set(floor, []); + } + map.get(floor).push(l0); + } + return map; +} + +// ───────────────────────────────────────────────────────────────────────────── +// EvidenceGroup(per-floor:N个L0 + 共享一对L1) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * @typedef {object} EvidenceGroup + * @property {number} floor - 楼层号 + * @property {object[]} l0Atoms - 该楼层所有被选中的 L0 + * @property {object|null} userL1 - USER 侧 top-1 L1 chunk(仅一份) + * @property {object|null} aiL1 - AI 侧 top-1 L1 chunk(仅一份) + * @property {number} totalTokens - 整组 token 估算 */ /** - * 为一个 L0 构建证据单元 - * @param {object} l0 - L0 对象 + * 为一个楼层构建证据组 + * + * 同楼层多个 L0 共享一对 L1,避免 L1 重复输出。 + * + * @param {number} floor - 楼层号 + * @param {object[]} l0AtomsForFloor - 该楼层所有被选中的 L0 * @param {Map} l1ByFloor - 楼层→L1配对映射 - * @returns {EvidenceUnit} + * @returns {EvidenceGroup} */ -function buildEvidenceUnit(l0, l1ByFloor) { - const pair = l1ByFloor.get(l0.floor); +function buildEvidenceGroup(floor, l0AtomsForFloor, l1ByFloor) { + const pair = l1ByFloor.get(floor); const userL1 = pair?.userTop1 || null; const aiL1 = pair?.aiTop1 || null; - // 预计算整个单元的 token 开销 - let totalTokens = estimateTokens(formatL0Line(l0)); + // 计算整组 token 开销 + let totalTokens = 0; + + // 所有 L0 的显示文本 + for (const l0 of l0AtomsForFloor) { + totalTokens += estimateTokens(buildL0DisplayText(l0)); + } + // 固定开销:楼层前缀、📌 标记、分号等 + totalTokens += 10; + + // L1 仅算一次 if (userL1) totalTokens += estimateTokens(formatL1Line(userL1, true)); if (aiL1) totalTokens += estimateTokens(formatL1Line(aiL1, false)); - return { l0, userL1, aiL1, totalTokens }; + return { floor, l0Atoms: l0AtomsForFloor, userL1, aiL1, totalTokens }; } /** - * 格式化一个证据单元为文本行 - * @param {EvidenceUnit} unit - 证据单元 + * 格式化一个证据组为文本行数组 + * + * 短行模式(拼接后 ≤ 120 字): + * › #500 [📌] 薇薇保持跪趴姿势;薇薇展示细节;薇薇与蓝袖之间:被审视 + * ┌ #499 [蓝袖] ... + * › #500 [角色] ... + * + * 长行模式(拼接后 > 120 字): + * › #500 [📌] 薇薇保持跪趴姿势 在书房 + * │ 薇薇展示肛周细节 在书房 + * │ 薇薇与蓝袖之间:身体被审视 在书房 + * ┌ #499 [蓝袖] ... + * › #500 [角色] ... + * + * @param {EvidenceGroup} group - 证据组 * @returns {string[]} 文本行数组 */ -function formatEvidenceUnit(unit) { +function formatEvidenceGroup(group) { + const displayTexts = group.l0Atoms.map(l0 => buildL0DisplayText(l0)); + const lines = []; - lines.push(formatL0Line(unit.l0)); - if (unit.userL1) { - lines.push(formatL1Line(unit.userL1, true)); + + // L0 部分 + const joined = displayTexts.join(';'); + + if (joined.length <= L0_JOINED_MAX_LENGTH) { + // 短行:分号拼接为一行 + lines.push(` › #${group.floor + 1} [📌] ${joined}`); + } else { + // 长行:每个 L0 独占一行,首行带楼层号 + lines.push(` › #${group.floor + 1} [📌] ${displayTexts[0]}`); + for (let i = 1; i < displayTexts.length; i++) { + lines.push(` │ ${displayTexts[i]}`); + } } - if (unit.aiL1) { - lines.push(formatL1Line(unit.aiL1, false)); + + // L1 证据(仅一次) + if (group.userL1) { + lines.push(formatL1Line(group.userL1, true)); } + if (group.aiL1) { + lines.push(formatL1Line(group.aiL1, false)); + } + return lines; } // ───────────────────────────────────────────────────────────────────────────── -// 事件证据收集 +// 事件证据收集(per-floor 分组) // ───────────────────────────────────────────────────────────────────────────── /** - * 为事件收集范围内的 L0 证据单元 + * 为事件收集范围内的 EvidenceGroup + * + * 同楼层多个 L0 归入同一组,共享一对 L1。 + * * @param {object} eventObj - 事件对象 * @param {object[]} l0Selected - 所有选中的 L0 * @param {Map} l1ByFloor - 楼层→L1配对映射 * @param {Set} usedL0Ids - 已消费的 L0 ID 集合(会被修改) - * @returns {EvidenceUnit[]} 该事件的证据单元列表 + * @returns {EvidenceGroup[]} 该事件的证据组列表(按楼层排序) */ -function collectEvidenceForEvent(eventObj, l0Selected, l1ByFloor, usedL0Ids) { +function collectEvidenceGroupsForEvent(eventObj, l0Selected, l1ByFloor, usedL0Ids) { const range = parseFloorRange(eventObj?.summary); if (!range) return []; - const units = []; + // 收集范围内未消费的 L0,按楼层分组 + const floorMap = new Map(); for (const l0 of l0Selected) { if (usedL0Ids.has(l0.id)) continue; if (l0.floor < range.start || l0.floor > range.end) continue; - const unit = buildEvidenceUnit(l0, l1ByFloor); - units.push(unit); + if (!floorMap.has(l0.floor)) { + floorMap.set(l0.floor, []); + } + floorMap.get(l0.floor).push(l0); usedL0Ids.add(l0.id); } - // 按楼层排序 - units.sort((a, b) => a.l0.floor - b.l0.floor); + // 构建 groups + const groups = []; + for (const [floor, l0s] of floorMap) { + groups.push(buildEvidenceGroup(floor, l0s, l1ByFloor)); + } - return units; + // 按楼层排序 + groups.sort((a, b) => a.floor - b.floor); + + return groups; } // ───────────────────────────────────────────────────────────────────────────── -// 事件格式化(L2→L0→L1 层级) +// 事件格式化(L2 → EvidenceGroup 层级) // ───────────────────────────────────────────────────────────────────────────── /** - * 格式化事件(含 L0→L1 证据) + * 格式化事件(含 EvidenceGroup 证据) * @param {object} eventItem - 事件召回项 * @param {number} idx - 编号 - * @param {EvidenceUnit[]} evidenceUnits - 该事件的证据单元 + * @param {EvidenceGroup[]} evidenceGroups - 该事件的证据组 * @param {Map} causalById - 因果事件索引 * @returns {string} 格式化后的文本 */ -function formatEventWithEvidence(eventItem, idx, evidenceUnits, causalById) { +function formatEventWithEvidence(eventItem, idx, evidenceGroups, causalById) { const ev = eventItem.event || {}; const time = ev.timeLabel || ""; const title = String(ev.title || "").trim(); @@ -456,9 +608,9 @@ function formatEventWithEvidence(eventItem, idx, evidenceUnits, causalById) { if (c) lines.push(formatCausalEventLine(c)); } - // L0→L1 证据单元 - for (const unit of evidenceUnits) { - lines.push(...formatEvidenceUnit(unit)); + // EvidenceGroup 证据 + for (const group of evidenceGroups) { + lines.push(...formatEvidenceGroup(group)); } return lines.join("\n"); @@ -673,7 +825,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, } // ═══════════════════════════════════════════════════════════════════════ - // [Events] L2 Events → 直接命中 + 相似命中 + 因果链 + L0→L1 证据 + // [Events] L2 Events → 直接命中 + 相似命中 + 因果链 + EvidenceGroup // ═══════════════════════════════════════════════════════════════════════ const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary); @@ -689,11 +841,11 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, const isDirect = e._recallType === "DIRECT"; - // 收集该事件范围内的 L0→L1 证据单元 - const evidenceUnits = collectEvidenceForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids); + // 收集该事件范围内的 EvidenceGroup(per-floor) + const evidenceGroups = collectEvidenceGroupsForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids); // 格式化事件(含证据) - const text = formatEventWithEvidence(e, 0, evidenceUnits, causalById); + const text = formatEventWithEvidence(e, 0, evidenceGroups, causalById); const cost = estimateTokens(text); // 预算检查:整个事件(含证据)作为原子单元 @@ -703,23 +855,31 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, const costNoEvidence = estimateTokens(textNoEvidence); if (total.used + costNoEvidence > total.max) { + // 归还 usedL0Ids + for (const group of evidenceGroups) { + for (const l0 of group.l0Atoms) { + usedL0Ids.delete(l0.id); + } + } continue; } // 放入不带证据的版本,归还已消费的 L0 ID - for (const unit of evidenceUnits) { - usedL0Ids.delete(unit.l0.id); + for (const group of evidenceGroups) { + for (const l0 of group.l0Atoms) { + usedL0Ids.delete(l0.id); + } } if (isDirect) { selectedDirect.push({ event: e.event, text: textNoEvidence, tokens: costNoEvidence, - evidenceUnits: [], candidateRank, + evidenceGroups: [], candidateRank, }); } else { selectedRelated.push({ event: e.event, text: textNoEvidence, tokens: costNoEvidence, - evidenceUnits: [], candidateRank, + evidenceGroups: [], candidateRank, }); } @@ -734,36 +894,36 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, tokens: costNoEvidence, similarity: e.similarity || 0, l0Count: 0, - l1Count: 0, + l1FloorCount: 0, }); continue; } // 预算充足,放入完整版本 - const l0Count = evidenceUnits.length; - let l1Count = 0; - for (const unit of evidenceUnits) { - if (unit.userL1) l1Count++; - if (unit.aiL1) l1Count++; + let l0Count = 0; + let l1FloorCount = 0; + for (const group of evidenceGroups) { + l0Count += group.l0Atoms.length; + if (group.userL1 || group.aiL1) l1FloorCount++; } if (isDirect) { selectedDirect.push({ event: e.event, text, tokens: cost, - evidenceUnits, candidateRank, + evidenceGroups, candidateRank, }); } else { selectedRelated.push({ event: e.event, text, tokens: cost, - evidenceUnits, candidateRank, + evidenceGroups, candidateRank, }); } injectionStats.event.selected++; injectionStats.event.tokens += cost; injectionStats.evidence.l0InEvents += l0Count; - injectionStats.evidence.l1InEvents += l1Count; + injectionStats.evidence.l1InEvents += l1FloorCount; total.used += cost; eventDetails.list.push({ @@ -773,7 +933,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, tokens: cost, similarity: e.similarity || 0, l0Count, - l1Count, + l1FloorCount, }); } @@ -798,7 +958,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, assembled.relatedEvents.lines = relatedEventTexts; // ═══════════════════════════════════════════════════════════════════════ - // [Evidence - Distant] 远期证据(已总结范围,未被事件消费的 L0→L1) + // [Evidence - Distant] 远期证据(已总结范围,未被事件消费的 L0) // ═══════════════════════════════════════════════════════════════════════ const lastSummarized = store.lastSummarizedMesId ?? -1; @@ -816,21 +976,25 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, if (distantL0.length && total.used < total.max) { const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) }; - // 按楼层排序(时间顺序) + // 按楼层排序(时间顺序)后分组 distantL0.sort((a, b) => a.floor - b.floor); + const distantFloorMap = groupL0ByFloor(distantL0); - for (const l0 of distantL0) { - const unit = buildEvidenceUnit(l0, l1ByFloor); + // 按楼层顺序遍历(Map 保持插入顺序,distantL0 已按 floor 排序) + for (const [floor, l0s] of distantFloorMap) { + const group = buildEvidenceGroup(floor, l0s, l1ByFloor); - // 原子单元预算检查 - if (distantBudget.used + unit.totalTokens > distantBudget.max) continue; + // 原子组预算检查 + if (distantBudget.used + group.totalTokens > distantBudget.max) continue; - const unitLines = formatEvidenceUnit(unit); - for (const line of unitLines) { + const groupLines = formatEvidenceGroup(group); + for (const line of groupLines) { assembled.distantEvidence.lines.push(line); } - distantBudget.used += unit.totalTokens; - usedL0Ids.add(l0.id); + distantBudget.used += group.totalTokens; + for (const l0 of l0s) { + usedL0Ids.add(l0.id); + } injectionStats.distantEvidence.units++; } @@ -854,20 +1018,23 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, if (recentL0.length) { const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX }; - // 按楼层排序(时间顺序) + // 按楼层排序后分组 recentL0.sort((a, b) => a.floor - b.floor); + const recentFloorMap = groupL0ByFloor(recentL0); - for (const l0 of recentL0) { - const unit = buildEvidenceUnit(l0, l1ByFloor); + for (const [floor, l0s] of recentFloorMap) { + const group = buildEvidenceGroup(floor, l0s, l1ByFloor); - if (recentBudget.used + unit.totalTokens > recentBudget.max) continue; + if (recentBudget.used + group.totalTokens > recentBudget.max) continue; - const unitLines = formatEvidenceUnit(unit); - for (const line of unitLines) { + const groupLines = formatEvidenceGroup(group); + for (const line of groupLines) { assembled.recentEvidence.lines.push(line); } - recentBudget.used += unit.totalTokens; - usedL0Ids.add(l0.id); + recentBudget.used += group.totalTokens; + for (const l0 of l0s) { + usedL0Ids.add(l0.id); + } injectionStats.recentEvidence.units++; } @@ -951,13 +1118,17 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities, : 100; metrics.quality.eventPrecisionProxy = metrics.event?.similarityDistribution?.mean || 0; - const totalL0Selected = l0Selected.length; - const l0WithL1 = l0Selected.filter(l0 => { - const pair = l1ByFloor.get(l0.floor); - return pair?.aiTop1 || pair?.userTop1; - }).length; - metrics.quality.l1AttachRate = totalL0Selected > 0 - ? Math.round(l0WithL1 / totalL0Selected * 100) + // l1AttachRate:有 L1 挂载的唯一楼层占所有 L0 覆盖楼层的比例 + const l0Floors = new Set(l0Selected.map(l0 => l0.floor)); + const l0FloorsWithL1 = new Set(); + for (const floor of l0Floors) { + const pair = l1ByFloor.get(floor); + if (pair?.aiTop1 || pair?.userTop1) { + l0FloorsWithL1.add(floor); + } + } + metrics.quality.l1AttachRate = l0Floors.size > 0 + ? Math.round(l0FloorsWithL1.size / l0Floors.size * 100) : 0; metrics.quality.potentialIssues = detectIssues(metrics); @@ -1036,7 +1207,7 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) { if (echo && canNotifyRecallFail()) { const msg = String(e?.message || "未知错误").replace(/\s+/g, " ").slice(0, 200); - await echo(`/echo severity=warning 向量召回失败:${msg}`); + await echo(`/echo severity=warning 嵌入 API 请求失败:${msg}(本次跳过记忆召回)`); } if (postToFrame) { @@ -1055,12 +1226,21 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) { (recallResult?.causalChain?.length || 0) > 0; if (!hasUseful) { - if (echo && canNotifyRecallFail()) { - await echo( - "/echo severity=warning 向量召回失败:没有可用召回结果(请先在面板中生成向量,或检查指纹不匹配)" - ); + const noVectorsGenerated = !meta?.fingerprint || (meta?.lastChunkFloor ?? -1) < 0; + const fpMismatch = meta?.fingerprint && meta.fingerprint !== getEngineFingerprint(vectorCfg); + + if (fpMismatch) { + if (echo && canNotifyRecallFail()) { + await echo("/echo severity=warning 向量引擎已变更,请重新生成向量"); + } + } else if (noVectorsGenerated) { + if (echo && canNotifyRecallFail()) { + await echo("/echo severity=warning 没有可用向量,请在剧情总结面板中生成向量"); + } } - if (postToFrame) { + // 向量存在但本次未命中 → 静默跳过,不打扰用户 + + if (postToFrame && (noVectorsGenerated || fpMismatch)) { postToFrame({ type: "RECALL_LOG", text: "\n[Vector Recall Empty]\nNo recall candidates / vectors not ready.\n", diff --git a/modules/story-summary/story-summary.js b/modules/story-summary/story-summary.js index e1ba523..8ea7754 100644 --- a/modules/story-summary/story-summary.js +++ b/modules/story-summary/story-summary.js @@ -526,6 +526,52 @@ async function handleClearVectors() { xbLog.info(MODULE_ID, "向量数据已清除"); } +// ═══════════════════════════════════════════════════════════════════════════ +// L0 自动补提取(每收到新消息后检查并补提取缺失楼层) +// ═══════════════════════════════════════════════════════════════════════════ + +async function maybeAutoExtractL0() { + const vectorCfg = getVectorConfig(); + if (!vectorCfg?.enabled) return; + if (anchorGenerating || vectorGenerating) return; + + const { chatId, chat } = getContext(); + if (!chatId || !chat?.length) return; + + const stats = await getAnchorStats(); + if (stats.pending <= 0) return; + + anchorGenerating = true; + + try { + await incrementalExtractAtoms(chatId, chat, null, { maxFloors: 20 }); + + // 为新提取的 L0 楼层构建 L1 chunks + await buildIncrementalChunks({ vectorConfig: vectorCfg }); + + invalidateLexicalIndex(); + + await sendAnchorStatsToFrame(); + await sendVectorStatsToFrame(); + + xbLog.info(MODULE_ID, "自动 L0 补提取完成"); + } catch (e) { + xbLog.error(MODULE_ID, "自动 L0 补提取失败", e); + } finally { + anchorGenerating = false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Embedding 连接预热 +// ═══════════════════════════════════════════════════════════════════════════ + +function warmupEmbeddingConnection() { + const vectorCfg = getVectorConfig(); + if (!vectorCfg?.enabled) return; + embed(['.'], vectorCfg, { timeout: 5000 }).catch(() => {}); +} + // ═══════════════════════════════════════════════════════════════════════════ // 实体词典注入 + 索引预热 // ═══════════════════════════════════════════════════════════════════════════ @@ -1284,6 +1330,9 @@ async function handleChatChanged() { // 实体词典注入 + 索引预热 refreshEntityLexiconAndWarmup(); + // Embedding 连接预热(保持 TCP keep-alive,减少首次召回超时) + warmupEmbeddingConnection(); + setTimeout(() => checkVectorIntegrityAndWarn(), 2000); } @@ -1316,7 +1365,10 @@ async function handleMessageReceived() { // 向量全量生成中时跳过 L1 sync(避免竞争写入) if (vectorGenerating) return; - await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig); + await syncOnMessageReceived(chatId, lastFloor, message, vectorConfig, () => { + sendAnchorStatsToFrame(); + sendVectorStatsToFrame(); + }); await maybeAutoBuildChunks(); applyHideStateDebounced(); @@ -1324,6 +1376,9 @@ async function handleMessageReceived() { // 新消息后刷新实体词典(可能有新角色) refreshEntityLexiconAndWarmup(); + + // 自动补提取缺失的 L0(延迟执行,避免与当前楼提取竞争) + setTimeout(() => maybeAutoExtractL0(), 2000); } function handleMessageSent() { diff --git a/modules/story-summary/vector/pipeline/chunk-builder.js b/modules/story-summary/vector/pipeline/chunk-builder.js index 7420efe..c0b9174 100644 --- a/modules/story-summary/vector/pipeline/chunk-builder.js +++ b/modules/story-summary/vector/pipeline/chunk-builder.js @@ -334,7 +334,7 @@ export async function syncOnMessageSwiped(chatId, lastFloor) { /** * 新消息后同步:删除 + 重建最后楼层 */ -export async function syncOnMessageReceived(chatId, lastFloor, message, vectorConfig) { +export async function syncOnMessageReceived(chatId, lastFloor, message, vectorConfig, onL0Complete) { if (!chatId || lastFloor < 0 || !message) return; if (!vectorConfig?.enabled) return; @@ -368,11 +368,10 @@ export async function syncOnMessageReceived(chatId, lastFloor, message, vectorCo const userMessage = (userFloor >= 0 && chat[userFloor]?.is_user) ? chat[userFloor] : null; try { - await extractAndStoreAtomsForRound(lastFloor, message, userMessage); + await extractAndStoreAtomsForRound(lastFloor, message, userMessage, onL0Complete); } catch (e) { xbLog.warn(MODULE_ID, `Atom 提取失败: floor ${lastFloor}`, e); } } } - diff --git a/modules/story-summary/vector/pipeline/state-integration.js b/modules/story-summary/vector/pipeline/state-integration.js index f5d1ae6..e305580 100644 --- a/modules/story-summary/vector/pipeline/state-integration.js +++ b/modules/story-summary/vector/pipeline/state-integration.js @@ -112,7 +112,8 @@ function buildL0InputText(userMessage, aiMessage) { return parts.join('\n\n---\n\n').trim(); } -export async function incrementalExtractAtoms(chatId, chat, onProgress) { +export async function incrementalExtractAtoms(chatId, chat, onProgress, options = {}) { + const { maxFloors = Infinity } = options; if (!chatId || !chat?.length) return { built: 0 }; const vectorCfg = getVectorConfig(); @@ -144,6 +145,11 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress) { pendingPairs.push({ userMsg, aiMsg: msg, aiFloor: i }); } + // 限制单次提取楼层数(自动触发时使用) + if (pendingPairs.length > maxFloors) { + pendingPairs.length = maxFloors; + } + if (!pendingPairs.length) { onProgress?.('已全部提取', 0, 0); return { built: 0 }; @@ -323,14 +329,14 @@ export async function clearAllAtomsAndVectors(chatId) { let extractionQueue = []; let isProcessing = false; -export async function extractAndStoreAtomsForRound(aiFloor, aiMessage, userMessage) { +export async function extractAndStoreAtomsForRound(aiFloor, aiMessage, userMessage, onComplete) { const { chatId } = getContext(); if (!chatId) return; const vectorCfg = getVectorConfig(); if (!vectorCfg?.enabled) return; - extractionQueue.push({ aiFloor, aiMessage, userMessage, chatId }); + extractionQueue.push({ aiFloor, aiMessage, userMessage, chatId, onComplete }); processQueue(); } @@ -339,13 +345,14 @@ async function processQueue() { isProcessing = true; while (extractionQueue.length > 0) { - const { aiFloor, aiMessage, userMessage, chatId } = extractionQueue.shift(); + const { aiFloor, aiMessage, userMessage, chatId, onComplete } = extractionQueue.shift(); try { const atoms = await extractAtomsForRound(userMessage, aiMessage, aiFloor, { timeout: 12000 }); if (!atoms?.length) { xbLog.info(MODULE_ID, `floor ${aiFloor}: 无有效 atoms`); + onComplete?.({ floor: aiFloor, atomCount: 0 }); continue; } @@ -356,8 +363,10 @@ async function processQueue() { await vectorizeAtomsSimple(chatId, atoms); xbLog.info(MODULE_ID, `floor ${aiFloor}: ${atoms.length} atoms 已存储`); + onComplete?.({ floor: aiFloor, atomCount: atoms.length }); } catch (e) { xbLog.error(MODULE_ID, `floor ${aiFloor} 处理失败`, e); + onComplete?.({ floor: aiFloor, atomCount: 0, error: e }); } } diff --git a/modules/story-summary/vector/retrieval/recall.js b/modules/story-summary/vector/retrieval/recall.js index 0d85b12..853cc2d 100644 --- a/modules/story-summary/vector/retrieval/recall.js +++ b/modules/story-summary/vector/retrieval/recall.js @@ -878,16 +878,23 @@ export async function recallMemory(allEvents, vectorConfig, options = {}) { try { const [vec] = await embed([bundle.queryText_v0], vectorConfig, { timeout: 10000 }); queryVector_v0 = vec; - } catch (e) { - xbLog.error(MODULE_ID, 'Round 1 向量化失败', e); - metrics.timing.total = Math.round(performance.now() - T0); - return { - events: [], l0Selected: [], l1ByFloor: new Map(), causalChain: [], - focusEntities: bundle.focusEntities, - elapsed: metrics.timing.total, - logText: 'Embedding failed (round 1).', - metrics, - }; + } catch (e1) { + xbLog.warn(MODULE_ID, 'Round 1 向量化失败,500ms 后重试', e1); + await new Promise(r => setTimeout(r, 500)); + try { + const [vec] = await embed([bundle.queryText_v0], vectorConfig, { timeout: 15000 }); + queryVector_v0 = vec; + } catch (e2) { + xbLog.error(MODULE_ID, 'Round 1 向量化重试仍失败', e2); + metrics.timing.total = Math.round(performance.now() - T0); + return { + events: [], l0Selected: [], l1ByFloor: new Map(), causalChain: [], + focusEntities: bundle.focusEntities, + elapsed: metrics.timing.total, + logText: 'Embedding failed (round 1, after retry).', + metrics, + }; + } } if (!queryVector_v0?.length) {