// ST-BME: 操控面板交互逻辑
import { renderTemplateAsync } from "../../../templates.js";
import { GraphRenderer } from "./graph-renderer.js";
import { getNodeColors } from "./themes.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 _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,
actions,
}) {
_getGraph = getGraph;
_getSettings = getSettings;
_getLastExtract = getLastExtract;
_getLastRecall = getLastRecall;
_getLastInjection = getLastInjection;
_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();
}
/**
* 打开面板
*/
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();
}
/**
* 关闭面板
*/
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;
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}`,
);
_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",
};
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);
}
});
}
}
// ==================== 工具函数 ====================
function _setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = String(text);
}
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;
}