// ST-BME: 操控面板交互逻辑 import { renderTemplateAsync } from "../../../templates.js"; import { GraphRenderer } from "./graph-renderer.js"; import { getNodeColors } from "./themes.js"; import { getSuggestedBackendModel, getVectorIndexStats, } from "./vector-index.js"; let panelEl = null; let overlayEl = null; let graphRenderer = null; let mobileGraphRenderer = null; // 由 index.js 注入的引用 let _getGraph = null; let _getSettings = null; let _getLastExtract = null; let _getLastRecall = null; let _getLastInjection = null; let _updateSettings = null; let _actionHandlers = {}; async function loadLocalTemplate(templateName) { const templatePath = new URL(`./${templateName}.html`, import.meta.url).pathname; const html = await renderTemplateAsync(templatePath, {}, true, true, true); if (typeof html !== "string" || html.trim().length === 0) { throw new Error(`Template render returned empty content: ${templatePath}`); } return html; } /** * 初始化面板(由 index.js 调用一次) */ export async function initPanel({ getGraph, getSettings, getLastExtract, getLastRecall, getLastInjection, updateSettings, actions, }) { _getGraph = getGraph; _getSettings = getSettings; _getLastExtract = getLastExtract; _getLastRecall = getLastRecall; _getLastInjection = getLastInjection; _updateSettings = updateSettings; _actionHandlers = actions || {}; overlayEl = document.getElementById("st-bme-panel-overlay"); panelEl = document.getElementById("st-bme-panel"); if (!overlayEl || !panelEl) { const html = await loadLocalTemplate("panel"); $("body").append(html); overlayEl = document.getElementById("st-bme-panel-overlay"); panelEl = document.getElementById("st-bme-panel"); if (!overlayEl || !panelEl) { throw new Error("Panel template rendered but required DOM nodes were not found"); } } _bindTabs(); _bindClose(); _bindGraphControls(); _bindActions(); _bindConfigControls(); } /** * 打开面板 */ export function openPanel() { if (!overlayEl) return; overlayEl.classList.add("active"); const isMobile = _isMobile(); const settings = _getSettings?.() || {}; const themeName = settings.panelTheme || "crimson"; const canvas = document.getElementById("bme-graph-canvas"); if (canvas && !graphRenderer && !isMobile) { graphRenderer = new GraphRenderer(canvas, themeName); graphRenderer.onNodeSelect = (node) => _showNodeDetail(node); } const mobileCanvas = document.getElementById("bme-mobile-graph-canvas"); if (mobileCanvas && !mobileGraphRenderer && isMobile) { mobileGraphRenderer = new GraphRenderer(mobileCanvas, themeName); mobileGraphRenderer.onNodeSelect = (node) => _showNodeDetail(node); } _refreshDashboard(); _refreshGraph(); _buildLegend(); _refreshConfigTab(); } /** * 关闭面板 */ export function closePanel() { if (!overlayEl) return; overlayEl.classList.remove("active"); } /** * 更新主题 */ export function updatePanelTheme(themeName) { graphRenderer?.setTheme(themeName); mobileGraphRenderer?.setTheme(themeName); _buildLegend(); } // ==================== Tab 切换 ==================== function _bindTabs() { panelEl?.querySelectorAll(".bme-tab-btn").forEach((btn) => { btn.addEventListener("click", () => { const tabId = btn.dataset.tab; _switchTab(tabId); }); }); } function _switchTab(tabId) { panelEl?.querySelectorAll(".bme-tab-btn").forEach((btn) => { btn.classList.toggle("active", btn.dataset.tab === tabId); }); panelEl?.querySelectorAll(".bme-tab-pane").forEach((pane) => { pane.classList.toggle("active", pane.id === `bme-pane-${tabId}`); }); switch (tabId) { case "dashboard": _refreshDashboard(); break; case "memory": _refreshMemoryBrowser(); break; case "injection": void _refreshInjectionPreview(); break; case "config": _refreshConfigTab(); break; default: break; } } // ==================== 总览 Tab ==================== function _refreshDashboard() { const graph = _getGraph?.(); if (!graph) return; const activeNodes = graph.nodes.filter((node) => !node.archived); const archivedCount = graph.nodes.filter((node) => node.archived).length; const totalNodes = graph.nodes.length; const fragRate = totalNodes > 0 ? Math.round((archivedCount / totalNodes) * 100) : 0; _setText("bme-stat-nodes", activeNodes.length); _setText("bme-stat-edges", graph.edges.length); _setText("bme-stat-archived", archivedCount); _setText("bme-stat-frag", `${fragRate}%`); _setText( "bme-status-meta", `NODES: ${activeNodes.length} | EDGES: ${graph.edges.length}`, ); const chatId = graph?.historyState?.chatId || "—"; const lastProcessed = graph?.historyState?.lastProcessedAssistantFloor ?? -1; const dirtyFrom = graph?.historyState?.historyDirtyFrom; const vectorStats = getVectorIndexStats(graph); const vectorMode = graph?.vectorIndexState?.mode || "—"; const vectorSource = graph?.vectorIndexState?.source || "—"; const recovery = graph?.historyState?.lastRecoveryResult; _setText("bme-status-chat-id", chatId); _setText( "bme-status-history", Number.isFinite(dirtyFrom) ? `脏区从楼层 ${dirtyFrom} 开始,已处理到 ${lastProcessed}` : `干净,已处理到楼层 ${lastProcessed}`, ); _setText( "bme-status-vector", `${vectorMode}/${vectorSource} · total ${vectorStats.total} · indexed ${vectorStats.indexed} · stale ${vectorStats.stale} · pending ${vectorStats.pending}`, ); _setText( "bme-status-recovery", recovery ? `${recovery.status} · from ${recovery.fromFloor ?? "—"} · ${recovery.reason || "—"}` : "暂无恢复记录", ); _renderRecentList("bme-recent-extract", _getLastExtract?.() || []); _renderRecentList("bme-recent-recall", _getLastRecall?.() || []); } function _renderRecentList(elementId, items) { const listEl = document.getElementById(elementId); if (!listEl) return; if (!items.length) { listEl.innerHTML = '
  • 暂无数据
  • '; return; } listEl.innerHTML = items .map((item) => { const secondary = item.meta || item.time || ""; return `
  • ${_typeLabel(item.type)}
    ${_escHtml(item.name || "—")}
    ${_escHtml(secondary)}
  • `; }) .join(""); } // ==================== 记忆浏览器 ==================== function _refreshMemoryBrowser() { const graph = _getGraph?.(); if (!graph) return; const searchInput = document.getElementById("bme-memory-search"); const filterSelect = document.getElementById("bme-memory-filter"); const listEl = document.getElementById("bme-memory-list"); if (!listEl) return; const query = String(searchInput?.value || "").trim().toLowerCase(); const filter = filterSelect?.value || "all"; let nodes = graph.nodes.filter((node) => !node.archived); if (filter !== "all") { nodes = nodes.filter((node) => node.type === filter); } if (query) { nodes = nodes.filter((node) => { const name = getNodeDisplayName(node).toLowerCase(); const text = JSON.stringify(node.fields || {}).toLowerCase(); return name.includes(query) || text.includes(query); }); } nodes.sort((a, b) => { const importanceDiff = (b.importance || 5) - (a.importance || 5); if (importanceDiff !== 0) return importanceDiff; return (b.seqRange?.[1] ?? b.seq ?? 0) - (a.seqRange?.[1] ?? a.seq ?? 0); }); listEl.innerHTML = nodes .slice(0, 100) .map((node) => { const name = getNodeDisplayName(node); const snippet = _getNodeSnippet(node); return `
  • ${_typeLabel(node.type)}
    ${_escHtml(name)}
    ${_escHtml(snippet)}
    imp: ${node.importance || 5} acc: ${node.accessCount || 0} seq: ${node.seqRange?.[1] ?? node.seq ?? 0}
  • `; }) .join(""); listEl.querySelectorAll(".bme-memory-item").forEach((el) => { el.addEventListener("click", () => { const nodeId = el.dataset.nodeId; graphRenderer?.highlightNode(nodeId); mobileGraphRenderer?.highlightNode(nodeId); const node = graph.nodes.find((candidate) => candidate.id === nodeId); if (node) _showNodeDetail(node); }); }); if (searchInput && !searchInput._bmeBound) { let timer = null; searchInput.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => _refreshMemoryBrowser(), 200); }); filterSelect?.addEventListener("change", () => _refreshMemoryBrowser()); searchInput._bmeBound = true; } } // ==================== 注入预览 ==================== async function _refreshInjectionPreview() { const container = document.getElementById("bme-injection-content"); const tokenEl = document.getElementById("bme-injection-tokens"); if (!container) return; const injection = String(_getLastInjection?.() || "").trim(); if (!injection) { container.innerHTML = '
    暂无注入内容。先完成一次召回或正常生成后再查看。
    '; if (tokenEl) tokenEl.textContent = ""; return; } try { const { estimateTokens } = await import("./injector.js"); const totalTokens = estimateTokens(injection); container.innerHTML = `
    ${_escHtml(injection)}
    `; if (tokenEl) tokenEl.textContent = `≈ ${totalTokens} tokens`; } catch (error) { container.innerHTML = `
    预览生成失败: ${_escHtml(error.message)}
    `; if (tokenEl) tokenEl.textContent = ""; } } // ==================== 图谱 ==================== function _refreshGraph() { const graph = _getGraph?.(); if (!graph) return; graphRenderer?.loadGraph(graph); mobileGraphRenderer?.loadGraph(graph); } function _buildLegend() { const legendEl = document.getElementById("bme-graph-legend"); if (!legendEl) return; const settings = _getSettings?.() || {}; const colors = getNodeColors(settings.panelTheme || "crimson"); const types = [ { key: "character", label: "角色" }, { key: "event", label: "事件" }, { key: "location", label: "地点" }, { key: "thread", label: "主线" }, { key: "rule", label: "规则" }, { key: "synopsis", label: "概要" }, { key: "reflection", label: "反思" }, ]; legendEl.innerHTML = types .map( (type) => ` ${type.label} `, ) .join(""); } function _bindGraphControls() { document .getElementById("bme-graph-zoom-in") ?.addEventListener("click", () => graphRenderer?.zoomIn()); document .getElementById("bme-graph-zoom-out") ?.addEventListener("click", () => graphRenderer?.zoomOut()); document .getElementById("bme-graph-reset") ?.addEventListener("click", () => graphRenderer?.resetView()); } // ==================== 节点详情 ==================== function _showNodeDetail(node) { const detailEl = document.getElementById("bme-node-detail"); const titleEl = document.getElementById("bme-detail-title"); const bodyEl = document.getElementById("bme-detail-body"); if (!detailEl || !titleEl || !bodyEl) return; const raw = node.raw || node; const fields = raw.fields || {}; titleEl.textContent = getNodeDisplayName(raw); const items = [ { label: "类型", value: _typeLabel(raw.type) }, { label: "ID", value: raw.id || "—" }, { label: "重要度", value: raw.importance || 5 }, { label: "访问次数", value: raw.accessCount || 0 }, { label: "序列号", value: raw.seqRange?.[1] ?? raw.seq ?? 0 }, ]; if (Array.isArray(raw.seqRange)) { items.push({ label: "序列范围", value: `${raw.seqRange[0]} ~ ${raw.seqRange[1]}` }); } if (Array.isArray(raw.clusters) && raw.clusters.length > 0) { items.push({ label: "聚类标签", value: raw.clusters.join(", ") }); } for (const [key, value] of Object.entries(fields)) { items.push({ label: key, value: typeof value === "object" ? JSON.stringify(value, null, 2) : value, }); } bodyEl.innerHTML = items .map( (item) => `
    ${_escHtml(String(item.value ?? "—"))}
    `, ) .join(""); detailEl.classList.add("open"); } function _bindClose() { document.getElementById("bme-panel-close")?.addEventListener("click", closePanel); document.getElementById("bme-detail-close")?.addEventListener("click", () => { document.getElementById("bme-node-detail")?.classList.remove("open"); }); overlayEl?.addEventListener("click", (event) => { if (event.target === overlayEl) closePanel(); }); } // ==================== 操作绑定 ==================== function _bindActions() { const bindings = { "bme-act-extract": "extract", "bme-act-compress": "compress", "bme-act-sleep": "sleep", "bme-act-synopsis": "synopsis", "bme-act-export": "export", "bme-act-import": "import", "bme-act-rebuild": "rebuild", "bme-act-evolve": "evolve", "bme-act-vector-rebuild": "rebuildVectorIndex", "bme-act-vector-reembed": "reembedDirect", }; for (const [elementId, actionKey] of Object.entries(bindings)) { document.getElementById(elementId)?.addEventListener("click", async () => { const handler = _actionHandlers[actionKey]; if (!handler) return; try { await handler(); _refreshDashboard(); _refreshGraph(); if (document.getElementById("bme-pane-memory")?.classList.contains("active")) { _refreshMemoryBrowser(); } if (document.getElementById("bme-pane-injection")?.classList.contains("active")) { await _refreshInjectionPreview(); } } catch (error) { console.error(`[ST-BME] Action ${actionKey} failed:`, error); } }); } document.getElementById("bme-act-vector-range")?.addEventListener("click", async () => { try { const start = _parseOptionalInt(document.getElementById("bme-range-start")?.value); const end = _parseOptionalInt(document.getElementById("bme-range-end")?.value); await _actionHandlers.rebuildVectorRange?.( Number.isFinite(start) && Number.isFinite(end) ? { start, end } : null, ); _refreshDashboard(); _refreshGraph(); } catch (error) { console.error("[ST-BME] Action rebuildVectorRange failed:", error); } }); } function _refreshConfigTab() { const settings = _getSettings?.() || {}; _setCheckboxValue("bme-setting-enabled", settings.enabled ?? false); _setCheckboxValue("bme-setting-recall-enabled", settings.recallEnabled ?? true); _setInputValue("bme-setting-extract-every", settings.extractEvery ?? 1); _setInputValue( "bme-setting-extract-context-turns", settings.extractContextTurns ?? 2, ); _setInputValue("bme-setting-inject-depth", settings.injectDepth ?? 4); _setInputValue("bme-setting-llm-url", settings.llmApiUrl || ""); _setInputValue("bme-setting-llm-key", settings.llmApiKey || ""); _setInputValue("bme-setting-llm-model", settings.llmModel || ""); _setCheckboxValue("bme-setting-recall-llm", settings.recallEnableLLM ?? true); _setInputValue("bme-setting-recall-max-nodes", settings.recallMaxNodes ?? 8); _setInputValue("bme-setting-embed-url", settings.embeddingApiUrl || ""); _setInputValue("bme-setting-embed-key", settings.embeddingApiKey || ""); _setInputValue( "bme-setting-embed-model", settings.embeddingModel || "text-embedding-3-small", ); _setInputValue( "bme-setting-embed-mode", settings.embeddingTransportMode || "backend", ); _setInputValue( "bme-setting-embed-backend-source", settings.embeddingBackendSource || "openai", ); _setInputValue( "bme-setting-embed-backend-model", settings.embeddingBackendModel || getSuggestedBackendModel(settings.embeddingBackendSource || "openai"), ); _setInputValue( "bme-setting-embed-backend-url", settings.embeddingBackendApiUrl || "", ); _setCheckboxValue( "bme-setting-embed-auto-suffix", settings.embeddingAutoSuffix !== false, ); _setInputValue("bme-setting-extract-prompt", settings.extractPrompt || ""); _setInputValue("bme-setting-panel-theme", settings.panelTheme || "crimson"); } function _bindConfigControls() { if (!panelEl || panelEl.dataset.bmeConfigBound === "true") return; bindCheckbox("bme-setting-enabled", (checked) => _updateSettings?.({ enabled: checked }), ); bindCheckbox("bme-setting-recall-enabled", (checked) => _updateSettings?.({ recallEnabled: checked }), ); bindNumber("bme-setting-extract-every", 1, 1, 50, (value) => _updateSettings?.({ extractEvery: value }), ); bindNumber("bme-setting-extract-context-turns", 2, 0, 20, (value) => _updateSettings?.({ extractContextTurns: value }), ); bindNumber("bme-setting-inject-depth", 4, 0, 9999, (value) => _updateSettings?.({ injectDepth: value }), ); bindText("bme-setting-llm-url", (value) => _updateSettings?.({ llmApiUrl: value.trim() }), ); bindText("bme-setting-llm-key", (value) => _updateSettings?.({ llmApiKey: value.trim() }), ); bindText("bme-setting-llm-model", (value) => _updateSettings?.({ llmModel: value.trim() }), ); bindCheckbox("bme-setting-recall-llm", (checked) => _updateSettings?.({ recallEnableLLM: checked }), ); bindNumber("bme-setting-recall-max-nodes", 8, 1, 50, (value) => _updateSettings?.({ recallMaxNodes: value }), ); bindText("bme-setting-embed-url", (value) => _updateSettings?.({ embeddingApiUrl: value.trim() }), ); bindText("bme-setting-embed-key", (value) => _updateSettings?.({ embeddingApiKey: value.trim() }), ); bindText("bme-setting-embed-model", (value) => _updateSettings?.({ embeddingModel: value.trim() }), ); bindText("bme-setting-embed-mode", (value) => _updateSettings?.({ embeddingTransportMode: value }), ); bindText("bme-setting-embed-backend-source", (value) => { const patch = { embeddingBackendSource: value }; const settings = _getSettings?.() || {}; const suggestedModel = getSuggestedBackendModel(value); if (!settings.embeddingBackendModel || settings.embeddingBackendModel === getSuggestedBackendModel(settings.embeddingBackendSource || "openai")) { patch.embeddingBackendModel = suggestedModel; } _updateSettings?.(patch); _setInputValue("bme-setting-embed-backend-model", patch.embeddingBackendModel || settings.embeddingBackendModel || ""); }); bindText("bme-setting-embed-backend-model", (value) => _updateSettings?.({ embeddingBackendModel: value.trim() }), ); bindText("bme-setting-embed-backend-url", (value) => _updateSettings?.({ embeddingBackendApiUrl: value.trim() }), ); bindCheckbox("bme-setting-embed-auto-suffix", (checked) => _updateSettings?.({ embeddingAutoSuffix: checked }), ); bindText("bme-setting-extract-prompt", (value) => _updateSettings?.({ extractPrompt: value }), ); bindText("bme-setting-panel-theme", (value) => _updateSettings?.({ panelTheme: value }), ); document.getElementById("bme-test-llm")?.addEventListener("click", async () => { await _actionHandlers.testMemoryLLM?.(); }); document.getElementById("bme-test-embedding")?.addEventListener("click", async () => { await _actionHandlers.testEmbedding?.(); }); panelEl.dataset.bmeConfigBound = "true"; } function bindText(id, onChange) { const element = document.getElementById(id); if (!element || element.dataset.bmeBound === "true") return; element.addEventListener("input", () => onChange(element.value)); element.addEventListener("change", () => onChange(element.value)); element.dataset.bmeBound = "true"; } function bindCheckbox(id, onChange) { const element = document.getElementById(id); if (!element || element.dataset.bmeBound === "true") return; element.addEventListener("change", () => onChange(Boolean(element.checked))); element.dataset.bmeBound = "true"; } function bindNumber(id, fallback, min, max, onChange) { const element = document.getElementById(id); if (!element || element.dataset.bmeBound === "true") return; element.addEventListener("input", () => { let value = Number.parseInt(element.value, 10); if (!Number.isFinite(value)) value = fallback; value = Math.min(max, Math.max(min, value)); onChange(value); }); element.dataset.bmeBound = "true"; } // ==================== 工具函数 ==================== function _setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = String(text); } function _setInputValue(id, value) { const el = document.getElementById(id); if (el && el.value !== String(value ?? "")) { el.value = String(value ?? ""); } } function _setCheckboxValue(id, checked) { const el = document.getElementById(id); if (el) { el.checked = Boolean(checked); } } function _parseOptionalInt(value) { const parsed = Number.parseInt(String(value ?? "").trim(), 10); return Number.isFinite(parsed) ? parsed : null; } function _escHtml(str) { const div = document.createElement("div"); div.textContent = String(str ?? ""); return div.innerHTML; } function _typeLabel(type) { const map = { character: "角色", event: "事件", location: "地点", thread: "主线", rule: "规则", synopsis: "概要", reflection: "反思", }; return map[type] || type || "—"; } function _getNodeSnippet(node) { const fields = node.fields || {}; if (fields.summary) return fields.summary; if (fields.state) return fields.state; if (fields.constraint) return fields.constraint; if (fields.insight) return fields.insight; if (fields.traits) return fields.traits; const entries = Object.entries(fields).filter( ([key]) => !["name", "title", "summary", "embedding"].includes(key), ); if (entries.length > 0) { return entries .slice(0, 2) .map(([key, value]) => `${key}: ${value}`) .join("; "); } return "无补充字段"; } function getNodeDisplayName(node) { return ( node?.fields?.name || node?.fields?.title || node?.fields?.summary || node?.fields?.insight || node?.id?.slice(0, 8) || "—" ); } function _isMobile() { return window.innerWidth <= 768; }