feat: 新增 UI 操控面板系统

- 新增 themes.js: 4 套配色主题 (Crimson/Cyan/Amber/Violet) + applyTheme()
- 新增 panel.html: 响应式面板模板 (Desktop 双栏 + Mobile 底部 Tab)
- 新增 graph-renderer.js: Canvas 力导向图谱渲染器 (零依赖)
- 新增 panel.js: Tab 切换、数据渲染、搜索过滤、节点详情、操作绑定
- 修改 style.css: +720 行面板样式 + @media 响应式
- 修改 index.js: 面板初始化 + Options 菜单注入 + action handlers
- 修改 settings.html: 主题选择下拉框 + 打开面板按钮
- 修改 graph.js: 新增 getActiveNodes() 辅助函数
This commit is contained in:
Youzini-afk
2026-03-23 22:18:36 +08:00
parent 2ca5106767
commit 0a07b7df44
8 changed files with 2174 additions and 1 deletions

461
graph-renderer.js Normal file
View File

@@ -0,0 +1,461 @@
// ST-BME: Canvas 力导向图谱渲染器
// 零依赖,纯 Canvas 2D 实现
import { getNodeColors } from './themes.js';
/**
* @typedef {Object} GraphNode
* @property {string} id
* @property {string} type
* @property {string} name
* @property {number} importance
* @property {number} x
* @property {number} y
* @property {number} vx
* @property {number} vy
* @property {boolean} pinned
*/
const FORCE_CONFIG = {
repulsion: 500, // 库仑斥力常数
springLength: 120, // 弹簧自然长度
springK: 0.08, // 弹簧刚度
damping: 0.85, // 阻尼系数
centerGravity: 0.01, // 向心引力
maxIterations: 300, // 力导向最大迭代帧
minNodeRadius: 6, // 最小节点半径
maxNodeRadius: 18, // 最大节点半径
labelFontSize: 10,
gridSpacing: 40,
gridColor: 'rgba(255,255,255,0.03)',
};
export class GraphRenderer {
/**
* @param {HTMLCanvasElement} canvas
* @param {string} themeName
*/
constructor(canvas, themeName = 'crimson') {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.nodes = [];
this.edges = [];
this.nodeMap = new Map();
this.colors = getNodeColors(themeName);
this.themeName = themeName;
// View transform
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
// Interaction state
this.dragNode = null;
this.hoveredNode = null;
this.selectedNode = null;
this.isDragging = false;
this.isPanning = false;
this.lastMouse = { x: 0, y: 0 };
// Animation
this.iteration = 0;
this.animating = false;
this.animId = null;
// Callbacks
this.onNodeSelect = null;
this._bindEvents();
this._resizeObserver = new ResizeObserver(() => this._resize());
this._resizeObserver.observe(canvas.parentElement);
this._resize();
}
/**
* 加载图谱数据
* @param {object} graph - 完整的 graph state
*/
loadGraph(graph) {
this.nodeMap.clear();
// 转换节点
const activeNodes = graph.nodes.filter(n => !n.archived);
this.nodes = activeNodes.map((n, i) => {
const angle = (2 * Math.PI * i) / activeNodes.length;
const r = Math.min(this.canvas.width, this.canvas.height) * 0.3;
const node = {
id: n.id,
type: n.type || 'event',
name: n.content?.name || n.content?.title || n.id.slice(0, 8),
importance: n.importance || 5,
x: this.canvas.width / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 40,
y: this.canvas.height / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 40,
vx: 0,
vy: 0,
pinned: false,
raw: n,
};
this.nodeMap.set(n.id, node);
return node;
});
// 转换边
this.edges = graph.edges
.filter(e => this.nodeMap.has(e.fromId) && this.nodeMap.has(e.toId))
.map(e => ({
from: this.nodeMap.get(e.fromId),
to: this.nodeMap.get(e.toId),
strength: e.strength || 0.5,
relation: e.relation || 'related',
}));
this.iteration = 0;
this.startAnimation();
}
/**
* 切换主题
*/
setTheme(themeName) {
this.themeName = themeName;
this.colors = getNodeColors(themeName);
this._render();
}
/**
* 高亮指定节点
*/
highlightNode(nodeId) {
this.selectedNode = this.nodeMap.get(nodeId) || null;
this._render();
}
// ==================== 力导向计算 ====================
_applyForces() {
const { nodes, edges } = this;
const W = this.canvas.width / window.devicePixelRatio;
const H = this.canvas.height / window.devicePixelRatio;
const cx = W / 2, cy = H / 2;
// 斥力(节点间排斥)
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
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 fx = (dx / dist) * force;
let fy = (dy / dist) * force;
if (!a.pinned) { a.vx -= fx; a.vy -= fy; }
if (!b.pinned) { b.vx += fx; b.vy += fy; }
}
}
// 弹簧力(边的引力)
for (const edge of edges) {
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 fx = (dx / dist) * force;
let fy = (dy / dist) * force;
if (!from.pinned) { from.vx += fx; from.vy += fy; }
if (!to.pinned) { to.vx -= fx; to.vy -= fy; }
}
// 向心力
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;
}
// 更新位置
for (const node of nodes) {
if (node.pinned) continue;
node.vx *= FORCE_CONFIG.damping;
node.vy *= FORCE_CONFIG.damping;
node.x += node.vx;
node.y += node.vy;
// 边界约束
node.x = Math.max(20, Math.min(W - 20, node.x));
node.y = Math.max(20, Math.min(H - 20, node.y));
}
}
// ==================== 渲染 ====================
_render() {
const ctx = this.ctx;
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.width / dpr;
const H = this.canvas.height / dpr;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
// 应用视图变换
ctx.translate(this.offsetX, this.offsetY);
ctx.scale(this.scale, this.scale);
// 背景网格
this._drawGrid(W, H);
// 边
for (const edge of this.edges) {
ctx.beginPath();
ctx.moveTo(edge.from.x, edge.from.y);
ctx.lineTo(edge.to.x, edge.to.y);
ctx.strokeStyle = `rgba(255,255,255,${0.05 + edge.strength * 0.15})`;
ctx.lineWidth = 0.5 + edge.strength * 1.5;
ctx.stroke();
}
// 节点
for (const node of this.nodes) {
const r = this._nodeRadius(node);
const color = this.colors[node.type] || this.colors.event;
const isSelected = node === this.selectedNode;
const isHovered = node === this.hoveredNode;
// 发光效果
if (isSelected || isHovered) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 8, 0, Math.PI * 2);
const glow = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 8);
glow.addColorStop(0, color + '60');
glow.addColorStop(1, color + '00');
ctx.fillStyle = glow;
ctx.fill();
}
// 节点圆
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? color : color + 'cc';
ctx.fill();
// 边框
if (isSelected) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
}
// 标签
ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.95 : 0.65})`;
ctx.font = `${FORCE_CONFIG.labelFontSize}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(node.name, node.x, node.y + r + 14);
}
ctx.restore();
}
_drawGrid(W, H) {
const ctx = this.ctx;
const sp = FORCE_CONFIG.gridSpacing;
ctx.strokeStyle = FORCE_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;
const endX = startX + W / this.scale + sp * 2;
const endY = startY + H / this.scale + sp * 2;
for (let x = startX; x < endX; x += sp) {
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
ctx.stroke();
}
for (let y = startY; y < endY; y += sp) {
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
}
_nodeRadius(node) {
const min = FORCE_CONFIG.minNodeRadius;
const max = FORCE_CONFIG.maxNodeRadius;
return min + ((node.importance || 5) / 10) * (max - min);
}
// ==================== 动画 ====================
startAnimation() {
if (this.animating) return;
this.animating = true;
this._tick();
}
stopAnimation() {
this.animating = false;
if (this.animId) cancelAnimationFrame(this.animId);
}
_tick() {
if (!this.animating) return;
if (this.iteration < FORCE_CONFIG.maxIterations) {
this._applyForces();
this.iteration++;
}
this._render();
this.animId = requestAnimationFrame(() => this._tick());
}
// ==================== 交互 ====================
_bindEvents() {
const c = this.canvas;
c.addEventListener('mousedown', (e) => this._onMouseDown(e));
c.addEventListener('mousemove', (e) => this._onMouseMove(e));
c.addEventListener('mouseup', (e) => this._onMouseUp(e));
c.addEventListener('wheel', (e) => this._onWheel(e), { passive: false });
c.addEventListener('dblclick', (e) => this._onDoubleClick(e));
// Touch support
c.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
const t = e.touches[0];
this._onMouseDown({ clientX: t.clientX, clientY: t.clientY, button: 0 });
}
}, { passive: true });
c.addEventListener('touchmove', (e) => {
if (e.touches.length === 1) {
const t = e.touches[0];
this._onMouseMove({ clientX: t.clientX, clientY: t.clientY });
}
}, { passive: true });
c.addEventListener('touchend', () => this._onMouseUp({}));
}
_canvasToWorld(clientX, clientY) {
const rect = this.canvas.getBoundingClientRect();
const x = (clientX - rect.left - this.offsetX) / this.scale;
const y = (clientY - rect.top - this.offsetY) / this.scale;
return { x, y };
}
_findNodeAt(wx, wy) {
for (let i = this.nodes.length - 1; i >= 0; i--) {
const n = this.nodes[i];
const r = this._nodeRadius(n);
const dx = n.x - wx, dy = n.y - wy;
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
}
return null;
}
_onMouseDown(e) {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
const node = this._findNodeAt(x, y);
this.lastMouse = { x: e.clientX, y: e.clientY };
if (node) {
this.dragNode = node;
node.pinned = true;
this.isDragging = true;
} else {
this.isPanning = true;
}
}
_onMouseMove(e) {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
if (this.isDragging && this.dragNode) {
this.dragNode.x = x;
this.dragNode.y = y;
this.iteration = 0; // restart physics
this.startAnimation();
} else if (this.isPanning) {
this.offsetX += e.clientX - this.lastMouse.x;
this.offsetY += e.clientY - this.lastMouse.y;
this._render();
} else {
// hover detection
const node = this._findNodeAt(x, y);
if (node !== this.hoveredNode) {
this.hoveredNode = node;
this.canvas.style.cursor = node ? 'pointer' : 'grab';
this._render();
}
}
this.lastMouse = { x: e.clientX, y: e.clientY };
}
_onMouseUp() {
if (this.dragNode) {
this.dragNode.pinned = false;
if (this.isDragging) {
// 如果拖动距离很小,视为点击选中
this.selectedNode = this.dragNode;
if (this.onNodeSelect) this.onNodeSelect(this.dragNode);
}
}
this.dragNode = null;
this.isDragging = false;
this.isPanning = false;
}
_onWheel(e) {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.2, Math.min(5, this.scale * factor));
// 以鼠标点为中心缩放
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
this.offsetX = mx - (mx - this.offsetX) * (newScale / this.scale);
this.offsetY = my - (my - this.offsetY) * (newScale / this.scale);
this.scale = newScale;
this._render();
}
_onDoubleClick(e) {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
const node = this._findNodeAt(x, y);
if (node) {
this.selectedNode = node;
if (this.onNodeSelect) this.onNodeSelect(node);
this._render();
}
}
// ==================== 工具 ====================
zoomIn() { this.scale = Math.min(5, this.scale * 1.2); this._render(); }
zoomOut() { this.scale = Math.max(0.2, this.scale * 0.8); this._render(); }
resetView() {
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
this._render();
}
_resize() {
const dpr = window.devicePixelRatio || 1;
const parent = this.canvas.parentElement;
if (!parent) return;
const w = parent.clientWidth;
const h = parent.clientHeight;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.canvas.style.width = w + 'px';
this.canvas.style.height = h + 'px';
this._render();
}
destroy() {
this.stopAnimation();
this._resizeObserver?.disconnect();
}
}

View File

@@ -90,6 +90,15 @@ export function addNode(graph, node) {
return node;
}
/**
* 获取所有活跃(未归档)节点
* @param {GraphState} graph
* @returns {Array}
*/
export function getActiveNodes(graph) {
return graph.nodes.filter((n) => !n.archived);
}
/**
* 根据 ID 获取节点
* @param {GraphState} graph

107
index.js
View File

@@ -31,6 +31,8 @@ import {
import { estimateTokens, formatInjection } from "./injector.js";
import { retrieve } from "./retriever.js";
import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js";
import { initPanel, openPanel, updatePanelTheme } from "./panel.js";
import { applyTheme } from "./themes.js";
const MODULE_NAME = "st_bme";
const GRAPH_METADATA_KEY = "st_bme_graph";
@@ -108,6 +110,9 @@ const defaultSettings = {
// ⑩ 反思条目P2
enableReflection: false, // 启用反思
reflectEveryN: 10, // 每 N 次提取后反思
// UI 面板
panelTheme: "crimson", // 面板主题 crimson|cyan|amber|violet
};
// ==================== 状态 ====================
@@ -116,6 +121,8 @@ let currentGraph = null;
let isExtracting = false;
let isRecalling = false;
let lastInjectionContent = "";
let lastExtractedItems = []; // 最近提取的节点(面板展示用)
let lastRecalledItems = []; // 最近召回的节点(面板展示用)
let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思)
// ==================== 设置管理 ====================
@@ -951,5 +958,103 @@ function bindSettingsUI() {
// 加载当前聊天的图谱
loadGraphFromChat();
console.log("[ST-BME] 初始化完成");
// ==================== 操控面板初始化 ====================
// 应用主题
const settings = getSettings();
applyTheme(settings.panelTheme || 'crimson');
// 初始化操控面板
await initPanel({
getGraph: () => currentGraph,
getSettings: () => getSettings(),
getLastExtract: () => lastExtractedItems,
getLastRecall: () => lastRecalledItems,
actions: {
extract: async () => {
const context = getContext();
const chat = context.chat;
if (!chat || !chat.length) return;
const s = getSettings();
const result = await extractMemories(
currentGraph, chat, chat.length - 1, s.extractContextTurns,
getSchema(), getEmbeddingConfig(), s,
);
if (result?.newNodes?.length) {
lastExtractedItems = result.newNodes.map(n => ({
type: n.type, name: n.content?.name || '', time: new Date().toLocaleTimeString(),
})).slice(0, 5);
}
saveGraphToChat();
},
compress: async () => {
await compressAll(currentGraph, getSettings());
saveGraphToChat();
},
sleep: async () => {
await sleepCycle(currentGraph, getSettings());
saveGraphToChat();
},
synopsis: async () => {
await generateSynopsis(currentGraph, getSettings());
saveGraphToChat();
},
export: () => {
const json = exportGraph(currentGraph);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'st-bme-graph.json'; a.click();
URL.revokeObjectURL(url);
},
import: () => {
const input = document.createElement('input');
input.type = 'file'; input.accept = '.json';
input.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
currentGraph = importGraph(text);
saveGraphToChat();
});
input.click();
},
rebuild: async () => {
if (!confirm('确定要重建图谱吗?这将清除所有现有数据。')) return;
currentGraph = createEmptyGraph();
saveGraphToChat();
},
evolve: async () => {
await evolveMemories(currentGraph, getEmbeddingConfig(), getSettings());
saveGraphToChat();
},
},
});
// 注入 Options 菜单按钮
const $menuItem = $('<div class="list-group-item flex-container flexGap5">')
.append('<i class="fa-solid fa-brain"></i>')
.append('<span>记忆图谱</span>')
.on('click', () => {
openPanel();
$('#options').hide();
});
$('#extensionsMenu .list-group').append($menuItem);
// 主题选择绑定(如果设置面板里有)
$('#st_bme_panel_theme')
.val(settings.panelTheme || 'crimson')
.on('change', function () {
const theme = $(this).val();
settings.panelTheme = theme;
extension_settings[MODULE_NAME].panelTheme = theme;
applyTheme(theme);
updatePanelTheme(theme);
saveSettingsDebounced();
});
// 设置面板中的"打开操控面板"按钮
$('#st_bme_btn_open_panel').on('click', () => openPanel());
console.log("[ST-BME] 初始化完成(含操控面板)");
})();

206
panel.html Normal file
View File

@@ -0,0 +1,206 @@
<div id="st-bme-panel-overlay">
<div id="st-bme-panel">
<!-- Header -->
<div class="bme-panel-header">
<div class="bme-panel-title">
<i class="fa-solid fa-brain"></i>
<span>ST-BME 记忆图谱</span>
<span class="bme-panel-subtitle" id="bme-panel-status">SYSTEM_ACTIVE</span>
</div>
<button class="bme-panel-close" id="bme-panel-close" title="关闭">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Body -->
<div class="bme-panel-body">
<!-- Sidebar (Desktop) -->
<div class="bme-panel-sidebar">
<div class="bme-tab-list">
<button class="bme-tab-btn active" data-tab="dashboard">
<i class="fa-solid fa-chart-simple"></i>
<span>总览</span>
</button>
<button class="bme-tab-btn" data-tab="memory">
<i class="fa-solid fa-brain"></i>
<span>记忆</span>
</button>
<button class="bme-tab-btn" data-tab="injection">
<i class="fa-solid fa-syringe"></i>
<span>注入</span>
</button>
<button class="bme-tab-btn" data-tab="actions">
<i class="fa-solid fa-gear"></i>
<span>操作</span>
</button>
</div>
<div class="bme-tab-content">
<!-- Dashboard Tab -->
<div class="bme-tab-pane active" id="bme-pane-dashboard">
<div class="bme-stats-grid">
<div class="bme-stat-card">
<div class="bme-stat-label">活跃节点</div>
<div class="bme-stat-value" id="bme-stat-nodes">0</div>
</div>
<div class="bme-stat-card">
<div class="bme-stat-label">边连接</div>
<div class="bme-stat-value" id="bme-stat-edges">0</div>
</div>
<div class="bme-stat-card">
<div class="bme-stat-label">已归档</div>
<div class="bme-stat-value" id="bme-stat-archived">0</div>
</div>
<div class="bme-stat-card">
<div class="bme-stat-label">碎片率</div>
<div class="bme-stat-value warning" id="bme-stat-frag">0%</div>
</div>
</div>
<!-- 移动端图谱预览(仅手机端可见) -->
<div class="bme-mobile-graph-preview" id="bme-mobile-graph-area">
<canvas id="bme-mobile-graph-canvas"></canvas>
<span class="bme-mobile-graph-label">REALTIME</span>
</div>
<div class="bme-graph-statusbar bme-mobile-graph-status" id="bme-mobile-graph-status">
<span><span class="bme-status-dot"></span>NODE_SYNC_ACTIVE</span>
</div>
<div class="bme-section-header">最近提取</div>
<ul class="bme-recent-list" id="bme-recent-extract"></ul>
<div class="bme-section-header">最近召回</div>
<ul class="bme-recent-list" id="bme-recent-recall"></ul>
</div>
<!-- Memory Browser Tab -->
<div class="bme-tab-pane" id="bme-pane-memory">
<div class="bme-search-bar">
<input type="text" class="bme-search-input" id="bme-memory-search"
placeholder="搜索记忆节点..." />
<select class="bme-filter-select" id="bme-memory-filter">
<option value="all">全部</option>
<option value="character">角色</option>
<option value="event">事件</option>
<option value="location">地点</option>
<option value="thread">线索</option>
<option value="rule">规则</option>
<option value="synopsis">概要</option>
<option value="reflection">反思</option>
</select>
</div>
<ul class="bme-memory-list" id="bme-memory-list"></ul>
</div>
<!-- Injection Preview Tab -->
<div class="bme-tab-pane" id="bme-pane-injection">
<div id="bme-injection-content"></div>
<div class="bme-injection-token-count" id="bme-injection-tokens"></div>
</div>
<!-- Actions Tab -->
<div class="bme-tab-pane" id="bme-pane-actions">
<div class="bme-action-grid">
<button class="bme-action-btn" id="bme-act-extract">
<i class="fa-solid fa-download"></i>
<span>手动提取</span>
</button>
<button class="bme-action-btn" id="bme-act-compress">
<i class="fa-solid fa-compress"></i>
<span>手动压缩</span>
</button>
<button class="bme-action-btn" id="bme-act-sleep">
<i class="fa-solid fa-moon"></i>
<span>执行遗忘</span>
</button>
<button class="bme-action-btn" id="bme-act-synopsis">
<i class="fa-solid fa-scroll"></i>
<span>更新概要</span>
</button>
<button class="bme-action-btn" id="bme-act-export">
<i class="fa-solid fa-file-export"></i>
<span>导出图谱</span>
</button>
<button class="bme-action-btn" id="bme-act-import">
<i class="fa-solid fa-file-import"></i>
<span>导入图谱</span>
</button>
<button class="bme-action-btn danger" id="bme-act-rebuild">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>重建图谱</span>
</button>
<button class="bme-action-btn" id="bme-act-evolve">
<i class="fa-solid fa-dna"></i>
<span>强制进化</span>
</button>
</div>
</div>
</div>
</div>
<!-- Main Graph Area (Desktop) -->
<div class="bme-panel-main">
<div class="bme-graph-toolbar">
<div class="bme-graph-toolbar-title">
<i class="fa-solid fa-diagram-project"></i>
<span>实时图谱</span>
</div>
<div class="bme-graph-controls">
<button id="bme-graph-zoom-in" title="放大">
<i class="fa-solid fa-plus"></i>
</button>
<button id="bme-graph-zoom-out" title="缩小">
<i class="fa-solid fa-minus"></i>
</button>
<button id="bme-graph-reset" title="重置">
<i class="fa-solid fa-arrows-rotate"></i>
</button>
</div>
</div>
<canvas id="bme-graph-canvas"></canvas>
<div class="bme-graph-legend" id="bme-graph-legend"></div>
<div class="bme-graph-statusbar">
<span><span class="bme-status-dot"></span><span id="bme-status-text">READY</span></span>
<span id="bme-status-meta">NODES: 0 | EDGES: 0</span>
</div>
<!-- Node Detail Slide-in -->
<div class="bme-node-detail" id="bme-node-detail">
<div class="bme-node-detail-header">
<h3 id="bme-detail-title">节点详情</h3>
<button class="bme-panel-close" id="bme-detail-close">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div id="bme-detail-body"></div>
</div>
</div>
<!-- Mobile Content Area (conditionally shown) -->
<div class="bme-mobile-content" id="bme-mobile-content" style="display:none;"></div>
</div>
<!-- Mobile Bottom Tab Bar -->
<div class="bme-panel-tabbar">
<button class="bme-tab-btn active" data-tab="dashboard">
<i class="fa-solid fa-chart-simple"></i>
<span>总览</span>
</button>
<button class="bme-tab-btn" data-tab="memory">
<i class="fa-solid fa-brain"></i>
<span>记忆</span>
</button>
<button class="bme-tab-btn" data-tab="injection">
<i class="fa-solid fa-syringe"></i>
<span>注入</span>
</button>
<button class="bme-tab-btn" data-tab="actions">
<i class="fa-solid fa-gear"></i>
<span>操作</span>
</button>
</div>
</div>
</div>

415
panel.js Normal file
View File

@@ -0,0 +1,415 @@
// ST-BME: 操控面板交互逻辑
import { renderExtensionTemplateAsync } from '../../extensions.js';
import { GraphRenderer } from './graph-renderer.js';
import { getNodeColors } from './themes.js';
let panelEl = null;
let overlayEl = null;
let graphRenderer = null;
let mobileGraphRenderer = null;
let isOpen = false;
// 由 index.js 注入的引用
let _getGraph = null;
let _getSettings = null;
let _getLastExtract = null;
let _getLastRecall = null;
let _actionHandlers = {};
/**
* 初始化面板(由 index.js 调用一次)
*/
export async function initPanel({ getGraph, getSettings, getLastExtract, getLastRecall, actions }) {
_getGraph = getGraph;
_getSettings = getSettings;
_getLastExtract = getLastExtract;
_getLastRecall = getLastRecall;
_actionHandlers = actions || {};
// 加载 HTML 模板
const html = await renderExtensionTemplateAsync('third-party/st-bme', 'panel');
$('body').append(html);
overlayEl = document.getElementById('st-bme-panel-overlay');
panelEl = document.getElementById('st-bme-panel');
_bindTabs();
_bindClose();
_bindGraphControls();
_bindActions();
}
/**
* 打开面板
*/
export function openPanel() {
if (!overlayEl) return;
overlayEl.classList.add('active');
isOpen = true;
const isMobile = _isMobile();
// 初始化桌面端图谱渲染器
const canvas = document.getElementById('bme-graph-canvas');
if (canvas && !graphRenderer && !isMobile) {
const settings = _getSettings?.() || {};
graphRenderer = new GraphRenderer(canvas, settings.panelTheme || 'crimson');
graphRenderer.onNodeSelect = (node) => _showNodeDetail(node);
}
// 初始化移动端 mini 图谱渲染器
const mobileCanvas = document.getElementById('bme-mobile-graph-canvas');
if (mobileCanvas && !mobileGraphRenderer && isMobile) {
const settings = _getSettings?.() || {};
mobileGraphRenderer = new GraphRenderer(mobileCanvas, settings.panelTheme || 'crimson');
mobileGraphRenderer.onNodeSelect = (node) => _showNodeDetail(node);
}
_refreshDashboard();
_refreshGraph();
_buildLegend();
}
/**
* 关闭面板
*/
export function closePanel() {
if (!overlayEl) return;
overlayEl.classList.remove('active');
isOpen = false;
}
/**
* 更新主题
*/
export function updatePanelTheme(themeName) {
if (graphRenderer) graphRenderer.setTheme(themeName);
if (mobileGraphRenderer) mobileGraphRenderer.setTheme(themeName);
}
// ==================== Tab 切换 ====================
function _bindTabs() {
// 桌面端 sidebar tabs + 手机端 bottom tabs
document.querySelectorAll('.bme-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tabId = btn.dataset.tab;
_switchTab(tabId);
});
});
}
function _switchTab(tabId) {
// 更新所有 tab 按钮状态
document.querySelectorAll('.bme-tab-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tabId);
});
// 更新 pane 显示
document.querySelectorAll('.bme-tab-pane').forEach(p => {
p.classList.toggle('active', p.id === `bme-pane-${tabId}`);
});
// 按需刷新内容
switch (tabId) {
case 'dashboard': _refreshDashboard(); break;
case 'memory': _refreshMemoryBrowser(); break;
case 'injection': _refreshInjectionPreview(); break;
}
}
// ==================== 总览 Tab ====================
function _refreshDashboard() {
const graph = _getGraph?.();
if (!graph) return;
const activeNodes = graph.nodes.filter(n => !n.archived);
const archived = graph.nodes.filter(n => n.archived).length;
const total = graph.nodes.length;
const fragRate = total > 0 ? Math.round((archived / total) * 100) : 0;
_setText('bme-stat-nodes', activeNodes.length);
_setText('bme-stat-edges', graph.edges.length);
_setText('bme-stat-archived', archived);
_setText('bme-stat-frag', fragRate + '%');
_setText('bme-status-meta', `NODES: ${activeNodes.length} | EDGES: ${graph.edges.length}`);
// 最近提取
const extractList = document.getElementById('bme-recent-extract');
if (extractList) {
const items = _getLastExtract?.() || [];
extractList.innerHTML = items.length ? items.map(item =>
`<li class="bme-recent-item">
<span class="bme-type-badge ${item.type}">${_typeLabel(item.type)}</span>
<div>
<div class="bme-recent-text">${_escHtml(item.name || item.content?.name || '—')}</div>
<div class="bme-recent-meta">${item.time || ''}</div>
</div>
</li>`
).join('') : '<li class="bme-recent-item"><div class="bme-recent-text" style="color:var(--bme-on-surface-dim)">暂无数据</div></li>';
}
// 最近召回
const recallList = document.getElementById('bme-recent-recall');
if (recallList) {
const items = _getLastRecall?.() || [];
recallList.innerHTML = items.length ? items.map(item =>
`<li class="bme-recent-item">
<span class="bme-type-badge ${item.type}">${_typeLabel(item.type)}</span>
<div>
<div class="bme-recent-text">${_escHtml(item.name || '—')}</div>
<div class="bme-recent-meta">score: ${(item.score || 0).toFixed(2)}</div>
</div>
</li>`
).join('') : '<li class="bme-recent-item"><div class="bme-recent-text" style="color:var(--bme-on-surface-dim)">暂无数据</div></li>';
}
}
// ==================== 记忆浏览器 ====================
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 = (searchInput?.value || '').toLowerCase();
const filter = filterSelect?.value || 'all';
let nodes = graph.nodes.filter(n => !n.archived);
if (filter !== 'all') {
nodes = nodes.filter(n => n.type === filter);
}
if (query) {
nodes = nodes.filter(n => {
const name = (n.content?.name || n.content?.title || '').toLowerCase();
const text = JSON.stringify(n.content || {}).toLowerCase();
return name.includes(query) || text.includes(query);
});
}
// 按 importance 降序
nodes.sort((a, b) => (b.importance || 5) - (a.importance || 5));
listEl.innerHTML = nodes.slice(0, 100).map(n => {
const name = n.content?.name || n.content?.title || n.id.slice(0, 8);
const snippet = _getNodeSnippet(n);
return `<li class="bme-memory-item" data-node-id="${n.id}">
<span class="bme-type-badge ${n.type}">${_typeLabel(n.type)}</span>
<div>
<div class="bme-memory-name">${_escHtml(name)}</div>
<div class="bme-memory-content">${_escHtml(snippet)}</div>
<div class="bme-memory-meta">
<span>imp: ${n.importance || 5}</span>
<span>acc: ${n.accessCount || 0}</span>
<span>seq: ${n.seq || 0}</span>
</div>
</div>
</li>`;
}).join('');
// 点击事件
listEl.querySelectorAll('.bme-memory-item').forEach(el => {
el.addEventListener('click', () => {
const nodeId = el.dataset.nodeId;
if (graphRenderer) graphRenderer.highlightNode(nodeId);
const node = graph.nodes.find(n => n.id === nodeId);
if (node) _showNodeDetail({ raw: node, type: node.type, name: node.content?.name || '' });
});
});
// 搜索绑定(防抖)
if (!searchInput._bmeBound) {
let timer;
searchInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => _refreshMemoryBrowser(), 200);
});
filterSelect?.addEventListener('change', () => _refreshMemoryBrowser());
searchInput._bmeBound = true;
}
}
// ==================== 注入预览 ====================
async function _refreshInjectionPreview() {
const graph = _getGraph?.();
const settings = _getSettings?.();
if (!graph || !settings) return;
const container = document.getElementById('bme-injection-content');
const tokenEl = document.getElementById('bme-injection-tokens');
if (!container) return;
try {
// 动态导入注入器模块
const { estimateTokens, formatInjection } = await import('./injector.js');
const injection = formatInjection(graph, settings.nodeSchema || []);
const totalTokens = estimateTokens(injection);
container.innerHTML = `<div class="bme-injection-preview">${_escHtml(injection)}</div>`;
if (tokenEl) tokenEl.textContent = `${totalTokens} tokens`;
} catch (e) {
container.innerHTML = `<div class="bme-injection-preview" style="color:var(--bme-accent3)">预览生成失败: ${_escHtml(e.message)}</div>`;
}
}
// ==================== 图谱 ====================
function _refreshGraph() {
const graph = _getGraph?.();
if (!graph) return;
if (graphRenderer) graphRenderer.loadGraph(graph);
if (mobileGraphRenderer) 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: '概要' },
];
legendEl.innerHTML = types.map(t =>
`<span class="bme-legend-item">
<span class="bme-legend-dot" style="background:${colors[t.key]}"></span>
${t.label}
</span>`
).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 name = raw.content?.name || raw.content?.title || raw.id?.slice(0, 8) || '—';
titleEl.textContent = name;
const fields = [
{ label: '类型', value: _typeLabel(raw.type) },
{ label: 'ID', value: raw.id?.slice(0, 12) + '...' },
{ label: '重要度', value: raw.importance || 5 },
{ label: '访问次数', value: raw.accessCount || 0 },
{ label: '序列号', value: raw.seq || 0 },
];
// 展示 content 字段
if (raw.content) {
for (const [k, v] of Object.entries(raw.content)) {
if (k === 'embedding') continue;
fields.push({ label: k, value: typeof v === 'object' ? JSON.stringify(v, null, 2) : v });
}
}
bodyEl.innerHTML = fields.map(f =>
`<div class="bme-node-detail-field">
<label>${_escHtml(f.label)}</label>
<div class="value">${_escHtml(String(f.value))}</div>
</div>`
).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', (e) => {
if (e.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 [elId, actionKey] of Object.entries(bindings)) {
document.getElementById(elId)?.addEventListener('click', async () => {
const handler = _actionHandlers[actionKey];
if (handler) {
try {
await handler();
// 刷新面板
_refreshDashboard();
_refreshGraph();
} catch (e) {
console.error(`[ST-BME] Action ${actionKey} failed:`, e);
}
}
});
}
}
// ==================== 工具函数 ====================
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 = str;
return div.innerHTML;
}
function _typeLabel(type) {
const map = {
character: '角色', event: '事件', location: '地点',
thread: '线索', rule: '规则', synopsis: '概要', reflection: '反思',
};
return map[type] || type || '—';
}
function _getNodeSnippet(node) {
const c = node.content || {};
if (c.description) return c.description;
if (c.summary) return c.summary;
if (c.what) return c.what;
const entries = Object.entries(c).filter(([k]) => k !== 'name' && k !== 'title' && k !== 'embedding');
if (entries.length) {
return entries.slice(0, 2).map(([k, v]) => `${k}: ${v}`).join('; ');
}
return '';
}
function _isMobile() {
return window.innerWidth <= 768;
}

View File

@@ -389,6 +389,31 @@
<hr class="st-bme-hr" />
<!-- 操控面板 -->
<div class="st-bme-section">
<h4 class="st-bme-section-title">
<i class="fa-solid fa-display"></i> 操控面板
</h4>
<div class="st-bme-row">
<label for="st_bme_panel_theme">面板主题</label>
<select id="st_bme_panel_theme" class="text_pole">
<option value="crimson">🔴 Crimson Synth</option>
<option value="cyan">🔵 Neon Cyan</option>
<option value="amber">🟡 Amber Console</option>
<option value="violet">🟣 Violet Haze</option>
</select>
</div>
<div class="st-bme-btn-group">
<button id="st_bme_btn_open_panel" class="menu_button">
<i class="fa-solid fa-brain"></i> 打开操控面板
</button>
</div>
</div>
<hr class="st-bme-hr" />
<!-- 操作 -->
<div class="st-bme-section">
<h4 class="st-bme-section-title">操作</h4>

778
style.css
View File

@@ -124,3 +124,781 @@
color: rgba(255, 255, 255, 0.35);
font-style: italic;
}
/* ==================== 操控面板 ==================== */
/* --- Overlay --- */
#st-bme-panel-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
#st-bme-panel-overlay.active {
opacity: 1;
pointer-events: auto;
}
/* --- Panel Container --- */
#st-bme-panel {
width: 82vw;
height: 72vh;
max-width: 1200px;
max-height: 800px;
background: var(--bme-surface, #131316);
border: 1px solid var(--bme-border, rgba(255,255,255,0.08));
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
transform: translateY(12px) scale(0.98);
transition: transform 0.25s ease;
}
#st-bme-panel-overlay.active #st-bme-panel {
transform: translateY(0) scale(1);
}
/* --- Header --- */
.bme-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--bme-surface-container, #1f1f22);
border-bottom: 1px solid var(--bme-border, rgba(255,255,255,0.08));
flex-shrink: 0;
}
.bme-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--bme-primary, #e94560);
}
.bme-panel-title i {
font-size: 16px;
}
.bme-panel-subtitle {
font-size: 11px;
color: var(--bme-on-surface-dim, rgba(228,225,230,0.6));
margin-left: 8px;
font-weight: 400;
}
.bme-panel-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--bme-on-surface-dim);
cursor: pointer;
border-radius: 4px;
font-size: 14px;
transition: all 0.15s;
}
.bme-panel-close:hover {
background: var(--bme-surface-highest);
color: var(--bme-on-surface);
}
/* --- Body (Desktop twin-panel) --- */
.bme-panel-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* --- Sidebar (Desktop) --- */
.bme-panel-sidebar {
width: 280px;
min-width: 280px;
display: flex;
flex-direction: column;
background: var(--bme-surface-container, #1f1f22);
border-right: 1px solid var(--bme-border);
overflow: hidden;
}
/* --- Tab List (Sidebar vertical) --- */
.bme-tab-list {
display: flex;
gap: 2px;
padding: 8px 8px 0;
flex-shrink: 0;
}
.bme-tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 4px;
border: none;
background: transparent;
color: var(--bme-on-surface-dim);
font-size: 11px;
cursor: pointer;
border-bottom: 2px solid transparent;
border-radius: 4px 4px 0 0;
transition: all 0.15s;
white-space: nowrap;
}
.bme-tab-btn:hover {
color: var(--bme-on-surface);
background: var(--bme-surface-high);
}
.bme-tab-btn.active {
color: var(--bme-primary);
border-bottom-color: var(--bme-primary);
background: var(--bme-surface-high);
}
.bme-tab-btn i {
font-size: 12px;
}
/* --- Tab Content --- */
.bme-tab-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.bme-tab-pane {
display: none;
}
.bme-tab-pane.active {
display: block;
}
/* --- Stats Grid --- */
.bme-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
}
.bme-stat-card {
background: var(--bme-surface-low, #1b1b1e);
border: 1px solid var(--bme-border);
border-radius: 6px;
padding: 10px;
}
.bme-stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bme-on-surface-dim);
margin-bottom: 4px;
}
.bme-stat-value {
font-size: 20px;
font-weight: 700;
color: var(--bme-primary);
font-feature-settings: 'tnum';
}
.bme-stat-value.warning {
color: var(--bme-accent3, #ffc107);
}
/* --- Section Headers --- */
.bme-section-header {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--bme-on-surface-dim);
margin: 12px 0 6px;
padding-bottom: 4px;
border-bottom: 1px solid var(--bme-border);
}
/* --- Recent List --- */
.bme-recent-list {
list-style: none;
padding: 0;
margin: 0;
}
.bme-recent-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 0;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.bme-recent-item:hover {
background: var(--bme-surface-high);
}
.bme-type-badge {
display: inline-block;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
margin-top: 2px;
}
.bme-type-badge.character { background: var(--bme-node-character); color: #000; }
.bme-type-badge.event { background: var(--bme-node-event); color: #000; }
.bme-type-badge.location { background: var(--bme-node-location); color: #000; }
.bme-type-badge.thread { background: var(--bme-node-thread); color: #000; }
.bme-type-badge.rule { background: var(--bme-node-rule); color: #fff; }
.bme-type-badge.synopsis { background: var(--bme-node-synopsis); color: #000; }
.bme-recent-text {
font-size: 12px;
color: var(--bme-on-surface);
line-height: 1.3;
}
.bme-recent-meta {
font-size: 10px;
color: var(--bme-on-surface-dim);
margin-top: 2px;
}
/* --- Main (Graph) Area --- */
.bme-panel-main {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: var(--bme-surface-lowest, #0e0e11);
overflow: hidden;
}
.bme-graph-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: var(--bme-surface, #131316);
border-bottom: 1px solid var(--bme-border);
flex-shrink: 0;
}
.bme-graph-toolbar-title {
font-size: 11px;
color: var(--bme-primary);
display: flex;
align-items: center;
gap: 6px;
}
.bme-graph-controls {
display: flex;
gap: 4px;
}
.bme-graph-controls button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--bme-border);
border-radius: 4px;
background: var(--bme-surface-container);
color: var(--bme-on-surface-dim);
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
}
.bme-graph-controls button:hover {
border-color: var(--bme-primary);
color: var(--bme-primary);
}
#bme-graph-canvas {
flex: 1;
width: 100%;
cursor: grab;
}
#bme-graph-canvas:active {
cursor: grabbing;
}
/* --- Graph Legend --- */
.bme-graph-legend {
display: flex;
gap: 12px;
padding: 6px 12px;
background: var(--bme-surface, #131316);
border-top: 1px solid var(--bme-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.bme-legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--bme-on-surface-dim);
}
.bme-legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* --- Status Bar --- */
.bme-graph-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
background: var(--bme-surface-container);
border-top: 1px solid var(--bme-border);
font-size: 10px;
color: var(--bme-on-surface-dim);
flex-shrink: 0;
}
.bme-status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bme-accent2, #4edea3);
margin-right: 4px;
}
/* --- Memory Browser Tab --- */
.bme-search-bar {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.bme-search-input {
flex: 1;
background: var(--bme-surface-lowest);
border: 1px solid var(--bme-border);
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
color: var(--bme-on-surface);
outline: none;
transition: border-color 0.15s;
}
.bme-search-input:focus {
border-color: var(--bme-primary);
box-shadow: 0 0 4px var(--bme-primary-dim);
}
.bme-search-input::placeholder {
color: var(--bme-on-surface-dim);
}
.bme-filter-select {
background: var(--bme-surface-lowest);
border: 1px solid var(--bme-border);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
color: var(--bme-on-surface);
outline: none;
}
.bme-memory-list {
list-style: none;
padding: 0;
margin: 0;
}
.bme-memory-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px;
border-radius: 4px;
margin-bottom: 3px;
cursor: pointer;
transition: background 0.1s;
border: 1px solid transparent;
}
.bme-memory-item:hover {
background: var(--bme-surface-high);
border-color: var(--bme-border);
}
.bme-memory-item.selected {
background: var(--bme-primary-dim);
border-color: var(--bme-primary);
}
.bme-memory-name {
font-size: 12px;
font-weight: 600;
color: var(--bme-on-surface);
}
.bme-memory-content {
font-size: 11px;
color: var(--bme-on-surface-dim);
margin-top: 2px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bme-memory-meta {
display: flex;
gap: 8px;
margin-top: 4px;
font-size: 10px;
color: var(--bme-on-surface-dim);
}
/* --- Injection Preview Tab --- */
.bme-injection-preview {
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
background: var(--bme-surface-lowest);
border: 1px solid var(--bme-border);
border-radius: 4px;
padding: 12px;
color: var(--bme-on-surface);
max-height: 100%;
overflow-y: auto;
}
.bme-injection-section-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
margin-bottom: 6px;
}
.bme-injection-section-label.core {
background: var(--bme-primary-dim);
color: var(--bme-primary);
}
.bme-injection-section-label.recall {
background: rgba(76, 175, 80, 0.15);
color: var(--bme-accent2);
}
.bme-injection-token-count {
font-size: 11px;
color: var(--bme-on-surface-dim);
margin-top: 8px;
text-align: right;
}
/* --- Actions Tab --- */
.bme-action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.bme-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 14px 8px;
background: var(--bme-surface-low);
border: 1px solid var(--bme-border);
border-radius: 6px;
color: var(--bme-on-surface-dim);
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
}
.bme-action-btn:hover {
border-color: var(--bme-primary);
color: var(--bme-primary);
background: var(--bme-primary-dim);
}
.bme-action-btn i {
font-size: 18px;
}
.bme-action-btn.danger:hover {
border-color: #ff5252;
color: #ff5252;
background: rgba(255, 82, 82, 0.1);
}
/* --- Mobile Bottom Tab Bar --- */
.bme-panel-tabbar {
display: none;
border-top: 1px solid var(--bme-border);
background: var(--bme-surface-container);
flex-shrink: 0;
}
.bme-panel-tabbar .bme-tab-btn {
flex: 1;
flex-direction: column;
padding: 6px 4px;
font-size: 10px;
border-bottom: none;
border-radius: 0;
border-top: 2px solid transparent;
min-height: 48px;
}
.bme-panel-tabbar .bme-tab-btn.active {
border-top-color: var(--bme-primary);
border-bottom-color: transparent;
}
.bme-panel-tabbar .bme-tab-btn i {
font-size: 16px;
margin-bottom: 2px;
}
/* --- Node Detail Panel (sidebar overlay) --- */
.bme-node-detail {
position: absolute;
top: 0;
right: 0;
width: 280px;
height: 100%;
background: var(--bme-surface-container);
border-left: 1px solid var(--bme-border);
padding: 12px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.2s ease;
z-index: 10;
}
.bme-node-detail.open {
transform: translateX(0);
}
.bme-node-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.bme-node-detail h3 {
font-size: 14px;
color: var(--bme-on-surface);
margin: 0;
}
.bme-node-detail-field {
margin-bottom: 8px;
}
.bme-node-detail-field label {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bme-on-surface-dim);
margin-bottom: 3px;
}
.bme-node-detail-field .value {
font-size: 12px;
color: var(--bme-on-surface);
line-height: 1.4;
}
/* --- Scrollbar --- */
.bme-tab-content::-webkit-scrollbar,
.bme-injection-preview::-webkit-scrollbar,
.bme-node-detail::-webkit-scrollbar {
width: 4px;
}
.bme-tab-content::-webkit-scrollbar-track,
.bme-injection-preview::-webkit-scrollbar-track,
.bme-node-detail::-webkit-scrollbar-track {
background: transparent;
}
.bme-tab-content::-webkit-scrollbar-thumb,
.bme-injection-preview::-webkit-scrollbar-thumb,
.bme-node-detail::-webkit-scrollbar-thumb {
background: var(--bme-surface-highest);
border-radius: 2px;
}
/* 移动端图谱预览 - 桌面端默认隐藏 */
.bme-mobile-graph-preview,
.bme-mobile-graph-status {
display: none;
}
/* ==================== 响应式 ==================== */
@media (max-width: 768px) {
#st-bme-panel {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
}
.bme-panel-body {
flex-direction: column;
}
/* 手机端sidebar 全宽显示,成为主内容区 */
.bme-panel-sidebar {
width: 100%;
min-width: unset;
flex: 1;
border-right: none;
}
/* 隐藏 sidebar 顶部 tab 列表,改用底部 tab bar */
.bme-panel-sidebar > .bme-tab-list {
display: none;
}
/* 手机端 tab content 撑满剩余空间 */
.bme-tab-content {
padding: 12px 14px;
}
/* 手机端:隐藏桌面端图谱区(大图谱) */
.bme-panel-main {
display: none;
}
/* 手机端底部 Tab Bar 显示 */
.bme-panel-tabbar {
display: flex;
}
/* 总览 Tab: 统计卡横向滚动 */
.bme-stats-grid {
display: flex;
gap: 6px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.bme-stats-grid .bme-stat-card {
min-width: 105px;
flex-shrink: 0;
}
/* 搜索栏改为纵向堆叠 */
.bme-search-bar {
flex-direction: column;
}
.bme-filter-select {
width: 100%;
}
/* 操作按钮放大触控区域 */
.bme-action-btn {
padding: 18px 8px;
font-size: 12px;
}
.bme-action-btn i {
font-size: 22px;
}
/* 节点详情:手机端全宽覆盖 */
.bme-node-detail {
width: 100%;
}
.bme-graph-legend {
font-size: 9px;
}
/* 手机端图谱预览(嵌在总览 Tab 内) */
.bme-mobile-graph-preview {
display: block;
height: 200px;
border-radius: 6px;
overflow: hidden;
margin: 8px 0 0;
border: 1px solid var(--bme-border);
background: var(--bme-surface-lowest);
position: relative;
}
.bme-mobile-graph-preview canvas {
width: 100%;
height: 100%;
}
.bme-mobile-graph-label {
position: absolute;
top: 6px;
right: 8px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bme-accent2);
background: var(--bme-surface);
padding: 1px 6px;
border-radius: 3px;
}
.bme-mobile-graph-status {
display: flex;
margin-bottom: 12px;
border-radius: 0 0 6px 6px;
}
}

174
themes.js Normal file
View File

@@ -0,0 +1,174 @@
// ST-BME: 主题配色系统
// 4 套 CSS 变量主题,通过 data-bme-theme 属性切换
export const THEMES = {
crimson: {
name: 'Crimson Synth',
primary: '#e94560',
primaryDim: 'rgba(233, 69, 96, 0.15)',
primaryGlow: 'rgba(233, 69, 96, 0.35)',
primaryText: '#ffb2b7',
secondary: '#fc536d',
accent2: '#4edea3', // tertiary / success
accent3: '#ffc107', // warning / P1
surface: '#131316',
surfaceContainer: '#1f1f22',
surfaceHigh: '#2a2a2d',
surfaceHighest: '#353438',
surfaceLow: '#1b1b1e',
surfaceLowest: '#0e0e11',
onSurface: '#e4e1e6',
onSurfaceDim: 'rgba(228, 225, 230, 0.6)',
border: 'rgba(255, 255, 255, 0.08)',
borderActive: 'rgba(233, 69, 96, 0.4)',
// 节点颜色
nodeCharacter: '#e94560',
nodeEvent: '#4fc3f7',
nodeLocation: '#66bb6a',
nodeThread: '#ffd54f',
nodeRule: '#ab47bc',
nodeSynopsis: '#b388ff',
nodeReflection: '#80deea',
},
cyan: {
name: 'Neon Cyan',
primary: '#00e5ff',
primaryDim: 'rgba(0, 229, 255, 0.15)',
primaryGlow: 'rgba(0, 229, 255, 0.35)',
primaryText: '#80f0ff',
secondary: '#2979ff',
accent2: '#00e676',
accent3: '#ffab40',
surface: '#131316',
surfaceContainer: '#1a1f22',
surfaceHigh: '#222a2d',
surfaceHighest: '#2d3538',
surfaceLow: '#171d1e',
surfaceLowest: '#0e1111',
onSurface: '#e0f7fa',
onSurfaceDim: 'rgba(224, 247, 250, 0.6)',
border: 'rgba(0, 229, 255, 0.1)',
borderActive: 'rgba(0, 229, 255, 0.4)',
nodeCharacter: '#00e5ff',
nodeEvent: '#2979ff',
nodeLocation: '#00bfa5',
nodeThread: '#ffab40',
nodeRule: '#7c4dff',
nodeSynopsis: '#18ffff',
nodeReflection: '#84ffff',
},
amber: {
name: 'Amber Console',
primary: '#ffb300',
primaryDim: 'rgba(255, 179, 0, 0.15)',
primaryGlow: 'rgba(255, 179, 0, 0.35)',
primaryText: '#ffd79b',
secondary: '#e65100',
accent2: '#00d2fe',
accent3: '#ff6e40',
surface: '#131316',
surfaceContainer: '#1f1d1a',
surfaceHigh: '#2a2822',
surfaceHighest: '#35322a',
surfaceLow: '#1b1a17',
surfaceLowest: '#0e0d0b',
onSurface: '#e4e1d6',
onSurfaceDim: 'rgba(228, 225, 214, 0.6)',
border: 'rgba(255, 179, 0, 0.1)',
borderActive: 'rgba(255, 179, 0, 0.4)',
nodeCharacter: '#ffb300',
nodeEvent: '#e65100',
nodeLocation: '#00d2fe',
nodeThread: '#ff6e40',
nodeRule: '#9e9d24',
nodeSynopsis: '#ffd740',
nodeReflection: '#ffab40',
},
violet: {
name: 'Violet Haze',
primary: '#b388ff',
primaryDim: 'rgba(179, 136, 255, 0.15)',
primaryGlow: 'rgba(179, 136, 255, 0.35)',
primaryText: '#d1b3ff',
secondary: '#7c4dff',
accent2: '#ea80fc',
accent3: '#ff80ab',
surface: '#131316',
surfaceContainer: '#1e1a22',
surfaceHigh: '#28222d',
surfaceHighest: '#332b38',
surfaceLow: '#1a171e',
surfaceLowest: '#0e0c11',
onSurface: '#e8e0f0',
onSurfaceDim: 'rgba(232, 224, 240, 0.6)',
border: 'rgba(179, 136, 255, 0.1)',
borderActive: 'rgba(179, 136, 255, 0.4)',
nodeCharacter: '#ea80fc',
nodeEvent: '#7c4dff',
nodeLocation: '#80cbc4',
nodeThread: '#ff80ab',
nodeRule: '#b388ff',
nodeSynopsis: '#ce93d8',
nodeReflection: '#80deea',
},
};
/**
* 将主题配色应用为 CSS 变量
* @param {string} themeName - crimson | cyan | amber | violet
* @param {HTMLElement} [root] - 目标元素,默认 document.documentElement
*/
export function applyTheme(themeName, root = null) {
const theme = THEMES[themeName] || THEMES.crimson;
const el = root || document.documentElement;
const vars = {
'--bme-primary': theme.primary,
'--bme-primary-dim': theme.primaryDim,
'--bme-primary-glow': theme.primaryGlow,
'--bme-primary-text': theme.primaryText,
'--bme-secondary': theme.secondary,
'--bme-accent2': theme.accent2,
'--bme-accent3': theme.accent3,
'--bme-surface': theme.surface,
'--bme-surface-container': theme.surfaceContainer,
'--bme-surface-high': theme.surfaceHigh,
'--bme-surface-highest': theme.surfaceHighest,
'--bme-surface-low': theme.surfaceLow,
'--bme-surface-lowest': theme.surfaceLowest,
'--bme-on-surface': theme.onSurface,
'--bme-on-surface-dim': theme.onSurfaceDim,
'--bme-border': theme.border,
'--bme-border-active': theme.borderActive,
'--bme-node-character': theme.nodeCharacter,
'--bme-node-event': theme.nodeEvent,
'--bme-node-location': theme.nodeLocation,
'--bme-node-thread': theme.nodeThread,
'--bme-node-rule': theme.nodeRule,
'--bme-node-synopsis': theme.nodeSynopsis,
'--bme-node-reflection': theme.nodeReflection,
};
for (const [key, value] of Object.entries(vars)) {
el.style.setProperty(key, value);
}
el.setAttribute('data-bme-theme', themeName);
}
/**
* 获取当前主题的节点颜色映射
* @param {string} themeName
* @returns {Object<string, string>}
*/
export function getNodeColors(themeName) {
const theme = THEMES[themeName] || THEMES.crimson;
return {
character: theme.nodeCharacter,
event: theme.nodeEvent,
location: theme.nodeLocation,
thread: theme.nodeThread,
rule: theme.nodeRule,
synopsis: theme.nodeSynopsis,
reflection: theme.nodeReflection,
};
}