From 4aeba55e909f6e1e31fe3aedff32abad10e2ab24 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sun, 29 Mar 2026 20:02:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20recall=20card=20UI=20v2=20=E2=80=94=20i?= =?UTF-8?q?nline=20card=20with=20force-graph=20sub-graph,=20sidebar=20edit?= =?UTF-8?q?or,=20no=20more=20prompt/alert/confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- graph-renderer.js | 63 +++-- index.js | 233 ++++++++---------- recall-message-ui.js | 517 +++++++++++++++++++++++++++++++++++++++ style.css | 431 ++++++++++++++++++++++++++++++++ tests/p0-regressions.mjs | 183 +++----------- 6 files changed, 1143 insertions(+), 298 deletions(-) create mode 100644 recall-message-ui.js diff --git a/README.md b/README.md index 79ffee9..6b2265b 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ ST-BME/ ├── injector.js # 召回结果格式化注入 ├── runtime-state.js # 运行时状态:楼层 hash、dirty 标记、恢复日志 ├── recall-persistence.js # 持久召回记录(message.extra.bme_recall) +├── recall-message-ui.js # 消息级召回卡片 UI(子图渲染 + 侧边栏编辑) ├── vector-index.js # 向量索引管理(backend / direct 双模式) ├── embedding.js # 直连 Embedding API 封装 ├── llm.js # 记忆 LLM 请求封装 @@ -366,11 +367,14 @@ ST-BME/ 消息级 UI: -- 带有 `bme_recall` 的用户气泡会显示 🧠 badge。 -- 点击 badge 可进行:查看详情 / 手动编辑 / 删除 / 重新召回。 -- 手动编辑后会将 `manuallyEdited=true`。 -- 重新召回成功后会覆盖记录并重置 `manuallyEdited=false`。 -- 删除会移除该楼层的持久召回记录。 +- 带有 `bme_recall` 的用户消息会显示内联卡片(含用户消息 + 🧠 召回条 + 记忆数 badge)。 +- 点击召回条展开,显示**力导向子图**(仅渲染被召回的节点和它们之间的边,复用 `GraphRenderer`)。 +- 子图中节点可拖拽/缩放,点击节点打开**右侧边栏**查看节点详情。 +- 操作按钮(展开态底部): + - **✏️ 编辑**:打开侧边栏编辑注入文本(实时 token 计数),保存后标记 `manuallyEdited=true`。 + - **🗑 删除**:二次确认(按钮变红 3s 超时重置),确认后移除持久召回记录。 + - **🔄 重新召回**:重新执行召回并覆盖记录,`manuallyEdited` 重置为 `false`。 +- 不再使用 `prompt()` / `alert()` / `confirm()` 浏览器原生对话框。 兼容性说明: diff --git a/graph-renderer.js b/graph-renderer.js index 00fe40f..2d24cbb 100644 --- a/graph-renderer.js +++ b/graph-renderer.js @@ -17,7 +17,7 @@ import { getGraphNodeLabel, getNodeDisplayName } from './node-labels.js'; * @property {boolean} pinned */ -const FORCE_CONFIG = { +const DEFAULT_FORCE_CONFIG = { repulsion: 500, // 库仑斥力常数 springLength: 120, // 弹簧自然长度 springK: 0.08, // 弹簧刚度 @@ -34,9 +34,17 @@ const FORCE_CONFIG = { export class GraphRenderer { /** * @param {HTMLCanvasElement} canvas - * @param {string} themeName + * @param {string|object} [options] - 主题名称字符串(向后兼容)或配置对象 + * options.theme {string} - 主题名称 + * options.forceConfig {object} - 力导向参数覆盖 + * options.onNodeClick {function} - 节点点击回调 + * options.onNodeDoubleClick {function} - 节点双击回调 */ - constructor(canvas, themeName = 'crimson') { + constructor(canvas, options = 'crimson') { + const isLegacy = typeof options === 'string'; + const themeName = isLegacy ? options : (options?.theme || 'crimson'); + const forceOverride = isLegacy ? {} : (options?.forceConfig || {}); + this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.nodes = []; @@ -44,6 +52,7 @@ export class GraphRenderer { this.nodeMap = new Map(); this.colors = getNodeColors(themeName); this.themeName = themeName; + this.config = { ...DEFAULT_FORCE_CONFIG, ...forceOverride }; // View transform this.scale = 1; @@ -64,7 +73,9 @@ export class GraphRenderer { this.animId = null; // Callbacks - this.onNodeSelect = null; + this.onNodeSelect = isLegacy ? null : (options?.onNodeSelect || null); + this.onNodeClick = isLegacy ? null : (options?.onNodeClick || null); + this.onNodeDoubleClick = isLegacy ? null : (options?.onNodeDoubleClick || null); this._bindEvents(); this._resizeObserver = new ResizeObserver(() => this._resize()); @@ -138,7 +149,7 @@ export class GraphRenderer { // ==================== 力导向计算 ==================== _applyForces() { - const { nodes, edges } = this; + const { nodes, edges, config } = this; const W = this.canvas.width / window.devicePixelRatio; const H = this.canvas.height / window.devicePixelRatio; const cx = W / 2, cy = H / 2; @@ -149,7 +160,7 @@ export class GraphRenderer { const a = nodes[i], b = nodes[j]; let dx = b.x - a.x, dy = b.y - a.y; let dist = Math.sqrt(dx * dx + dy * dy) || 1; - let force = FORCE_CONFIG.repulsion / (dist * dist); + let force = config.repulsion / (dist * dist); let fx = (dx / dist) * force; let fy = (dy / dist) * force; if (!a.pinned) { a.vx -= fx; a.vy -= fy; } @@ -162,8 +173,8 @@ export class GraphRenderer { const { from, to, strength } = edge; let dx = to.x - from.x, dy = to.y - from.y; let dist = Math.sqrt(dx * dx + dy * dy) || 1; - let displacement = dist - FORCE_CONFIG.springLength; - let force = FORCE_CONFIG.springK * displacement * strength; + let displacement = dist - config.springLength; + let force = config.springK * displacement * strength; let fx = (dx / dist) * force; let fy = (dy / dist) * force; if (!from.pinned) { from.vx += fx; from.vy += fy; } @@ -173,15 +184,15 @@ export class GraphRenderer { // 向心力 for (const node of nodes) { if (node.pinned) continue; - node.vx += (cx - node.x) * FORCE_CONFIG.centerGravity; - node.vy += (cy - node.y) * FORCE_CONFIG.centerGravity; + node.vx += (cx - node.x) * config.centerGravity; + node.vy += (cy - node.y) * config.centerGravity; } // 更新位置 for (const node of nodes) { if (node.pinned) continue; - node.vx *= FORCE_CONFIG.damping; - node.vy *= FORCE_CONFIG.damping; + node.vx *= config.damping; + node.vy *= config.damping; node.x += node.vx; node.y += node.vy; // 边界约束 @@ -252,7 +263,7 @@ export class GraphRenderer { // 标签 ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.95 : 0.65})`; - ctx.font = `${FORCE_CONFIG.labelFontSize}px Inter, sans-serif`; + ctx.font = `${this.config.labelFontSize}px Inter, sans-serif`; ctx.textAlign = 'center'; ctx.fillText(node.label || node.name, node.x, node.y + r + 14); } @@ -261,9 +272,11 @@ export class GraphRenderer { } _drawGrid(W, H) { + const sp = this.config.gridSpacing; + if (!sp || sp <= 0) return; + const ctx = this.ctx; - const sp = FORCE_CONFIG.gridSpacing; - ctx.strokeStyle = FORCE_CONFIG.gridColor; + ctx.strokeStyle = this.config.gridColor; ctx.lineWidth = 0.5; const startX = Math.floor(-this.offsetX / this.scale / sp) * sp; const startY = Math.floor(-this.offsetY / this.scale / sp) * sp; @@ -285,8 +298,8 @@ export class GraphRenderer { } _nodeRadius(node) { - const min = FORCE_CONFIG.minNodeRadius; - const max = FORCE_CONFIG.maxNodeRadius; + const min = this.config.minNodeRadius; + const max = this.config.maxNodeRadius; return min + ((node.importance || 5) / 10) * (max - min); } @@ -305,7 +318,7 @@ export class GraphRenderer { _tick() { if (!this.animating) return; - if (this.iteration < FORCE_CONFIG.maxIterations) { + if (this.iteration < this.config.maxIterations) { this._applyForces(); this.iteration++; } @@ -361,6 +374,7 @@ export class GraphRenderer { const { x, y } = this._canvasToWorld(e.clientX, e.clientY); const node = this._findNodeAt(x, y); this.lastMouse = { x: e.clientX, y: e.clientY }; + this._dragStartMouse = { x: e.clientX, y: e.clientY }; if (node) { this.dragNode = node; @@ -399,14 +413,22 @@ export class GraphRenderer { if (this.dragNode) { this.dragNode.pinned = false; if (this.isDragging) { + const start = this._dragStartMouse || { x: 0, y: 0 }; + const dx = (this.lastMouse.x - start.x); + const dy = (this.lastMouse.y - start.y); + const movedDistance = Math.sqrt(dx * dx + dy * dy); // 如果拖动距离很小,视为点击选中 - this.selectedNode = this.dragNode; - if (this.onNodeSelect) this.onNodeSelect(this.dragNode); + if (movedDistance < 6) { + this.selectedNode = this.dragNode; + if (this.onNodeSelect) this.onNodeSelect(this.dragNode); + if (this.onNodeClick) this.onNodeClick(this.dragNode); + } } } this.dragNode = null; this.isDragging = false; this.isPanning = false; + this._dragStartMouse = null; } _onWheel(e) { @@ -431,6 +453,7 @@ export class GraphRenderer { if (node) { this.selectedNode = node; if (this.onNodeSelect) this.onNodeSelect(node); + if (this.onNodeDoubleClick) this.onNodeDoubleClick(node); this._render(); } } diff --git a/index.js b/index.js index 336c56e..e73de9a 100644 --- a/index.js +++ b/index.js @@ -196,6 +196,12 @@ import { resolveGenerationTargetUserMessageIndex, writePersistedRecallToUserMessage, } from "./recall-persistence.js"; +import { + createRecallCardElement, + openRecallSidebar, + updateRecallCardData, +} from "./recall-message-ui.js"; + // 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; @@ -1123,48 +1129,67 @@ function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) { return Number.isFinite(fallbackIndex) ? fallbackIndex : null; } -function buildMessageRecallBadgeTitle(messageIndex, record) { - const lines = [`ST-BME 持久召回 · 楼层 ${messageIndex}`]; - if (record?.manuallyEdited) { - lines.push("来源:手动编辑"); - } - if (Number.isFinite(record?.generationCount)) { - lines.push(`已回退使用:${record.generationCount} 次`); - } - if (record?.updatedAt) { - lines.push(`更新:${record.updatedAt}`); - } - return lines.join("\n"); +function getRecallCardCallbacks() { + return { + onEdit: (messageIndex) => { + const record = getMessageRecallRecord(messageIndex); + if (!record) return; + openRecallSidebar({ + mode: "edit", + messageIndex, + record, + node: null, + graph: currentGraph, + callbacks: { + onSave: (idx, newText) => { + const edited = editMessageRecallRecord(idx, newText); + if (edited) { + toastr.success("已保存手动编辑"); + } else { + toastr.warning("编辑失败:注入文本不能为空"); + } + schedulePersistedRecallMessageUiRefresh(); + }, + estimateTokens, + }, + }); + }, + onDelete: (messageIndex) => { + if (removeMessageRecallRecord(messageIndex)) { + toastr.success("已删除持久召回注入"); + schedulePersistedRecallMessageUiRefresh(); + } + }, + onRerunRecall: async (messageIndex) => { + const result = await rerunRecallForMessage(messageIndex); + if (result?.status === "completed") { + toastr.success("重新召回完成"); + } + schedulePersistedRecallMessageUiRefresh(); + }, + onNodeClick: (messageIndex, node) => { + const record = getMessageRecallRecord(messageIndex); + if (!record) return; + openRecallSidebar({ + mode: "view", + messageIndex, + record, + node, + graph: currentGraph, + callbacks: { + onSave: (idx, newText) => { + const edited = editMessageRecallRecord(idx, newText); + if (edited) toastr.success("已保存手动编辑"); + else toastr.warning("编辑失败:注入文本不能为空"); + schedulePersistedRecallMessageUiRefresh(); + }, + estimateTokens, + }, + }); + }, + }; } -function createMessageRecallBadgeElement(messageIndex, record) { - const badge = document.createElement("button"); - badge.type = "button"; - badge.className = "st-bme-recall-badge"; - badge.textContent = "🧠"; - badge.dataset.messageIndex = String(messageIndex); - badge.style.marginInlineStart = "6px"; - badge.style.padding = "0 4px"; - badge.style.borderRadius = "10px"; - badge.style.border = "1px solid var(--SmartThemeBorderColor, #666)"; - badge.style.background = "var(--SmartThemeQuoteColor, rgba(120, 120, 120, 0.18))"; - badge.style.cursor = "pointer"; - badge.style.fontSize = "12px"; - badge.style.lineHeight = "1.4"; - - badge.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - const indexFromDataset = Number.parseInt(badge.dataset.messageIndex, 10); - void onMessageRecallBadgeClick( - Number.isFinite(indexFromDataset) ? indexFromDataset : messageIndex, - ); - }); - - badge.title = buildMessageRecallBadgeTitle(messageIndex, record); - badge.dataset.updatedAt = String(record?.updatedAt || ""); - return badge; -} function refreshPersistedRecallMessageUi() { const context = getContext(); @@ -1174,47 +1199,72 @@ function refreshPersistedRecallMessageUi() { const chatRoot = document.getElementById("chat"); if (!chatRoot) return; + const themeName = getSettings()?.panelTheme || "crimson"; + const callbacks = getRecallCardCallbacks(); + const messageElements = Array.from(chatRoot.querySelectorAll(".mes")); for (let fallbackIndex = 0; fallbackIndex < messageElements.length; fallbackIndex++) { const messageElement = messageElements[fallbackIndex]; const messageIndex = resolveMessageIndexFromElement(messageElement, fallbackIndex); if (!Number.isFinite(messageIndex)) continue; - const existingBadges = Array.from( + // Clean up old-style badges (migration from v1) + const oldBadges = Array.from( messageElement.querySelectorAll?.(".st-bme-recall-badge") || [], ); - const existingBadge = existingBadges[0] || null; - for (let index = 1; index < existingBadges.length; index++) { - existingBadges[index].remove(); + for (const oldBadge of oldBadges) oldBadge.remove(); + + // Find existing card + const existingCards = Array.from( + messageElement.querySelectorAll?.(".bme-recall-card") || [], + ); + let existingCard = null; + for (const card of existingCards) { + if (card.dataset.messageIndex === String(messageIndex)) { + existingCard = card; + } else { + card._bmeDestroyRenderer?.(); + card.remove(); + } } const message = chat[messageIndex]; if (!message?.is_user) { - existingBadge?.remove(); + if (existingCard) { + existingCard._bmeDestroyRenderer?.(); + existingCard.remove(); + } continue; } const record = readPersistedRecallFromUserMessage(chat, messageIndex); if (!record?.injectionText) { - existingBadge?.remove(); + if (existingCard) { + existingCard._bmeDestroyRenderer?.(); + existingCard.remove(); + } continue; } - const badge = existingBadge || createMessageRecallBadgeElement(messageIndex, record); - badge.dataset.messageIndex = String(messageIndex); - const nextUpdatedAt = String(record.updatedAt || ""); - if (badge.dataset.updatedAt !== nextUpdatedAt) { - badge.dataset.updatedAt = nextUpdatedAt; - badge.title = buildMessageRecallBadgeTitle(messageIndex, record); - } + if (existingCard) { + // Update data without rebuilding (preserves expanded state) + updateRecallCardData(existingCard, record); + } else { + // Create new card + const card = createRecallCardElement({ + messageIndex, + record, + userMessageText: message.mes || "", + graph: currentGraph, + themeName, + callbacks, + }); - if (!existingBadge) { const anchor = - messageElement.querySelector?.(".mes_buttons") || - messageElement.querySelector?.(".mes_title") || - messageElement.querySelector?.(".mes_header") || + messageElement.querySelector?.(".mes_block") || + messageElement.querySelector?.(".mes_text")?.parentElement || messageElement; - anchor.appendChild(badge); + anchor.appendChild(card); } } } @@ -1227,22 +1277,6 @@ function schedulePersistedRecallMessageUiRefresh(delayMs = 120) { }, Math.max(16, Number.parseInt(delayMs, 10) || 120)); } -function showMessageRecallDetail(messageIndex, record) { - const details = [ - `楼层: ${messageIndex}`, - `来源: ${record.recallSource || "unknown"}`, - `Hook: ${record.hookName || "-"}`, - `tokenEstimate: ${record.tokenEstimate || 0}`, - `generationCount: ${record.generationCount || 0}`, - `manuallyEdited: ${record.manuallyEdited ? "true" : "false"}`, - `updatedAt: ${record.updatedAt || "-"}`, - "", - "注入内容:", - record.injectionText || "(empty)", - ].join("\n"); - globalThis.alert?.(details); -} - async function rerunRecallForMessage(messageIndex) { const chat = getContext()?.chat; const message = Array.isArray(chat) ? chat[messageIndex] : null; @@ -1273,57 +1307,6 @@ async function rerunRecallForMessage(messageIndex) { return result; } -async function onMessageRecallBadgeClick(messageIndex) { - const record = getMessageRecallRecord(messageIndex); - if (!record) { - toastr.info("该楼层暂无持久召回记录"); - schedulePersistedRecallMessageUiRefresh(); - return; - } - - const choiceRaw = globalThis.prompt?.( - [ - `ST-BME 持久召回(楼层 ${messageIndex})`, - "1 查看详情", - "2 手动编辑", - "3 删除", - "4 重新召回", - "请输入序号:", - ].join("\n"), - "1", - ); - const choice = String(choiceRaw || "").trim().toLowerCase(); - if (!choice) return; - - if (choice === "1" || choice === "view" || choice === "detail") { - showMessageRecallDetail(messageIndex, record); - } else if (choice === "2" || choice === "edit") { - const nextText = globalThis.prompt?.( - `编辑楼层 ${messageIndex} 的持久召回注入文本:`, - record.injectionText || "", - ); - if (nextText !== null && nextText !== undefined) { - const edited = editMessageRecallRecord(messageIndex, nextText); - if (edited) { - toastr.success("已保存手动编辑并标记 manuallyEdited=true"); - } else { - toastr.warning("编辑失败:注入文本不能为空"); - } - } - } else if (choice === "3" || choice === "delete") { - const confirmed = globalThis.confirm?.(`确认删除楼层 ${messageIndex} 的持久召回注入?`); - if (confirmed && removeMessageRecallRecord(messageIndex)) { - toastr.success("已删除持久召回注入"); - } - } else if (choice === "4" || choice === "reroll" || choice === "recall") { - const rerunResult = await rerunRecallForMessage(messageIndex); - if (rerunResult?.status === "completed") { - toastr.success("重新召回完成,已覆盖持久召回记录"); - } - } - - schedulePersistedRecallMessageUiRefresh(); -} function getSendTextareaValue() { return String(document.getElementById("send_textarea")?.value ?? ""); diff --git a/recall-message-ui.js b/recall-message-ui.js new file mode 100644 index 0000000..9d012a0 --- /dev/null +++ b/recall-message-ui.js @@ -0,0 +1,517 @@ +// ST-BME: 消息级召回卡片 UI +// 纯 DOM 构建模块,不含模块级 mutable state + +import { GraphRenderer } from "./graph-renderer.js"; + +// ==================== 常量 ==================== + +export const RECALL_CARD_FORCE_CONFIG = { + repulsion: 1200, + springLength: 50, + springK: 0.04, + damping: 0.85, + centerGravity: 0.08, + maxIterations: 80, + minNodeRadius: 6, + maxNodeRadius: 14, + labelFontSize: 11, + gridSpacing: 0, + gridColor: "transparent", +}; + +const DELETE_CONFIRM_TIMEOUT_MS = 3000; + +// ==================== 子图构建 ==================== + +/** + * 从完整图谱中提取召回节点子图 + * @param {object} graph - currentGraph + * @param {string[]} selectedNodeIds + * @returns {{ nodes: Array, edges: Array }} + */ +export function buildRecallSubGraph(graph, selectedNodeIds) { + if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(selectedNodeIds)) { + return { nodes: [], edges: [] }; + } + + const idSet = new Set(selectedNodeIds); + const nodes = graph.nodes + .filter((n) => idSet.has(n.id) && !n.archived) + .map((n) => ({ ...n })); + + const edges = (graph.edges || []) + .filter( + (e) => + !e.invalidAt && + !e.expiredAt && + idSet.has(e.fromId) && + idSet.has(e.toId), + ); + + return { nodes, edges }; +} + +// ==================== 辅助 DOM ==================== + +function el(tag, className, textContent) { + const element = document.createElement(tag); + if (className) element.className = className; + if (textContent !== undefined) element.textContent = textContent; + return element; +} + +function formatTokenHint(tokenEstimate) { + if (!Number.isFinite(tokenEstimate) || tokenEstimate <= 0) return ""; + return `~${tokenEstimate} tokens`; +} + +function formatMetaLine(record) { + const parts = []; + if (record.recallSource) parts.push(`来源: ${record.recallSource}`); + if (record.tokenEstimate > 0) parts.push(`~${record.tokenEstimate} tokens`); + if (Number.isFinite(record.generationCount) && record.generationCount > 0) { + parts.push(`回退 ${record.generationCount} 次`); + } + if (record.updatedAt) { + const dateStr = String(record.updatedAt).replace(/T/, " ").replace(/\.\d+Z$/, ""); + parts.push(dateStr); + } + return parts.join(" · "); +} + +// ==================== 卡片 DOM 构建 ==================== + +/** + * 创建消息级召回卡片 DOM + * @param {object} params + * @param {number} params.messageIndex + * @param {object} params.record - bme_recall record + * @param {string} params.userMessageText + * @param {object|null} params.graph - currentGraph + * @param {string} params.themeName + * @param {object} params.callbacks + * @returns {HTMLElement} + */ +export function createRecallCardElement({ + messageIndex, + record, + userMessageText = "", + graph = null, + themeName = "crimson", + callbacks = {}, +}) { + const card = el("div", "bme-recall-card"); + card.dataset.messageIndex = String(messageIndex); + card.dataset.updatedAt = String(record?.updatedAt || ""); + + // -- 用户消息区 -- + const userLabel = el("div", "bme-recall-user-label"); + userLabel.innerHTML = "💬 本轮用户输入"; + card.appendChild(userLabel); + + const userText = el("div", "bme-recall-user-text", userMessageText || "(empty)"); + card.appendChild(userText); + + // -- 召回条 -- + const nodeCount = Array.isArray(record?.selectedNodeIds) + ? record.selectedNodeIds.length + : 0; + const bar = el("div", "bme-recall-bar"); + + const barIcon = el("span", "bme-recall-bar-icon", "🧠"); + bar.appendChild(barIcon); + + const barTitle = el("span", "bme-recall-bar-title", "相关记忆召回"); + bar.appendChild(barTitle); + + const badge = el( + "span", + "bme-recall-count-badge", + nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓", + ); + bar.appendChild(badge); + + const tokenHint = el( + "span", + "bme-recall-token-hint", + formatTokenHint(record?.tokenEstimate), + ); + bar.appendChild(tokenHint); + + const arrow = el("span", "bme-recall-expand-arrow", "▶"); + bar.appendChild(arrow); + + card.appendChild(bar); + + // -- 展开内容区 -- + const body = el("div", "bme-recall-body"); + card.appendChild(body); + + // renderer 实例管理 + let renderer = null; + + function destroyRenderer() { + if (renderer) { + renderer.stopAnimation(); + renderer.destroy(); + renderer = null; + } + } + + function buildExpandedContent() { + body.innerHTML = ""; + + const subGraph = graph + ? buildRecallSubGraph(graph, record?.selectedNodeIds || []) + : { nodes: [], edges: [] }; + + if (subGraph.nodes.length === 0) { + const emptyMsg = el( + "div", + "bme-recall-empty", + graph ? "召回节点已不存在或图谱已重建" : "图谱未就绪", + ); + body.appendChild(emptyMsg); + } else { + // Canvas 容器 + const canvasWrap = el("div", "bme-recall-canvas-wrap"); + const canvas = document.createElement("canvas"); + canvasWrap.appendChild(canvas); + body.appendChild(canvasWrap); + + // 创建小画布 GraphRenderer + renderer = new GraphRenderer(canvas, { + theme: themeName, + forceConfig: RECALL_CARD_FORCE_CONFIG, + onNodeClick: (node) => { + if (typeof callbacks.onNodeClick === "function") { + callbacks.onNodeClick(messageIndex, node); + } + }, + onNodeDoubleClick: (node) => { + if (typeof callbacks.onNodeClick === "function") { + callbacks.onNodeClick(messageIndex, node); + } + }, + }); + renderer.loadGraph(subGraph); + } + + // 元信息行 + const meta = el("div", "bme-recall-meta", formatMetaLine(record || {})); + if (record?.manuallyEdited) { + const tag = el("span", "bme-recall-meta-tag", "✍ 手动编辑"); + meta.appendChild(tag); + } + body.appendChild(meta); + + // 操作按钮行 + const actions = el("div", "bme-recall-actions"); + + const editBtn = el("button", "bme-recall-action-btn"); + editBtn.innerHTML = '✏️ 编辑'; + editBtn.type = "button"; + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + callbacks.onEdit?.(messageIndex); + }); + actions.appendChild(editBtn); + + const deleteBtn = el("button", "bme-recall-action-btn"); + deleteBtn.innerHTML = '🗑 删除'; + deleteBtn.type = "button"; + setupDeleteConfirmation(deleteBtn, () => { + callbacks.onDelete?.(messageIndex); + }); + actions.appendChild(deleteBtn); + + const recallBtn = el("button", "bme-recall-action-btn"); + recallBtn.innerHTML = '🔄 重新召回'; + recallBtn.type = "button"; + recallBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + setRecallButtonLoading(recallBtn, true); + try { + await callbacks.onRerunRecall?.(messageIndex); + } finally { + setRecallButtonLoading(recallBtn, false); + } + }); + actions.appendChild(recallBtn); + + body.appendChild(actions); + } + + // 点击召回条 toggle 展开/折叠 + bar.addEventListener("click", (e) => { + e.stopPropagation(); + const isExpanded = card.classList.toggle("expanded"); + if (isExpanded) { + buildExpandedContent(); + } else { + destroyRenderer(); + body.innerHTML = ""; + } + }); + + // 暴露清理方法 + card._bmeDestroyRenderer = destroyRenderer; + + return card; +} + +/** + * 更新已有卡片的 badge / token hint / meta(不重建整个卡片) + */ +export function updateRecallCardData(cardElement, record) { + if (!cardElement || !record) return; + + cardElement.dataset.updatedAt = String(record.updatedAt || ""); + + const badge = cardElement.querySelector(".bme-recall-count-badge"); + if (badge) { + const nodeCount = Array.isArray(record.selectedNodeIds) + ? record.selectedNodeIds.length + : 0; + badge.textContent = nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓"; + } + + const tokenHint = cardElement.querySelector(".bme-recall-token-hint"); + if (tokenHint) { + tokenHint.textContent = formatTokenHint(record.tokenEstimate); + } +} + +// ==================== 删除二次确认 ==================== + +export function setupDeleteConfirmation(button, onConfirm) { + let confirmTimer = null; + let pendingConfirm = false; + const originalHTML = button.innerHTML; + + function reset() { + clearTimeout(confirmTimer); + confirmTimer = null; + pendingConfirm = false; + button.innerHTML = originalHTML; + button.classList.remove("danger"); + } + + button.addEventListener("click", (e) => { + e.stopPropagation(); + if (pendingConfirm) { + reset(); + onConfirm(); + return; + } + pendingConfirm = true; + button.textContent = "确认删除?"; + button.classList.add("danger"); + confirmTimer = setTimeout(reset, DELETE_CONFIRM_TIMEOUT_MS); + }); +} + +// ==================== Loading 状态 ==================== + +export function setRecallButtonLoading(button, loading) { + if (loading) { + button._bmeOriginalHTML = button.innerHTML; + button.innerHTML = + ' 召回中...'; + button.classList.add("loading"); + button.disabled = true; + } else { + button.innerHTML = button._bmeOriginalHTML || button.innerHTML; + button.classList.remove("loading"); + button.disabled = false; + } +} + +// ==================== 侧边栏 ==================== + +let sidebarBackdrop = null; +let sidebarElement = null; + +function ensureSidebarDOM() { + if (sidebarBackdrop && sidebarElement) return; + + sidebarBackdrop = el("div", "bme-recall-sidebar-backdrop"); + sidebarBackdrop.addEventListener("click", () => closeRecallSidebar()); + + sidebarElement = el("div", "bme-recall-sidebar"); + + document.body.appendChild(sidebarBackdrop); + document.body.appendChild(sidebarElement); +} + +/** + * 打开召回编辑/查看侧边栏 + * @param {object} params + * @param {'view'|'edit'} params.mode + * @param {number} params.messageIndex + * @param {object} params.record + * @param {object|null} params.node - 点击的节点(view 模式) + * @param {object|null} params.graph + * @param {object} params.callbacks + */ +export function openRecallSidebar({ + mode = "edit", + messageIndex, + record, + node = null, + graph = null, + callbacks = {}, +}) { + ensureSidebarDOM(); + sidebarElement.innerHTML = ""; + + // Header + const header = el("div", "bme-recall-sidebar-header"); + const headerTitle = el("div", "bme-recall-sidebar-header-title"); + headerTitle.textContent = + mode === "edit" ? "📝 编辑召回注入" : "🔍 节点详情"; + header.appendChild(headerTitle); + + const closeBtn = el("button", "bme-recall-sidebar-close"); + closeBtn.innerHTML = "✕"; + closeBtn.type = "button"; + closeBtn.addEventListener("click", () => closeRecallSidebar()); + header.appendChild(closeBtn); + + sidebarElement.appendChild(header); + + // Node info (if viewing a specific node) + if (node && mode === "view") { + const nodeInfo = el("div", "bme-recall-sidebar-node-info"); + const rows = [ + ["类型", node.type || node.raw?.type || "-"], + ["名称", node.name || node.raw?.name || "-"], + ["重要度", String(node.importance ?? node.raw?.importance ?? "-")], + ]; + for (const [label, value] of rows) { + const row = el("div", "bme-recall-sidebar-node-info-row"); + const labelEl = el("span", "bme-recall-sidebar-node-info-label", label); + const valueEl = el("span", "", value); + row.appendChild(labelEl); + row.appendChild(valueEl); + nodeInfo.appendChild(row); + } + + // Show edges to other recalled nodes + if (graph && record?.selectedNodeIds) { + const idSet = new Set(record.selectedNodeIds); + const relatedEdges = (graph.edges || []).filter( + (e) => + !e.invalidAt && + !e.expiredAt && + ((e.fromId === node.id && idSet.has(e.toId)) || + (e.toId === node.id && idSet.has(e.fromId))), + ); + if (relatedEdges.length > 0) { + const edgeRow = el("div", "bme-recall-sidebar-node-info-row"); + const edgeLabel = el("span", "bme-recall-sidebar-node-info-label", "关联"); + const edgeValue = el("span", "", `${relatedEdges.length} 条边`); + edgeRow.appendChild(edgeLabel); + edgeRow.appendChild(edgeValue); + nodeInfo.appendChild(edgeRow); + } + } + + sidebarElement.appendChild(nodeInfo); + } + + // Body + const body = el("div", "bme-recall-sidebar-body"); + const sectionLabel = el( + "div", + "bme-recall-sidebar-section-label", + mode === "edit" ? "注入文本(可编辑)" : "注入文本", + ); + body.appendChild(sectionLabel); + + let textarea = null; + const injectionText = record?.injectionText || ""; + + if (mode === "edit") { + textarea = document.createElement("textarea"); + textarea.className = "bme-recall-sidebar-textarea"; + textarea.value = injectionText; + textarea.placeholder = "输入注入文本..."; + body.appendChild(textarea); + + const tokenHint = el("div", "bme-recall-sidebar-token-hint"); + const updateTokenHint = () => { + const count = + typeof callbacks.estimateTokens === "function" + ? callbacks.estimateTokens(textarea.value) + : textarea.value.length; + tokenHint.textContent = `~${count} tokens`; + }; + updateTokenHint(); + + let debounceTimer = null; + textarea.addEventListener("input", () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(updateTokenHint, 300); + }); + body.appendChild(tokenHint); + } else { + const readonlyEl = el("div", "bme-recall-sidebar-readonly", injectionText || "(empty)"); + body.appendChild(readonlyEl); + } + + sidebarElement.appendChild(body); + + // Footer + const footer = el("div", "bme-recall-sidebar-footer"); + + if (mode === "edit") { + const saveBtn = el("button", "bme-recall-sidebar-btn primary", "保存"); + saveBtn.type = "button"; + saveBtn.addEventListener("click", () => { + const newText = textarea?.value || ""; + callbacks.onSave?.(messageIndex, newText); + closeRecallSidebar(); + }); + footer.appendChild(saveBtn); + + const cancelBtn = el("button", "bme-recall-sidebar-btn secondary", "取消"); + cancelBtn.type = "button"; + cancelBtn.addEventListener("click", () => closeRecallSidebar()); + footer.appendChild(cancelBtn); + } else { + // View mode: offer edit button + const editBtn = el("button", "bme-recall-sidebar-btn primary", "✏️ 编辑"); + editBtn.type = "button"; + editBtn.addEventListener("click", () => { + openRecallSidebar({ + mode: "edit", + messageIndex, + record, + node: null, + graph, + callbacks, + }); + }); + footer.appendChild(editBtn); + + const closeFooterBtn = el("button", "bme-recall-sidebar-btn secondary", "关闭"); + closeFooterBtn.type = "button"; + closeFooterBtn.addEventListener("click", () => closeRecallSidebar()); + footer.appendChild(closeFooterBtn); + } + + sidebarElement.appendChild(footer); + + // Animate in + requestAnimationFrame(() => { + sidebarBackdrop.classList.add("open"); + sidebarElement.classList.add("open"); + if (textarea) textarea.focus(); + }); +} + +export function closeRecallSidebar() { + if (sidebarBackdrop) sidebarBackdrop.classList.remove("open"); + if (sidebarElement) sidebarElement.classList.remove("open"); +} diff --git a/style.css b/style.css index 597c79c..a1767b1 100644 --- a/style.css +++ b/style.css @@ -2705,3 +2705,434 @@ } } + +/* --- Recall Message Card --- */ + +.bme-recall-card { + margin-top: 8px; + background: var(--bme-surface-container, #1f1f22); + border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + border-left: 3px solid var(--bme-primary, #e94560); + border-radius: 8px; + overflow: hidden; + font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif; +} + +.bme-recall-user-label { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px 4px; + font-size: 12px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + font-weight: 500; +} + +.bme-recall-user-text { + padding: 4px 14px 10px; + font-size: 14px; + line-height: 1.6; + color: var(--bme-on-surface, #e4e1e6); + word-break: break-word; +} + +/* --- Recall Bar (collapse/expand trigger) --- */ + +.bme-recall-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: var(--bme-surface-low, #1b1b1e); + cursor: pointer; + user-select: none; + transition: background 0.15s; + border-top: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); +} + +.bme-recall-bar:hover { + background: var(--bme-surface-high, #2a2a2d); +} + +.bme-recall-bar-icon { + font-size: 14px; + flex-shrink: 0; +} + +.bme-recall-bar-title { + font-size: 12px; + font-weight: 600; + color: var(--bme-on-surface, #e4e1e6); + flex-shrink: 0; +} + +.bme-recall-count-badge { + display: inline-flex; + align-items: center; + padding: 1px 8px; + font-size: 11px; + font-weight: 600; + border-radius: 10px; + background: var(--bme-primary-dim, rgba(233, 69, 96, 0.15)); + color: var(--bme-primary-text, #ffb2b7); + flex-shrink: 0; +} + +.bme-recall-token-hint { + font-size: 11px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + margin-left: auto; + flex-shrink: 0; +} + +.bme-recall-expand-arrow { + font-size: 12px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + transition: transform 0.25s ease; + flex-shrink: 0; + margin-left: 4px; +} + +.bme-recall-card.expanded .bme-recall-expand-arrow { + transform: rotate(90deg); +} + +/* --- Recall Body (expanded content) --- */ + +.bme-recall-body { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: + max-height 0.3s ease, + opacity 0.25s ease; +} + +.bme-recall-card.expanded .bme-recall-body { + max-height: 600px; + opacity: 1; +} + +.bme-recall-canvas-wrap { + width: 100%; + height: 250px; + position: relative; + background: var(--bme-surface-lowest, #0e0e11); + border-radius: 0; +} + +.bme-recall-canvas-wrap canvas { + display: block; + width: 100%; + height: 100%; +} + +.bme-recall-empty { + display: flex; + align-items: center; + justify-content: center; + height: 120px; + font-size: 12px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + font-style: italic; +} + +/* --- Recall Meta --- */ + +.bme-recall-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 14px; + font-size: 11px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + border-top: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); +} + +.bme-recall-meta-tag { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + border-radius: 4px; + background: var(--bme-accent3, #ffc107); + color: #000; +} + +/* --- Recall Actions --- */ + +.bme-recall-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 14px 10px; +} + +.bme-recall-action-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + font-size: 11px; + font-weight: 500; + border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + border-radius: 6px; + background: var(--bme-surface-low, #1b1b1e); + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; + white-space: nowrap; +} + +.bme-recall-action-btn:hover { + background: var(--bme-surface-high, #2a2a2d); + border-color: var(--bme-border-active, rgba(233, 69, 96, 0.4)); + color: var(--bme-on-surface, #e4e1e6); +} + +.bme-recall-action-btn.danger { + background: rgba(233, 69, 96, 0.2); + border-color: var(--bme-primary, #e94560); + color: var(--bme-primary, #e94560); +} + +.bme-recall-action-btn.loading { + pointer-events: none; + opacity: 0.7; +} + +@keyframes bme-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.bme-recall-action-btn.loading .bme-recall-btn-icon { + animation: bme-spin 1s linear infinite; +} + +/* --- Recall Sidebar --- */ + +.bme-recall-sidebar-backdrop { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} + +.bme-recall-sidebar-backdrop.open { + opacity: 1; + pointer-events: auto; +} + +.bme-recall-sidebar { + position: fixed; + top: 0; + right: 0; + width: min(400px, 90vw); + height: 100vh; + z-index: 10001; + background: var(--bme-surface-container, #1f1f22); + border-left: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.25s ease; + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.4); +} + +.bme-recall-sidebar.open { + transform: translateX(0); +} + +.bme-recall-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + flex-shrink: 0; +} + +.bme-recall-sidebar-header-title { + font-size: 14px; + font-weight: 600; + color: var(--bme-primary, #e94560); + display: flex; + align-items: center; + gap: 6px; +} + +.bme-recall-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + cursor: pointer; + border-radius: 4px; + font-size: 14px; + transition: all 0.15s; +} + +.bme-recall-sidebar-close:hover { + background: var(--bme-surface-highest, #353438); + color: var(--bme-on-surface, #e4e1e6); +} + +.bme-recall-sidebar-node-info { + padding: 12px 16px; + border-bottom: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + font-size: 12px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + display: flex; + flex-direction: column; + gap: 4px; +} + +.bme-recall-sidebar-node-info-row { + display: flex; + gap: 8px; +} + +.bme-recall-sidebar-node-info-label { + font-weight: 600; + color: var(--bme-on-surface, #e4e1e6); + min-width: 48px; +} + +.bme-recall-sidebar-body { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.bme-recall-sidebar-section-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); +} + +.bme-recall-sidebar-textarea { + width: 100%; + min-height: 200px; + padding: 10px; + font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace; + font-size: 13px; + line-height: 1.6; + background: var(--bme-surface-low, #1b1b1e); + border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + border-radius: 6px; + color: var(--bme-on-surface, #e4e1e6); + resize: vertical; + transition: border-color 0.15s; +} + +.bme-recall-sidebar-textarea:focus { + outline: none; + border-color: var(--bme-border-active, rgba(233, 69, 96, 0.4)); +} + +.bme-recall-sidebar-readonly { + width: 100%; + min-height: 120px; + padding: 10px; + font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace; + font-size: 13px; + line-height: 1.6; + background: var(--bme-surface-low, #1b1b1e); + border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + border-radius: 6px; + color: var(--bme-on-surface, #e4e1e6); + white-space: pre-wrap; + word-break: break-word; + overflow-y: auto; + max-height: 40vh; +} + +.bme-recall-sidebar-token-hint { + font-size: 11px; + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); + font-feature-settings: 'tnum'; +} + +.bme-recall-sidebar-footer { + display: flex; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + flex-shrink: 0; +} + +.bme-recall-sidebar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 7px 16px; + font-size: 12px; + font-weight: 500; + border-radius: 6px; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s; + white-space: nowrap; +} + +.bme-recall-sidebar-btn.primary { + background: var(--bme-primary-dim, rgba(233, 69, 96, 0.15)); + border: 1px solid var(--bme-primary, #e94560); + color: var(--bme-primary-text, #ffb2b7); +} + +.bme-recall-sidebar-btn.primary:hover { + background: var(--bme-primary-glow, rgba(233, 69, 96, 0.35)); +} + +.bme-recall-sidebar-btn.secondary { + background: transparent; + border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08)); + color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6)); +} + +.bme-recall-sidebar-btn.secondary:hover { + background: var(--bme-surface-high, #2a2a2d); + color: var(--bme-on-surface, #e4e1e6); +} + +/* --- Recall Card Responsive --- */ + +@media (max-width: 768px) { + .bme-recall-canvas-wrap { + height: 180px; + } + + .bme-recall-sidebar { + width: 100vw; + } + + .bme-recall-actions { + gap: 4px; + } + + .bme-recall-action-btn { + padding: 4px 8px; + font-size: 10px; + } +} diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 122844d..ab7ee3e 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -361,138 +361,6 @@ function createGenerationRecallHarness() { }); } -function createMessageRecallUiHarness() { - return fs.readFile(indexPath, "utf8").then((source) => { - const start = source.indexOf("function getMessageRecallRecord(messageIndex) {"); - const end = source.indexOf("function getSendTextareaValue() {"); - if (start < 0 || end < 0 || end <= start) { - throw new Error("无法从 index.js 提取消息级召回 UI 定义"); - } - - const snippet = source.slice(start, end).replace(/^export\s+/gm, ""); - const chat = [ - { - is_user: true, - mes: "u0", - extra: { - bme_recall: { - version: 1, - injectionText: "persisted-memory", - selectedNodeIds: ["n1"], - recallInput: "u0", - recallSource: "chat-last-user", - hookName: "GENERATION_AFTER_COMMANDS", - tokenEstimate: 16, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - generationCount: 0, - manuallyEdited: false, - }, - }, - }, - ]; - - const badgeHost = { - children: [], - appendChild(child) { - child.parent = this; - this.children.push(child); - }, - }; - const messageEl = { - dataset: {}, - getAttribute(name) { - if (name === "mesid" || name === "data-mesid") return "0"; - return null; - }, - querySelector(selector) { - if (selector === ".mes_buttons") return badgeHost; - return null; - }, - }; - const chatRoot = { - querySelectorAll(selector) { - if (selector === ".mes") return [messageEl]; - if (selector === ".st-bme-recall-badge") { - return badgeHost.children.filter( - (child) => child.className === "st-bme-recall-badge", - ); - } - return []; - }, - }; - - const context = { - console, - Date, - clearTimeout, - setTimeout, - result: null, - persistedRecallUiRefreshTimer: null, - lastInjectionContent: "", - lastRecallSentUserMessage: createRecallInputRecord(), - runtimeStatus: createUiStatus("待命", "", "idle"), - getContext: () => ({ chat }), - document: { - getElementById(id) { - return id === "chat" ? chatRoot : null; - }, - createElement() { - return { - className: "", - textContent: "", - dataset: {}, - style: {}, - listeners: {}, - parent: null, - addEventListener(type, handler) { - this.listeners[type] = handler; - }, - remove() { - if (!this.parent?.children) return; - this.parent.children = this.parent.children.filter((item) => item !== this); - }, - }; - }, - }, - readPersistedRecallFromUserMessage, - writePersistedRecallToUserMessage, - removePersistedRecallFromUserMessage, - markPersistedRecallManualEdit, - bumpPersistedRecallGenerationCount, - buildPersistedRecallRecord, - resolveGenerationTargetUserMessageIndex, - resolveFinalRecallInjectionSource, - normalizeRecallInputText, - estimateTokens: (text = "") => String(text || "").length, - triggerChatMetadataSave: () => "debounced", - getSettings: () => ({}), - applyModuleInjectionPrompt: () => ({}), - createUiStatus, - refreshPanelLiveState: () => {}, - runRecall: async () => ({ status: "completed", didRecall: true, injectionText: "fresh" }), - applyFinalRecallInjectionForGeneration: () => ({ source: "fresh" }), - toastr: { info() {}, success() {}, warning() {}, error() {} }, - promptResponses: [], - prompt(defaultText = "") { - return context.promptResponses.length > 0 ? context.promptResponses.shift() : defaultText; - }, - confirm: () => true, - alertMessages: [], - alert(message) { - context.alertMessages.push(String(message || "")); - }, - }; - vm.createContext(context); - vm.runInContext( - `${snippet}\nresult = { refreshPersistedRecallMessageUi, onMessageRecallBadgeClick };`, - context, - { filename: indexPath }, - ); - return { context, chat, badgeHost }; - }); -} - function createRerollHarness() { return fs.readFile(indexPath, "utf8").then((source) => { const rollbackStart = source.indexOf("async function rollbackGraphForReroll("); @@ -1690,24 +1558,43 @@ async function testPersistentRecallSourceResolutionAndTargetRouting() { assert.equal(fallback.injectionText, "persisted"); } -async function testMessageRecallUiBadgeEntryPoints() { - const { context, chat, badgeHost } = await createMessageRecallUiHarness(); - context.result.refreshPersistedRecallMessageUi(); - assert.equal(badgeHost.children.length, 1); - assert.equal(typeof badgeHost.children[0].listeners.click, "function"); +async function testRecallSubGraphAndDataLayerEntryPoints() { + // Sub-graph build test (pure function, no DOM needed) + const { buildRecallSubGraph } = await import("../recall-message-ui.js"); - context.promptResponses = ["1"]; - await context.result.onMessageRecallBadgeClick(0); - assert.equal(context.alertMessages.length, 1); + const graph = { + nodes: [ + { id: "n1", type: "character", name: "赵管家", importance: 7 }, + { id: "n2", type: "event", name: "喂食", importance: 5 }, + { id: "n3", type: "location", name: "厨房", importance: 3, archived: true }, + { id: "n4", type: "thread", name: "主线", importance: 8 }, + ], + edges: [ + { fromId: "n1", toId: "n2", strength: 0.8, relation: "related" }, + { fromId: "n2", toId: "n3", strength: 0.5, relation: "located" }, + { fromId: "n1", toId: "n4", strength: 0.6, relation: "participates" }, + ], + }; - context.promptResponses = ["2", "edited-by-user"]; - await context.result.onMessageRecallBadgeClick(0); - const edited = readPersistedRecallFromUserMessage(chat, 0); - assert.equal(edited?.injectionText, "edited-by-user"); - assert.equal(edited?.manuallyEdited, true); + const sub1 = buildRecallSubGraph(graph, ["n1", "n2"]); + assert.equal(sub1.nodes.length, 2); + assert.equal(sub1.edges.length, 1); + assert.equal(sub1.edges[0].fromId, "n1"); - context.promptResponses = ["3"]; - await context.result.onMessageRecallBadgeClick(0); + // archived node should be excluded + const sub2 = buildRecallSubGraph(graph, ["n1", "n3"]); + assert.equal(sub2.nodes.length, 1); + assert.equal(sub2.edges.length, 0); + + // empty/null safety + assert.equal(buildRecallSubGraph(null, ["n1"]).nodes.length, 0); + assert.equal(buildRecallSubGraph(graph, null).nodes.length, 0); + assert.equal(buildRecallSubGraph(graph, []).nodes.length, 0); + + // Data layer: edit and delete still work + const chat = [{ is_user: true, mes: "u0", extra: { bme_recall: { version: 1, injectionText: "test", selectedNodeIds: ["n1"], generationCount: 0, manuallyEdited: false, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", recallInput: "u0", recallSource: "test", hookName: "TEST", tokenEstimate: 4 } } }]; + assert.ok(readPersistedRecallFromUserMessage(chat, 0)); + assert.equal(removePersistedRecallFromUserMessage(chat, 0), true); assert.equal(readPersistedRecallFromUserMessage(chat, 0), null); } @@ -2063,7 +1950,7 @@ await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting(); -await testMessageRecallUiBadgeEntryPoints(); +await testRecallSubGraphAndDataLayerEntryPoints(); await testRerollUsesBatchBoundaryRollbackAndPersistsState(); await testRerollRejectsMissingRecoveryPoint(); await testRerollFallsBackToDirectExtractForUnprocessedFloor();