// 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"); }