mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
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:
461
graph-renderer.js
Normal file
461
graph-renderer.js
Normal 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();
|
||||
}
|
||||
}
|
||||
9
graph.js
9
graph.js
@@ -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
107
index.js
@@ -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
206
panel.html
Normal 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
415
panel.js
Normal 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;
|
||||
}
|
||||
@@ -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
778
style.css
@@ -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
174
themes.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user