Files
zclaw_openfang/desktop/src/components/SwarmDashboard.tsx
iven 6f72442531 docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective
Major changes:
- Shift from "OpenFang desktop client" to "independent AI Agent desktop app"
- Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?"
- Simplify project structure and tech stack sections
- Replace OpenClaw vs OpenFang comparison with unified backend approach
- Consolidate troubleshooting from scattered sections into organized FAQ
- Update Hands system documentation with 8 capabilities and status
- Stream
2026-03-20 19:30:09 +08:00

591 lines
20 KiB
TypeScript

/**
* SwarmDashboard - Multi-Agent Collaboration Task Dashboard
*
* Visualizes swarm tasks, multi-agent collaboration) with real-time
* status updates, task history, and manual trigger functionality.
*
* Part of ZCLAW L4 Self-Evolution capability.
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Users,
Play,
CheckCircle,
XCircle,
Clock,
ChevronDown,
ChevronRight,
Layers,
GitBranch,
MessageSquare,
RefreshCw,
Plus,
History,
Sparkles,
} from 'lucide-react';
import {
AgentSwarm,
type SwarmTask,
type Subtask,
type SwarmTaskStatus,
type CommunicationStyle,
} from '../lib/agent-swarm';
import { useAgentStore } from '../store/agentStore';
// === Types ===
interface SwarmDashboardProps {
className?: string;
onTaskSelect?: (task: SwarmTask) => void;
}
type FilterType = 'all' | 'active' | 'completed' | 'failed';
// === Status Config ===
const TASK_STATUS_CONFIG: Record<SwarmTaskStatus, { label: string; className: string; dotClass: string; icon: typeof CheckCircle }> = {
planning: {
label: '规划中',
className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
dotClass: 'bg-purple-500',
icon: Layers,
},
executing: {
label: '执行中',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
dotClass: 'bg-blue-500 animate-pulse',
icon: Play,
},
aggregating: {
label: '汇总中',
className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
dotClass: 'bg-cyan-500 animate-pulse',
icon: RefreshCw,
},
done: {
label: '已完成',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
dotClass: 'bg-green-500',
icon: CheckCircle,
},
failed: {
label: '失败',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
dotClass: 'bg-red-500',
icon: XCircle,
},
};
const SUBTASK_STATUS_CONFIG: Record<string, { label: string; dotClass: string }> = {
pending: { label: '待执行', dotClass: 'bg-gray-400' },
running: { label: '执行中', dotClass: 'bg-blue-500 animate-pulse' },
done: { label: '完成', dotClass: 'bg-green-500' },
failed: { label: '失败', dotClass: 'bg-red-500' },
};
const COMMUNICATION_STYLE_CONFIG: Record<CommunicationStyle, { label: string; icon: typeof Users; description: string }> = {
sequential: {
label: '顺序执行',
icon: GitBranch,
description: '每个 Agent 依次处理,输出传递给下一个',
},
parallel: {
label: '并行执行',
icon: Layers,
description: '多个 Agent 同时处理不同子任务',
},
debate: {
label: '辩论模式',
icon: MessageSquare,
description: '多个 Agent 提供观点,协调者综合',
},
};
// === Components ===
function TaskStatusBadge({ status }: { status: SwarmTaskStatus }) {
const config = TASK_STATUS_CONFIG[status];
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
<Icon className="w-3 h-3" />
{config.label}
</span>
);
}
function SubtaskStatusDot({ status }: { status: string }) {
const config = SUBTASK_STATUS_CONFIG[status] || SUBTASK_STATUS_CONFIG.pending;
return <span className={`w-2 h-2 rounded-full ${config.dotClass}`} title={config.label} />;
}
function CommunicationStyleBadge({ style }: { style: CommunicationStyle }) {
const config = COMMUNICATION_STYLE_CONFIG[style];
const Icon = config.icon;
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
title={config.description}
>
<Icon className="w-3 h-3" />
{config.label}
</span>
);
}
function SubtaskItem({
subtask,
agentName,
isExpanded,
onToggle,
}: {
subtask: Subtask;
agentName: string;
isExpanded: boolean;
onToggle: () => void;
}) {
const duration = useMemo(() => {
if (!subtask.startedAt) return null;
const start = new Date(subtask.startedAt).getTime();
const end = subtask.completedAt ? new Date(subtask.completedAt).getTime() : Date.now();
return Math.round((end - start) / 1000);
}, [subtask.startedAt, subtask.completedAt]);
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
>
<SubtaskStatusDot status={subtask.status} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{subtask.description}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
: {agentName}
{duration !== null && <span className="ml-2">· {duration}s</span>}
</div>
</div>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
</button>
<AnimatePresence>
{isExpanded && subtask.result && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-gray-200 dark:border-gray-700"
>
<div className="p-3 text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap max-h-40 overflow-y-auto">
{subtask.result}
</div>
</motion.div>
)}
</AnimatePresence>
{subtask.error && (
<div className="px-3 py-2 bg-red-50 dark:bg-red-900/20 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-red-600 dark:text-red-400">{subtask.error}</p>
</div>
)}
</div>
);
}
function TaskCard({
task,
isSelected,
onSelect,
}: {
task: SwarmTask;
isSelected: boolean;
onSelect: () => void;
}) {
const [expandedSubtasks, setExpandedSubtasks] = useState<Set<string>>(new Set());
const { clones } = useAgentStore();
const toggleSubtask = useCallback((subtaskId: string) => {
setExpandedSubtasks((prev) => {
const next = new Set(prev);
if (next.has(subtaskId)) {
next.delete(subtaskId);
} else {
next.add(subtaskId);
}
return next;
});
}, []);
const completedCount = task.subtasks.filter((s) => s.status === 'done').length;
const totalDuration = useMemo(() => {
if (!task.completedAt) return null;
const start = new Date(task.createdAt).getTime();
const end = new Date(task.completedAt).getTime();
return Math.round((end - start) / 1000);
}, [task.createdAt, task.completedAt]);
const getAgentName = (agentId: string) => {
const agent = clones.find((a) => a.id === agentId);
return agent?.name || agentId;
};
return (
<div
className={`border rounded-lg overflow-hidden transition-all ${
isSelected
? 'border-orange-500 dark:border-orange-400 ring-2 ring-orange-500/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<button
onClick={onSelect}
className="w-full p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<TaskStatusBadge status={task.status} />
<CommunicationStyleBadge style={task.communicationStyle} />
</div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{task.description}
</h3>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{completedCount}/{task.subtasks.length}
</span>
{totalDuration !== null && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{totalDuration}s
</span>
)}
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
{isSelected ? (
<ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
)}
</div>
</button>
<AnimatePresence>
{isSelected && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-gray-200 dark:border-gray-700"
>
<div className="p-4 space-y-3">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</h4>
<div className="space-y-2">
{task.subtasks.map((subtask) => (
<SubtaskItem
key={subtask.id}
subtask={subtask}
agentName={getAgentName(subtask.assignedTo)}
isExpanded={expandedSubtasks.has(subtask.id)}
onToggle={() => toggleSubtask(subtask.id)}
/>
))}
</div>
{task.finalResult && (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h4 className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">
</h4>
<p className="text-sm text-green-600 dark:text-green-300 whitespace-pre-wrap">
{task.finalResult}
</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function CreateTaskForm({
onSubmit,
onCancel,
}: {
onSubmit: (description: string, style: CommunicationStyle) => void;
onCancel: () => void;
}) {
const [description, setDescription] = useState('');
const [style, setStyle] = useState<CommunicationStyle>('sequential');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (description.trim()) {
onSubmit(description.trim(), style);
}
};
return (
<form onSubmit={handleSubmit} className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="描述需要协作完成的任务..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(COMMUNICATION_STYLE_CONFIG) as CommunicationStyle[]).map((s) => {
const config = COMMUNICATION_STYLE_CONFIG[s];
const Icon = config.icon;
return (
<button
key={s}
type="button"
onClick={() => setStyle(s)}
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
style === s
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 text-gray-600 dark:text-gray-400'
}`}
>
<Icon className="w-4 h-4" />
<span className="text-xs">{config.label}</span>
</button>
);
})}
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
>
</button>
<button
type="submit"
disabled={!description.trim()}
className="px-4 py-1.5 text-sm bg-orange-500 hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-1.5"
>
<Sparkles className="w-4 h-4" />
</button>
</div>
</form>
);
}
// === Main Component ===
export function SwarmDashboard({ className = '', onTaskSelect }: SwarmDashboardProps) {
const [swarm] = useState(() => new AgentSwarm());
const [tasks, setTasks] = useState<SwarmTask[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [filter, setFilter] = useState<FilterType>('all');
const [showCreateForm, setShowCreateForm] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Load tasks from swarm history
useEffect(() => {
const history = swarm.getHistory();
setTasks([...history].reverse()); // Most recent first
}, [swarm]);
const filteredTasks = useMemo(() => {
switch (filter) {
case 'active':
return tasks.filter((t) => ['planning', 'executing', 'aggregating'].includes(t.status));
case 'completed':
return tasks.filter((t) => t.status === 'done');
case 'failed':
return tasks.filter((t) => t.status === 'failed');
default:
return tasks;
}
}, [tasks, filter]);
const stats = useMemo(() => {
const active = tasks.filter((t) => ['planning', 'executing', 'aggregating'].includes(t.status)).length;
const completed = tasks.filter((t) => t.status === 'done').length;
const failed = tasks.filter((t) => t.status === 'failed').length;
return { total: tasks.length, active, completed, failed };
}, [tasks]);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
// Simulate refresh delay
await new Promise((resolve) => setTimeout(resolve, 500));
const history = swarm.getHistory();
setTasks([...history].reverse());
setIsRefreshing(false);
}, [swarm]);
const handleCreateTask = useCallback(
(description: string, style: CommunicationStyle) => {
const task = swarm.createTask(description, { communicationStyle: style });
setTasks((prev) => [task, ...prev]);
setSelectedTaskId(task.id);
setShowCreateForm(false);
onTaskSelect?.(task);
// Note: Actual execution should be triggered via chatStore.dispatchSwarmTask
console.log('[SwarmDashboard] Task created:', task.id, 'Style:', style);
},
[swarm, onTaskSelect]
);
const handleSelectTask = useCallback(
(taskId: string) => {
setSelectedTaskId((prev) => (prev === taskId ? null : taskId));
const task = tasks.find((t) => t.id === taskId);
if (task && selectedTaskId !== taskId) {
onTaskSelect?.(task);
}
},
[tasks, onTaskSelect, selectedTaskId]
);
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setShowCreateForm((prev) => !prev)}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{/* Stats Bar */}
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 text-xs">
<span className="text-gray-500 dark:text-gray-400">
: <span className="font-medium text-gray-900 dark:text-gray-100">{stats.total}</span>
</span>
<span className="text-blue-600 dark:text-blue-400">
: <span className="font-medium">{stats.active}</span>
</span>
<span className="text-green-600 dark:text-green-400">
: <span className="font-medium">{stats.completed}</span>
</span>
{stats.failed > 0 && (
<span className="text-red-600 dark:text-red-400">
: <span className="font-medium">{stats.failed}</span>
</span>
)}
</div>
{/* Filter Tabs */}
<div className="flex gap-1 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
{(['all', 'active', 'completed', 'failed'] as FilterType[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filter === f
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{f === 'all' ? '全部' : f === 'active' ? '活跃' : f === 'completed' ? '已完成' : '失败'}
</button>
))}
</div>
{/* Create Form */}
<AnimatePresence>
{showCreateForm && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-b border-gray-200 dark:border-gray-700 overflow-hidden"
>
<CreateTaskForm
onSubmit={handleCreateTask}
onCancel={() => setShowCreateForm(false)}
/>
</motion.div>
)}
</AnimatePresence>
{/* Task List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
<History className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">
{filter === 'all'
? '暂无协作任务'
: filter === 'active'
? '暂无活跃任务'
: filter === 'completed'
? '暂无已完成任务'
: '暂无失败任务'}
</p>
<button
onClick={() => setShowCreateForm(true)}
className="mt-2 text-orange-500 hover:text-orange-600 text-sm"
>
</button>
</div>
) : (
filteredTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={() => handleSelectTask(task.id)}
/>
))
)}
</div>
</div>
);
}
export default SwarmDashboard;