- ChatArea: DeerFlow ai-elements annotations for accessibility - Conversation: remove unused Context, simplify message rendering - Delete dead modules: audit-logger.ts, gateway-reconnect.ts - Replace console.log with structured logger across components - Add idb dependency for IndexedDB persistence - Fix kernel-skills type safety improvements
620 lines
20 KiB
TypeScript
620 lines
20 KiB
TypeScript
/**
|
|
* MemoryGraph - 记忆图谱可视化组件
|
|
*
|
|
* 使用 Canvas 实现力导向图布局,展示记忆之间的关联关系。
|
|
*
|
|
* 功能:
|
|
* - 力导向布局算法
|
|
* - 节点拖拽
|
|
* - 类型筛选
|
|
* - 搜索高亮
|
|
* - 导出图片
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
ZoomIn,
|
|
ZoomOut,
|
|
Maximize2,
|
|
Download,
|
|
Search,
|
|
Filter,
|
|
X,
|
|
RefreshCw,
|
|
Tag,
|
|
Clock,
|
|
Star,
|
|
} from 'lucide-react';
|
|
import { Button, Badge } from './ui';
|
|
import {
|
|
useMemoryGraphStore,
|
|
type GraphNode,
|
|
type GraphEdge,
|
|
type MemoryType,
|
|
} from '../store/memoryGraphStore';
|
|
import { useConversationStore } from '../store/chat/conversationStore';
|
|
import { cardHover, defaultTransition } from '../lib/animations';
|
|
|
|
// Mark as intentionally unused for future use
|
|
void cardHover;
|
|
void defaultTransition;
|
|
|
|
// === Constants ===
|
|
|
|
const NODE_COLORS: Record<MemoryType, { fill: string; stroke: string; text: string }> = {
|
|
fact: { fill: '#3b82f6', stroke: '#1d4ed8', text: '#ffffff' },
|
|
preference: { fill: '#f59e0b', stroke: '#d97706', text: '#ffffff' },
|
|
lesson: { fill: '#10b981', stroke: '#059669', text: '#ffffff' },
|
|
context: { fill: '#8b5cf6', stroke: '#7c3aed', text: '#ffffff' },
|
|
task: { fill: '#ef4444', stroke: '#dc2626', text: '#ffffff' },
|
|
};
|
|
|
|
const TYPE_LABELS: Record<MemoryType, string> = {
|
|
fact: '事实',
|
|
preference: '偏好',
|
|
lesson: '经验',
|
|
context: '上下文',
|
|
task: '任务',
|
|
};
|
|
|
|
const NODE_RADIUS = 20;
|
|
const REPULSION_STRENGTH = 5000;
|
|
const ATTRACTION_STRENGTH = 0.01;
|
|
const DAMPING = 0.9;
|
|
const MIN_VELOCITY = 0.01;
|
|
|
|
// === Force-Directed Layout ===
|
|
|
|
function simulateStep(
|
|
nodes: GraphNode[],
|
|
edges: GraphEdge[],
|
|
width: number,
|
|
height: number
|
|
): GraphNode[] {
|
|
const updatedNodes = nodes.map(node => ({ ...node }));
|
|
|
|
// 斥力 (节点间)
|
|
for (let i = 0; i < updatedNodes.length; i++) {
|
|
for (let j = i + 1; j < updatedNodes.length; j++) {
|
|
const n1 = updatedNodes[i];
|
|
const n2 = updatedNodes[j];
|
|
const dx = n2.x - n1.x;
|
|
const dy = n2.y - n1.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const force = REPULSION_STRENGTH / (dist * dist);
|
|
|
|
const fx = (dx / dist) * force;
|
|
const fy = (dy / dist) * force;
|
|
|
|
n1.vx -= fx;
|
|
n1.vy -= fy;
|
|
n2.vx += fx;
|
|
n2.vy += fy;
|
|
}
|
|
}
|
|
|
|
// 引力 (边)
|
|
for (const edge of edges) {
|
|
const source = updatedNodes.find(n => n.id === edge.source);
|
|
const target = updatedNodes.find(n => n.id === edge.target);
|
|
if (!source || !target) continue;
|
|
|
|
const dx = target.x - source.x;
|
|
const dy = target.y - source.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const force = dist * ATTRACTION_STRENGTH * edge.strength;
|
|
|
|
const fx = (dx / dist) * force;
|
|
const fy = (dy / dist) * force;
|
|
|
|
source.vx += fx;
|
|
source.vy += fy;
|
|
target.vx -= fx;
|
|
target.vy -= fy;
|
|
}
|
|
|
|
// 中心引力
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const centerForce = 0.001;
|
|
|
|
for (const node of updatedNodes) {
|
|
node.vx += (centerX - node.x) * centerForce;
|
|
node.vy += (centerY - node.y) * centerForce;
|
|
}
|
|
|
|
// 更新位置
|
|
for (const node of updatedNodes) {
|
|
node.vx *= DAMPING;
|
|
node.vy *= DAMPING;
|
|
|
|
if (Math.abs(node.vx) < MIN_VELOCITY) node.vx = 0;
|
|
if (Math.abs(node.vy) < MIN_VELOCITY) node.vy = 0;
|
|
|
|
node.x += node.vx;
|
|
node.y += node.vy;
|
|
|
|
// 边界约束
|
|
node.x = Math.max(NODE_RADIUS, Math.min(width - NODE_RADIUS, node.x));
|
|
node.y = Math.max(NODE_RADIUS, Math.min(height - NODE_RADIUS, node.y));
|
|
}
|
|
|
|
return updatedNodes;
|
|
}
|
|
|
|
// === Main Component ===
|
|
|
|
interface MemoryGraphProps {
|
|
className?: string;
|
|
}
|
|
|
|
export function MemoryGraph({ className = '' }: MemoryGraphProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const animationRef = useRef<number | null>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragNode, setDragNode] = useState<string | null>(null);
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const currentAgent = useConversationStore((s) => s.currentAgent);
|
|
const agentId = currentAgent?.id || 'zclaw-main';
|
|
|
|
const {
|
|
isLoading,
|
|
error,
|
|
filter,
|
|
layout,
|
|
selectedNodeId,
|
|
showLabels,
|
|
simulationRunning,
|
|
loadGraph,
|
|
setFilter,
|
|
resetFilter,
|
|
setLayout,
|
|
selectNode,
|
|
toggleLabels,
|
|
startSimulation,
|
|
stopSimulation,
|
|
updateNodePositions,
|
|
highlightSearch,
|
|
getFilteredNodes,
|
|
getFilteredEdges,
|
|
} = useMemoryGraphStore();
|
|
|
|
const filteredNodes = getFilteredNodes();
|
|
const filteredEdges = getFilteredEdges();
|
|
|
|
// 加载图谱
|
|
useEffect(() => {
|
|
loadGraph(agentId);
|
|
}, [agentId, loadGraph]);
|
|
|
|
// 力导向模拟
|
|
useEffect(() => {
|
|
if (!simulationRunning || filteredNodes.length === 0) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const simulate = () => {
|
|
const updated = simulateStep(filteredNodes, filteredEdges, layout.width, layout.height);
|
|
updateNodePositions(updated.map(n => ({ id: n.id, x: n.x, y: n.y })));
|
|
animationRef.current = requestAnimationFrame(simulate);
|
|
};
|
|
|
|
animationRef.current = requestAnimationFrame(simulate);
|
|
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, [simulationRunning, filteredNodes.length, filteredEdges, layout.width, layout.height, updateNodePositions]);
|
|
|
|
// Canvas 渲染
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = layout.width * dpr;
|
|
canvas.height = layout.height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// 清空画布 - 使用浅色背景匹配系统主题
|
|
ctx.fillStyle = '#f9fafb'; // gray-50
|
|
ctx.fillRect(0, 0, layout.width, layout.height);
|
|
|
|
// 应用变换
|
|
ctx.save();
|
|
ctx.translate(layout.offsetX, layout.offsetY);
|
|
ctx.scale(layout.zoom, layout.zoom);
|
|
|
|
// 绘制边
|
|
for (const edge of filteredEdges) {
|
|
const source = filteredNodes.find(n => n.id === edge.source);
|
|
const target = filteredNodes.find(n => n.id === edge.target);
|
|
if (!source || !target) continue;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(source.x, source.y);
|
|
ctx.lineTo(target.x, target.y);
|
|
ctx.strokeStyle = edge.type === 'reference'
|
|
? 'rgba(59, 130, 246, 0.5)'
|
|
: edge.type === 'related'
|
|
? 'rgba(245, 158, 11, 0.3)'
|
|
: 'rgba(139, 92, 246, 0.2)';
|
|
ctx.lineWidth = edge.strength * 3;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// 绘制节点
|
|
for (const node of filteredNodes) {
|
|
const colors = NODE_COLORS[node.type];
|
|
const isSelected = node.id === selectedNodeId;
|
|
const radius = isSelected ? NODE_RADIUS * 1.3 : NODE_RADIUS;
|
|
|
|
// 高亮效果
|
|
if (node.isHighlighted) {
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, radius + 8, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
|
ctx.fill();
|
|
}
|
|
|
|
// 节点圆形
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = colors.fill;
|
|
ctx.fill();
|
|
ctx.strokeStyle = colors.stroke;
|
|
ctx.lineWidth = isSelected ? 3 : 1;
|
|
ctx.stroke();
|
|
|
|
// 节点标签
|
|
if (showLabels) {
|
|
ctx.font = '10px Inter, system-ui, sans-serif';
|
|
ctx.fillStyle = colors.text;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const text = node.label.slice(0, 10);
|
|
ctx.fillText(text, node.x, node.y);
|
|
}
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
// 图例
|
|
const legendY = 20;
|
|
let legendX = 20;
|
|
ctx.font = '12px Inter, system-ui, sans-serif';
|
|
|
|
for (const [type, label] of Object.entries(TYPE_LABELS)) {
|
|
const colors = NODE_COLORS[type as MemoryType];
|
|
ctx.beginPath();
|
|
ctx.arc(legendX, legendY, 6, 0, Math.PI * 2);
|
|
ctx.fillStyle = colors.fill;
|
|
ctx.fill();
|
|
ctx.fillStyle = '#6b7280'; // gray-500 for better visibility on light background
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(label, legendX + 12, legendY + 4);
|
|
legendX += 70;
|
|
}
|
|
|
|
}, [filteredNodes, filteredEdges, layout, selectedNodeId, showLabels]);
|
|
|
|
// 鼠标事件处理
|
|
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left - layout.offsetX) / layout.zoom;
|
|
const y = (e.clientY - rect.top - layout.offsetY) / layout.zoom;
|
|
|
|
// 检查是否点击了节点
|
|
for (const node of filteredNodes) {
|
|
const dx = x - node.x;
|
|
const dy = y - node.y;
|
|
if (dx * dx + dy * dy < NODE_RADIUS * NODE_RADIUS) {
|
|
setDragNode(node.id);
|
|
setIsDragging(true);
|
|
selectNode(node.id);
|
|
return;
|
|
}
|
|
}
|
|
|
|
selectNode(null);
|
|
}, [filteredNodes, layout, selectNode]);
|
|
|
|
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (!isDragging || !dragNode) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left - layout.offsetX) / layout.zoom;
|
|
const y = (e.clientY - rect.top - layout.offsetY) / layout.zoom;
|
|
|
|
updateNodePositions([{ id: dragNode, x, y }]);
|
|
}, [isDragging, dragNode, layout, updateNodePositions]);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
setIsDragging(false);
|
|
setDragNode(null);
|
|
}, []);
|
|
|
|
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
const newZoom = Math.max(0.2, Math.min(3, layout.zoom * delta));
|
|
setLayout({ zoom: newZoom });
|
|
}, [layout.zoom, setLayout]);
|
|
|
|
const handleSearch = useCallback((query: string) => {
|
|
setSearchQuery(query);
|
|
highlightSearch(query);
|
|
}, [highlightSearch]);
|
|
|
|
const handleExport = useCallback(async () => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const dataUrl = canvas.toDataURL('image/png');
|
|
const a = document.createElement('a');
|
|
a.href = dataUrl;
|
|
a.download = `memory-graph-${new Date().toISOString().slice(0, 10)}.png`;
|
|
a.click();
|
|
}, []);
|
|
|
|
const selectedNode = filteredNodes.find(n => n.id === selectedNodeId);
|
|
|
|
return (
|
|
<div className={`flex flex-col h-full ${className}`}>
|
|
{/* 工具栏 */}
|
|
<div className="flex items-center gap-2 p-2 bg-gray-100 dark:bg-gray-800/50 rounded-t-lg border-b border-gray-200 dark:border-gray-700">
|
|
{/* 搜索框 */}
|
|
<div className="relative flex-1 max-w-xs">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索记忆..."
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
className="w-full pl-8 pr-2 py-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-orange-400 dark:focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* 筛选按钮 */}
|
|
<Button
|
|
variant={showFilters ? 'primary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="flex items-center gap-1"
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
筛选
|
|
</Button>
|
|
|
|
{/* 缩放控制 */}
|
|
<div className="flex items-center gap-1 border-l border-gray-700 pl-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLayout({ zoom: Math.max(0.2, layout.zoom * 0.8) })}
|
|
>
|
|
<ZoomOut className="w-4 h-4" />
|
|
</Button>
|
|
<span className="text-xs text-gray-400 min-w-[3rem] text-center">
|
|
{Math.round(layout.zoom * 100)}%
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLayout({ zoom: Math.min(3, layout.zoom * 1.2) })}
|
|
>
|
|
<ZoomIn className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLayout({ zoom: 1, offsetX: 0, offsetY: 0 })}
|
|
>
|
|
<Maximize2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 模拟控制 */}
|
|
<div className="flex items-center gap-1 border-l border-gray-700 pl-2">
|
|
<Button
|
|
variant={simulationRunning ? 'primary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => simulationRunning ? stopSimulation() : startSimulation()}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${simulationRunning ? 'animate-spin' : ''}`} />
|
|
{simulationRunning ? '停止' : '布局'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 导出 */}
|
|
<Button variant="ghost" size="sm" onClick={handleExport}>
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
|
|
{/* 标签切换 */}
|
|
<Button variant="ghost" size="sm" onClick={toggleLabels}>
|
|
<Tag className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 筛选面板 */}
|
|
<AnimatePresence>
|
|
{showFilters && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="overflow-hidden bg-gray-50 dark:bg-gray-800/30 border-b border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div className="p-3 flex flex-wrap gap-3">
|
|
{/* 类型筛选 */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-400">类型:</span>
|
|
{(Object.keys(TYPE_LABELS) as MemoryType[]).map(type => (
|
|
<button
|
|
key={type}
|
|
onClick={() => {
|
|
const types = filter.types.includes(type)
|
|
? filter.types.filter(t => t !== type)
|
|
: [...filter.types, type];
|
|
setFilter({ types });
|
|
}}
|
|
className={`px-2 py-1 text-xs rounded ${
|
|
filter.types.includes(type)
|
|
? 'bg-orange-500 text-white'
|
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
}`}
|
|
>
|
|
{TYPE_LABELS[type]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 重要性筛选 */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-400">重要性:</span>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="10"
|
|
value={filter.minImportance}
|
|
onChange={(e) => setFilter({ minImportance: parseInt(e.target.value) })}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-xs text-gray-300">{filter.minImportance}+</span>
|
|
</div>
|
|
|
|
{/* 重置 */}
|
|
<Button variant="ghost" size="sm" onClick={resetFilter}>
|
|
<X className="w-3 h-3 mr-1" />
|
|
重置
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 图谱画布 */}
|
|
<div className="flex-1 relative overflow-hidden bg-gray-50 dark:bg-gray-900">
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-50/80 dark:bg-gray-900/80 z-10">
|
|
<RefreshCw className="w-8 h-8 text-blue-500 animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-red-400 text-sm">{error}</div>
|
|
</div>
|
|
)}
|
|
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{ width: layout.width, height: layout.height }}
|
|
className="cursor-grab active:cursor-grabbing"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
onWheel={handleWheel}
|
|
/>
|
|
|
|
{/* 节点详情面板 */}
|
|
<AnimatePresence>
|
|
{selectedNode && (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 20 }}
|
|
className="absolute top-4 right-4 w-64 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-xl"
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<Badge variant={selectedNode.type as any}>
|
|
{TYPE_LABELS[selectedNode.type]}
|
|
</Badge>
|
|
<button
|
|
onClick={() => selectNode(null)}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-white"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-200 mb-3">{selectedNode.label}</p>
|
|
|
|
<div className="space-y-2 text-xs text-gray-500 dark:text-gray-400">
|
|
<div className="flex items-center gap-2">
|
|
<Star className="w-3 h-3" />
|
|
重要性: {selectedNode.importance}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="w-3 h-3" />
|
|
访问次数: {selectedNode.accessCount}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Tag className="w-3 h-3" />
|
|
创建: {new Date(selectedNode.createdAt).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 关联边统计 */}
|
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">关联记忆:</div>
|
|
<div className="text-sm text-gray-700 dark:text-gray-200">
|
|
{filteredEdges.filter(
|
|
e => e.source === selectedNode.id || e.target === selectedNode.id
|
|
).length} 个
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 空状态 */}
|
|
{!isLoading && filteredNodes.length === 0 && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-center text-gray-500">
|
|
<Tag className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
<p>暂无记忆数据</p>
|
|
<p className="text-sm mt-1">开始对话后将自动记录记忆</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 状态栏 */}
|
|
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800/50 rounded-b-lg text-xs text-gray-500 dark:text-gray-400">
|
|
<div className="flex items-center gap-4">
|
|
<span>节点: {filteredNodes.length}</span>
|
|
<span>关联: {filteredEdges.length}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{simulationRunning && (
|
|
<span className="flex items-center gap-1 text-green-400">
|
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
布局中...
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default MemoryGraph;
|