// 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(" · "); } function normalizeUserInputDisplayMode(mode) { const normalized = String(mode || "").trim(); if ( normalized === "off" || normalized === "beautify_only" || normalized === "mirror" ) { return normalized; } return "mirror"; } function stableSerialize(value) { if (value === null || value === undefined) return "null"; const type = typeof value; if (type === "number") { return Number.isFinite(value) ? String(value) : "null"; } if (type === "boolean") return value ? "true" : "false"; if (type === "string") return JSON.stringify(value); if (Array.isArray(value)) { return `[${value.map((item) => stableSerialize(item)).join(",")}]`; } if (type === "object") { const keys = Object.keys(value).sort(); return `{${keys .map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`) .join(",")}}`; } return "null"; } function normalizeSelectedNodeIds(selectedNodeIds = []) { return Array.isArray(selectedNodeIds) ? selectedNodeIds .map((id) => String(id || "").trim()) .filter(Boolean) .sort() : []; } function summarizeSubGraphForSignature(subGraph) { const nodes = Array.isArray(subGraph?.nodes) ? subGraph.nodes .map((node) => ({ id: String(node?.id || ""), type: String(node?.type || ""), archived: Boolean(node?.archived), seq: Number.isFinite(node?.seq) ? node.seq : 0, seqRange: Array.isArray(node?.seqRange) ? [ Number.isFinite(node.seqRange[0]) ? node.seqRange[0] : 0, Number.isFinite(node.seqRange[1]) ? node.seqRange[1] : 0, ] : [], fields: node?.fields && typeof node.fields === "object" ? { ...node.fields } : {}, })) .sort((left, right) => left.id.localeCompare(right.id)) : []; const edges = Array.isArray(subGraph?.edges) ? subGraph.edges .map((edge) => ({ fromId: String(edge?.fromId || ""), toId: String(edge?.toId || ""), relation: String(edge?.relation || ""), strength: Number.isFinite(edge?.strength) ? edge.strength : 0, })) .sort((left, right) => { const leftKey = `${left.fromId}->${left.toId}:${left.relation}`; const rightKey = `${right.fromId}->${right.toId}:${right.relation}`; return leftKey.localeCompare(rightKey); }) : []; return { nodes, edges }; } function buildExpandedRenderSignature({ record, userMessageText, selectedNodeIds, subGraph, } = {}) { return stableSerialize({ updatedAt: String(record?.updatedAt || ""), manuallyEdited: Boolean(record?.manuallyEdited), generationCount: Number.isFinite(record?.generationCount) ? record.generationCount : 0, tokenEstimate: Number.isFinite(record?.tokenEstimate) ? record.tokenEstimate : 0, recallSource: String(record?.recallSource || ""), hookName: String(record?.hookName || ""), injectionText: String(record?.injectionText || ""), selectedNodeIds: normalizeSelectedNodeIds(selectedNodeIds), userMessageText: String(userMessageText || ""), subGraph: summarizeSubGraphForSignature(subGraph), }); } // ==================== 卡片 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", userInputDisplayMode = "mirror", callbacks = {}, }) { const card = el("div", "bme-recall-card"); card.dataset.messageIndex = String(messageIndex); card.dataset.updatedAt = String(record?.updatedAt || ""); card.dataset.expandedRenderSignature = ""; let activeRecord = record || {}; let activeUserMessageText = String(userMessageText || ""); let activeGraph = graph || null; let activeCallbacks = callbacks || {}; let activeUserInputDisplayMode = normalizeUserInputDisplayMode( userInputDisplayMode, ); let expandedRenderSignature = ""; // -- 用户消息区 -- const userLabel = el("div", "bme-recall-user-label"); userLabel.innerHTML = "💬 本轮用户输入"; card.appendChild(userLabel); const userText = el("div", "bme-recall-user-text", activeUserMessageText || "(empty)"); card.appendChild(userText); // -- 召回条 -- const initialNodeCount = Array.isArray(activeRecord?.selectedNodeIds) ? activeRecord.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", initialNodeCount > 0 ? `记忆 ${initialNodeCount}` : "记忆 ✓", ); bar.appendChild(badge); const tokenHint = el( "span", "bme-recall-token-hint", formatTokenHint(activeRecord?.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(subGraph = null, nextSignature = "") { body.innerHTML = ""; const resolvedSubGraph = subGraph || (activeGraph ? buildRecallSubGraph(activeGraph, activeRecord?.selectedNodeIds || []) : { nodes: [], edges: [] }); if (resolvedSubGraph.nodes.length === 0) { const emptyMsg = el( "div", "bme-recall-empty", activeGraph ? "召回节点已不存在或图谱已重建" : "图谱未就绪", ); 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 activeCallbacks.onNodeClick === "function") { activeCallbacks.onNodeClick(messageIndex, node); } }, onNodeDoubleClick: (node) => { if (typeof activeCallbacks.onNodeClick === "function") { activeCallbacks.onNodeClick(messageIndex, node); } }, }); renderer.loadGraph(resolvedSubGraph); } // 元信息行 const meta = el("div", "bme-recall-meta", formatMetaLine(activeRecord || {})); if (activeRecord?.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(); activeCallbacks.onEdit?.(messageIndex); }); actions.appendChild(editBtn); const deleteBtn = el("button", "bme-recall-action-btn"); deleteBtn.innerHTML = '🗑 删除'; deleteBtn.type = "button"; setupDeleteConfirmation(deleteBtn, () => { activeCallbacks.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 activeCallbacks.onRerunRecall?.(messageIndex); } finally { setRecallButtonLoading(recallBtn, false); } }); actions.appendChild(recallBtn); body.appendChild(actions); expandedRenderSignature = nextSignature || buildExpandedRenderSignature({ record: activeRecord, userMessageText: activeUserMessageText, selectedNodeIds: activeRecord?.selectedNodeIds || [], subGraph: resolvedSubGraph, }); card.dataset.expandedRenderSignature = expandedRenderSignature; } function applyCardRuntimeData(next = {}, { skipExpandedRerender = false } = {}) { if (next.record && typeof next.record === "object") { activeRecord = next.record; } if (Object.prototype.hasOwnProperty.call(next, "userMessageText")) { activeUserMessageText = String(next.userMessageText || ""); } if (Object.prototype.hasOwnProperty.call(next, "userInputDisplayMode")) { activeUserInputDisplayMode = normalizeUserInputDisplayMode( next.userInputDisplayMode, ); } if (Object.prototype.hasOwnProperty.call(next, "graph")) { activeGraph = next.graph || null; } if (next.callbacks && typeof next.callbacks === "object") { activeCallbacks = next.callbacks; } card.dataset.updatedAt = String(activeRecord?.updatedAt || ""); card.dataset.expandedRenderSignature = expandedRenderSignature; card.dataset.userInputDisplayMode = activeUserInputDisplayMode; card.classList.toggle( "bme-recall-hide-user-input", activeUserInputDisplayMode === "off", ); userText.textContent = activeUserMessageText || "(empty)"; const nodeCount = Array.isArray(activeRecord?.selectedNodeIds) ? activeRecord.selectedNodeIds.length : 0; badge.textContent = nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓"; tokenHint.textContent = formatTokenHint(activeRecord?.tokenEstimate); if (skipExpandedRerender || !card.classList.contains("expanded")) return; const nextSubGraph = activeGraph ? buildRecallSubGraph(activeGraph, activeRecord?.selectedNodeIds || []) : { nodes: [], edges: [] }; const nextSignature = buildExpandedRenderSignature({ record: activeRecord, userMessageText: activeUserMessageText, selectedNodeIds: activeRecord?.selectedNodeIds || [], subGraph: nextSubGraph, }); if (nextSignature === expandedRenderSignature) return; destroyRenderer(); buildExpandedContent(nextSubGraph, nextSignature); } card._bmeUpdateRecallCard = applyCardRuntimeData; // 点击召回条 toggle 展开/折叠 bar.addEventListener("click", (e) => { e.stopPropagation(); const isExpanded = card.classList.toggle("expanded"); if (isExpanded) { applyCardRuntimeData({}, { skipExpandedRerender: true }); buildExpandedContent(); } else { destroyRenderer(); body.innerHTML = ""; expandedRenderSignature = ""; card.dataset.expandedRenderSignature = ""; } }); applyCardRuntimeData({}, { skipExpandedRerender: true }); // 暴露清理方法 card._bmeDestroyRenderer = () => { destroyRenderer(); expandedRenderSignature = ""; card.dataset.expandedRenderSignature = ""; }; return card; } /** * 更新已有卡片的 badge / token hint / meta(不重建整个卡片) */ export function updateRecallCardData(cardElement, record, options = {}) { if (!cardElement || !record) return; if (typeof cardElement._bmeUpdateRecallCard === "function") { cardElement._bmeUpdateRecallCard({ record, userMessageText: options?.userMessageText, userInputDisplayMode: options?.userInputDisplayMode, graph: options?.graph, callbacks: options?.callbacks, }); return; } cardElement.dataset.updatedAt = String(record.updatedAt || ""); } // ==================== 删除二次确认 ==================== 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"); }