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:
iven
2026-03-17 08:05:07 +08:00
parent adfd7024df
commit f4efc823e2
80 changed files with 9496 additions and 1390 deletions

View 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;