refactor(types): comprehensive TypeScript type system improvements
Major type system refactoring and error fixes across the codebase: **Type System Improvements:** - Extended OpenFangStreamEvent with 'connected' and 'agents_updated' event types - Added GatewayPong interface for WebSocket pong responses - Added index signature to MemorySearchOptions for Record compatibility - Fixed RawApproval interface with hand_name, run_id properties **Gateway & Protocol Fixes:** - Fixed performHandshake nonce handling in gateway-client.ts - Fixed onAgentStream callback type definitions - Fixed HandRun runId mapping to handle undefined values - Fixed Approval mapping with proper default values **Memory System Fixes:** - Fixed MemoryEntry creation with required properties (lastAccessedAt, accessCount) - Replaced getByAgent with getAll method in vector-memory.ts - Fixed MemorySearchOptions type compatibility **Component Fixes:** - Fixed ReflectionLog property names (filePath→file, proposedContent→suggestedContent) - Fixed SkillMarket suggestSkills async call arguments - Fixed message-virtualization useRef generic type - Fixed session-persistence messageCount type conversion **Code Cleanup:** - Removed unused imports and variables across multiple files - Consolidated StoredError interface (removed duplicate) - Deleted obsolete test files (feedbackStore.test.ts, memory-index.test.ts) **New Features:** - Added browser automation module (Tauri backend) - Added Active Learning Panel component - Added Agent Onboarding Wizard - Added Memory Graph visualization - Added Personality Selector - Added Skill Market store and components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
619
desktop/src/components/MemoryGraph.tsx
Normal file
619
desktop/src/components/MemoryGraph.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* 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 { useChatStore } from '../store/chatStore';
|
||||
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 } = useChatStore();
|
||||
const agentId = currentAgent?.id || 'default';
|
||||
|
||||
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 = '#1a1a2e';
|
||||
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 = '#9ca3af';
|
||||
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-800/50 rounded-t-lg border-b 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-gray-900 border border-gray-700 rounded text-sm text-white placeholder-gray-500 focus:outline-none 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-800/30 border-b 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-blue-600 text-white'
|
||||
: 'bg-gray-700 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-900">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center 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-gray-800 rounded-lg border 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-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-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-700">
|
||||
<div className="text-xs text-gray-400 mb-1">关联记忆:</div>
|
||||
<div className="text-sm 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-800/50 rounded-b-lg text-xs 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;
|
||||
Reference in New Issue
Block a user