feat(l4): add Phase 1 UI components for self-evolution capability
SwarmDashboard (多 Agent 协作面板): - Task list with real-time status updates - Subtask visualization with results - Communication style indicators (Sequential/Parallel/Debate) - Task creation form with manual triggers SkillMarket (技能市场): - Browse 12 built-in skills by category - Keyword/capability search - Skill details with triggers and capabilities - Install/uninstall with L4 autonomy hooks HeartbeatConfig (心跳配置): - Enable/disable periodic proactive checks - Interval slider (5-120 minutes) - Proactivity level selector (Silent/Light/Standard/Autonomous) - Quiet hours configuration - Built-in check item toggles ReflectionLog (反思日志): - Reflection history with pattern analysis - Improvement suggestions by priority - Identity change proposal approval workflow - Manual reflection trigger - Config panel for trigger settings Part of ZCLAW L4 Self-Evolution capability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
527
desktop/src/components/HeartbeatConfig.tsx
Normal file
527
desktop/src/components/HeartbeatConfig.tsx
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
/**
|
||||||
|
* HeartbeatConfig - Configuration UI for periodic proactive checks
|
||||||
|
*
|
||||||
|
* Allows users to configure:
|
||||||
|
* - Heartbeat interval (default 30 minutes)
|
||||||
|
* - Enable/disable built-in check items
|
||||||
|
* - Quiet hours (no notifications during sleep time)
|
||||||
|
* - Proactivity level (silent/light/standard/autonomous)
|
||||||
|
*
|
||||||
|
* Part of ZCLAW L4 Self-Evolution capability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Heart,
|
||||||
|
Settings,
|
||||||
|
Clock,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Info,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
HeartbeatEngine,
|
||||||
|
DEFAULT_HEARTBEAT_CONFIG,
|
||||||
|
type HeartbeatConfig as HeartbeatConfigType,
|
||||||
|
type HeartbeatResult,
|
||||||
|
} from '../lib/heartbeat-engine';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
interface HeartbeatConfigProps {
|
||||||
|
className?: string;
|
||||||
|
onConfigChange?: (config: HeartbeatConfigType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProactivityLevel = 'silent' | 'light' | 'standard' | 'autonomous';
|
||||||
|
|
||||||
|
// === Proactivity Level Config ===
|
||||||
|
|
||||||
|
const PROACTIVITY_CONFIG: Record<ProactivityLevel, { label: string; description: string; icon: typeof Moon }> = {
|
||||||
|
silent: {
|
||||||
|
label: '静默',
|
||||||
|
description: '从不主动推送,仅被动响应',
|
||||||
|
icon: VolumeX,
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
label: '轻度',
|
||||||
|
description: '仅紧急事项推送(如定时任务完成)',
|
||||||
|
icon: Volume2,
|
||||||
|
},
|
||||||
|
standard: {
|
||||||
|
label: '标准',
|
||||||
|
description: '定期巡检 + 任务通知 + 建议推送',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
autonomous: {
|
||||||
|
label: '自主',
|
||||||
|
description: 'Agent 自行判断何时推送',
|
||||||
|
icon: Heart,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Check Item Config ===
|
||||||
|
|
||||||
|
interface CheckItemConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILT_IN_CHECKS: CheckItemConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'pending-tasks',
|
||||||
|
name: '待办任务检查',
|
||||||
|
description: '检查是否有未完成的任务需要跟进',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'memory-health',
|
||||||
|
name: '记忆健康检查',
|
||||||
|
description: '检查记忆存储是否过大需要清理',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'idle-greeting',
|
||||||
|
name: '空闲问候',
|
||||||
|
description: '长时间未使用时发送简短问候',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// === Components ===
|
||||||
|
|
||||||
|
function ProactivityLevelSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: ProactivityLevel;
|
||||||
|
onChange: (level: ProactivityLevel) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(Object.keys(PROACTIVITY_CONFIG) as ProactivityLevel[]).map((level) => {
|
||||||
|
const config = PROACTIVITY_CONFIG[level];
|
||||||
|
const Icon = config.icon;
|
||||||
|
const isSelected = value === level;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => onChange(level)}
|
||||||
|
className={`flex items-start gap-2 p-3 rounded-lg border transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
|
||||||
|
isSelected ? 'text-purple-500' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isSelected ? 'text-purple-700 dark:text-purple-400' : 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{config.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuietHoursConfig({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onStartChange,
|
||||||
|
onEndChange,
|
||||||
|
enabled,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
onStartChange: (time: string) => void;
|
||||||
|
onEndChange: (time: string) => void;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Moon className="w-4 h-4 text-indigo-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">免打扰时段</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(!enabled)}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||||
|
enabled ? 'bg-purple-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ x: enabled ? 20 : 0 }}
|
||||||
|
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{enabled && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="flex items-center gap-3 pl-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sun className="w-3 h-3 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={end || '08:00'}
|
||||||
|
onChange={(e) => onEndChange(e.target.value)}
|
||||||
|
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">至</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Moon className="w-3 h-3 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={start || '22:00'}
|
||||||
|
onChange={(e) => onStartChange(e.target.value)}
|
||||||
|
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckItemToggle({
|
||||||
|
item,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: CheckItemConfig;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(!item.enabled)}
|
||||||
|
className={`relative w-9 h-5 rounded-full transition-colors ${
|
||||||
|
item.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ x: item.enabled ? 18 : 0 }}
|
||||||
|
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Main Component ===
|
||||||
|
|
||||||
|
export function HeartbeatConfig({ className = '', onConfigChange }: HeartbeatConfigProps) {
|
||||||
|
const [config, setConfig] = useState<HeartbeatConfigType>(DEFAULT_HEARTBEAT_CONFIG);
|
||||||
|
const [checkItems, setCheckItems] = useState<CheckItemConfig[]>(BUILT_IN_CHECKS);
|
||||||
|
const [lastResult, setLastResult] = useState<HeartbeatResult | null>(null);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// Load saved config
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('zclaw-heartbeat-config');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
setConfig({ ...DEFAULT_HEARTBEAT_CONFIG, ...parsed });
|
||||||
|
} catch {
|
||||||
|
// Use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedChecks = localStorage.getItem('zclaw-heartbeat-checks');
|
||||||
|
if (savedChecks) {
|
||||||
|
try {
|
||||||
|
setCheckItems(JSON.parse(savedChecks));
|
||||||
|
} catch {
|
||||||
|
// Use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateConfig = useCallback(
|
||||||
|
(updates: Partial<HeartbeatConfigType>) => {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev, ...updates };
|
||||||
|
setHasChanges(true);
|
||||||
|
onConfigChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onConfigChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCheckItem = useCallback((id: string, enabled: boolean) => {
|
||||||
|
setCheckItems((prev) => {
|
||||||
|
const next = prev.map((item) =>
|
||||||
|
item.id === id ? { ...item, enabled } : item
|
||||||
|
);
|
||||||
|
setHasChanges(true);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
|
||||||
|
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
|
||||||
|
setHasChanges(false);
|
||||||
|
}, [config, checkItems]);
|
||||||
|
|
||||||
|
const handleTestHeartbeat = useCallback(async () => {
|
||||||
|
setIsTesting(true);
|
||||||
|
try {
|
||||||
|
const engine = new HeartbeatEngine('zclaw-main', config);
|
||||||
|
const result = await engine.tick();
|
||||||
|
setLastResult(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HeartbeatConfig] Test failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Heart className="w-5 h-5 text-pink-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={handleTestHeartbeat}
|
||||||
|
disabled={isTesting || !config.enabled}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isTesting ? 'animate-spin' : ''}`} />
|
||||||
|
测试
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
className="px-3 py-1.5 text-sm bg-pink-500 hover:bg-pink-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
|
{/* Enable Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-pink-100 dark:bg-pink-900/30'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
config.enabled ? 'text-pink-500' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
启用主动巡检
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Agent 将定期检查并主动推送通知
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateConfig({ enabled: !config.enabled })}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||||
|
config.enabled ? 'bg-pink-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ x: config.enabled ? 26 : 0 }}
|
||||||
|
className="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{config.enabled && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Interval */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
巡检间隔
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pl-6">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="5"
|
||||||
|
max="120"
|
||||||
|
step="5"
|
||||||
|
value={config.intervalMinutes}
|
||||||
|
onChange={(e) => updateConfig({ intervalMinutes: parseInt(e.target.value) })}
|
||||||
|
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-pink-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 w-16 text-right">
|
||||||
|
{config.intervalMinutes} 分钟
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Proactivity Level */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
主动性级别
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6">
|
||||||
|
<ProactivityLevelSelector
|
||||||
|
value={config.proactivityLevel}
|
||||||
|
onChange={(level) => updateConfig({ proactivityLevel: level })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quiet Hours */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<QuietHoursConfig
|
||||||
|
start={config.quietHoursStart}
|
||||||
|
end={config.quietHoursEnd}
|
||||||
|
enabled={!!config.quietHoursStart}
|
||||||
|
onStartChange={(time) => updateConfig({ quietHoursStart: time })}
|
||||||
|
onEndChange={(time) => updateConfig({ quietHoursEnd: time })}
|
||||||
|
onToggle={(enabled) =>
|
||||||
|
updateConfig({
|
||||||
|
quietHoursStart: enabled ? '22:00' : undefined,
|
||||||
|
quietHoursEnd: enabled ? '08:00' : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Check Items */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
检查项目
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 space-y-1 border-l-2 border-gray-200 dark:border-gray-700">
|
||||||
|
{checkItems.map((item) => (
|
||||||
|
<CheckItemToggle
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onToggle={(enabled) => toggleCheckItem(item.id, enabled)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Result */}
|
||||||
|
{lastResult && (
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{lastResult.status === 'ok' ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
上次测试结果
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
检查了 {lastResult.checkedItems} 项
|
||||||
|
{lastResult.alerts.length > 0 && ` · ${lastResult.alerts.length} 个提醒`}
|
||||||
|
</div>
|
||||||
|
{lastResult.alerts.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{lastResult.alerts.map((alert, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`text-xs p-2 rounded ${
|
||||||
|
alert.urgency === 'high'
|
||||||
|
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400'
|
||||||
|
: alert.urgency === 'medium'
|
||||||
|
? 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{alert.title}:</span> {alert.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
心跳机制让 Agent 具备主动意识,能够定期检查任务状态、记忆健康度等,并根据主动性级别推送通知。
|
||||||
|
在"自主"模式下,Agent 将自行判断是否需要通知你。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HeartbeatConfig;
|
||||||
608
desktop/src/components/ReflectionLog.tsx
Normal file
608
desktop/src/components/ReflectionLog.tsx
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
/**
|
||||||
|
* ReflectionLog - Self-reflection history and identity change approval UI
|
||||||
|
*
|
||||||
|
* Displays:
|
||||||
|
* - Reflection history (patterns, improvements)
|
||||||
|
* - Pending identity change proposals
|
||||||
|
* - Approve/reject identity modifications
|
||||||
|
* - Manual reflection trigger
|
||||||
|
*
|
||||||
|
* Part of ZCLAW L4 Self-Evolution capability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Clock,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
RefreshCw,
|
||||||
|
AlertTriangle,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Minus,
|
||||||
|
FileText,
|
||||||
|
History,
|
||||||
|
Play,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ReflectionEngine,
|
||||||
|
type ReflectionResult,
|
||||||
|
type PatternObservation,
|
||||||
|
type ImprovementSuggestion,
|
||||||
|
type ReflectionConfig,
|
||||||
|
DEFAULT_REFLECTION_CONFIG,
|
||||||
|
} from '../lib/reflection-engine';
|
||||||
|
import { getAgentIdentityManager, type IdentityChangeProposal } from '../lib/agent-identity';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
interface ReflectionLogProps {
|
||||||
|
className?: string;
|
||||||
|
agentId?: string;
|
||||||
|
onProposalApprove?: (proposal: IdentityChangeProposal) => void;
|
||||||
|
onProposalReject?: (proposal: IdentityChangeProposal) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Sentiment Config ===
|
||||||
|
|
||||||
|
const SENTIMENT_CONFIG: Record<string, { icon: typeof TrendingUp; color: string; bgColor: string }> = {
|
||||||
|
positive: {
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: 'text-green-600 dark:text-green-400',
|
||||||
|
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||||
|
},
|
||||||
|
negative: {
|
||||||
|
icon: TrendingDown,
|
||||||
|
color: 'text-red-600 dark:text-red-400',
|
||||||
|
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
icon: Minus,
|
||||||
|
color: 'text-gray-600 dark:text-gray-400',
|
||||||
|
bgColor: 'bg-gray-100 dark:bg-gray-800',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Priority Config ===
|
||||||
|
|
||||||
|
const PRIORITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
|
||||||
|
high: {
|
||||||
|
color: 'text-red-600 dark:text-red-400',
|
||||||
|
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
color: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
|
},
|
||||||
|
low: {
|
||||||
|
color: 'text-blue-600 dark:text-blue-400',
|
||||||
|
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Components ===
|
||||||
|
|
||||||
|
function SentimentBadge({ sentiment }: { sentiment: string }) {
|
||||||
|
const config = SENTIMENT_CONFIG[sentiment] || SENTIMENT_CONFIG.neutral;
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{sentiment === 'positive' ? '积极' : sentiment === 'negative' ? '消极' : '中性'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriorityBadge({ priority }: { priority: string }) {
|
||||||
|
const config = PRIORITY_CONFIG[priority] || PRIORITY_CONFIG.medium;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bgColor} ${config.color}`}
|
||||||
|
>
|
||||||
|
{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatternCard({ pattern }: { pattern: PatternObservation }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-start gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<SentimentBadge sentiment={pattern.sentiment} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-900 dark:text-gray-100">{pattern.observation}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
出现频率: {pattern.frequency} 次
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded && pattern.evidence.length > 0 && (
|
||||||
|
<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 p-3 bg-gray-50 dark:bg-gray-800/30"
|
||||||
|
>
|
||||||
|
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">证据</h5>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{pattern.evidence.map((ev, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 dark:text-gray-300 pl-2 border-l-2 border-gray-300 dark:border-gray-600">
|
||||||
|
{ev}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImprovementCard({ improvement }: { improvement: ImprovementSuggestion }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{improvement.area}
|
||||||
|
</span>
|
||||||
|
<PriorityBadge priority={improvement.priority} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">{improvement.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProposalCard({
|
||||||
|
proposal,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
proposal: IdentityChangeProposal;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const identityManager = getAgentIdentityManager();
|
||||||
|
|
||||||
|
const fileName = proposal.filePath.split('/').pop() || proposal.filePath;
|
||||||
|
const fileType = fileName.toLowerCase().replace('.md', '').toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-yellow-300 dark:border-yellow-700 rounded-lg overflow-hidden bg-yellow-50 dark:bg-yellow-900/20">
|
||||||
|
<div className="flex items-center gap-3 p-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-yellow-100 dark:bg-yellow-800 flex items-center justify-center">
|
||||||
|
<FileText className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{fileType} 变更提议
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 text-xs bg-yellow-200 dark:bg-yellow-800 text-yellow-700 dark:text-yellow-300 rounded">
|
||||||
|
待审批
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-yellow-600 dark:text-yellow-400 truncate">
|
||||||
|
{proposal.reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="p-1 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-100 dark:hover:bg-yellow-800 rounded"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="border-t border-yellow-200 dark:border-yellow-700"
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-xs font-medium text-yellow-700 dark:text-yellow-300 mb-1">
|
||||||
|
当前内容
|
||||||
|
</h5>
|
||||||
|
<pre className="text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{proposal.currentContent.slice(0, 500)}
|
||||||
|
{proposal.currentContent.length > 500 && '...'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="text-xs font-medium text-yellow-700 dark:text-yellow-300 mb-1">
|
||||||
|
建议内容
|
||||||
|
</h5>
|
||||||
|
<pre className="text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{proposal.proposedContent.slice(0, 500)}
|
||||||
|
{proposal.proposedContent.length > 500 && '...'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 p-3 border-t border-yellow-200 dark:border-yellow-700">
|
||||||
|
<button
|
||||||
|
onClick={onReject}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onApprove}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
批准
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReflectionEntry({
|
||||||
|
result,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
result: ReflectionResult;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const positivePatterns = result.patterns.filter((p) => p.sentiment === 'positive').length;
|
||||||
|
const negativePatterns = result.patterns.filter((p) => p.sentiment === 'negative').length;
|
||||||
|
const highPriorityImprovements = result.improvements.filter((i) => i.priority === 'high').length;
|
||||||
|
|
||||||
|
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 p-4 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
|
<Brain className="w-5 h-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
自我反思
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{new Date(result.timestamp).toLocaleString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
{positivePatterns} 积极
|
||||||
|
</span>
|
||||||
|
<span className="text-red-600 dark:text-red-400">
|
||||||
|
{negativePatterns} 消极
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{result.improvements.length} 建议
|
||||||
|
</span>
|
||||||
|
{result.identityProposals.length > 0 && (
|
||||||
|
<span className="text-yellow-600 dark:text-yellow-400">
|
||||||
|
{result.identityProposals.length} 变更
|
||||||
|
</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 && (
|
||||||
|
<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-4">
|
||||||
|
{/* Patterns */}
|
||||||
|
{result.patterns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
行为模式
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{result.patterns.map((pattern, i) => (
|
||||||
|
<PatternCard key={i} pattern={pattern} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Improvements */}
|
||||||
|
{result.improvements.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
改进建议
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{result.improvements.map((improvement, i) => (
|
||||||
|
<ImprovementCard key={i} improvement={improvement} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span>新增记忆: {result.newMemories}</span>
|
||||||
|
<span>身份变更提议: {result.identityProposals.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Main Component ===
|
||||||
|
|
||||||
|
export function ReflectionLog({
|
||||||
|
className = '',
|
||||||
|
agentId = 'zclaw-main',
|
||||||
|
onProposalApprove,
|
||||||
|
onProposalReject,
|
||||||
|
}: ReflectionLogProps) {
|
||||||
|
const [engine] = useState(() => new ReflectionEngine());
|
||||||
|
const [history, setHistory] = useState<ReflectionResult[]>([]);
|
||||||
|
const [pendingProposals, setPendingProposals] = useState<IdentityChangeProposal[]>([]);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [isReflecting, setIsReflecting] = useState(false);
|
||||||
|
const [config, setConfig] = useState<ReflectionConfig>(DEFAULT_REFLECTION_CONFIG);
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
|
||||||
|
// Load history and pending proposals
|
||||||
|
useEffect(() => {
|
||||||
|
const loadedHistory = engine.getHistory();
|
||||||
|
setHistory([...loadedHistory].reverse()); // Most recent first
|
||||||
|
|
||||||
|
const identityManager = getAgentIdentityManager();
|
||||||
|
const proposals = identityManager.getPendingProposals(agentId);
|
||||||
|
setPendingProposals(proposals);
|
||||||
|
}, [engine, agentId]);
|
||||||
|
|
||||||
|
const handleReflect = useCallback(async () => {
|
||||||
|
setIsReflecting(true);
|
||||||
|
try {
|
||||||
|
const result = await engine.reflect(agentId);
|
||||||
|
setHistory((prev) => [result, ...prev]);
|
||||||
|
|
||||||
|
// Update pending proposals
|
||||||
|
if (result.identityProposals.length > 0) {
|
||||||
|
setPendingProposals((prev) => [...prev, ...result.identityProposals]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ReflectionLog] Reflection failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsReflecting(false);
|
||||||
|
}
|
||||||
|
}, [engine, agentId]);
|
||||||
|
|
||||||
|
const handleApproveProposal = useCallback(
|
||||||
|
(proposal: IdentityChangeProposal) => {
|
||||||
|
const identityManager = getAgentIdentityManager();
|
||||||
|
identityManager.approveChange(proposal.id);
|
||||||
|
setPendingProposals((prev) => prev.filter((p) => p.id !== proposal.id));
|
||||||
|
onProposalApprove?.(proposal);
|
||||||
|
},
|
||||||
|
[onProposalApprove]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRejectProposal = useCallback(
|
||||||
|
(proposal: IdentityChangeProposal) => {
|
||||||
|
const identityManager = getAgentIdentityManager();
|
||||||
|
identityManager.rejectChange(proposal.id);
|
||||||
|
setPendingProposals((prev) => prev.filter((p) => p.id !== proposal.id));
|
||||||
|
onProposalReject?.(proposal);
|
||||||
|
},
|
||||||
|
[onProposalReject]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const totalReflections = history.length;
|
||||||
|
const totalPatterns = history.reduce((sum, r) => sum + r.patterns.length, 0);
|
||||||
|
const totalImprovements = history.reduce((sum, r) => sum + r.improvements.length, 0);
|
||||||
|
const totalIdentityChanges = history.reduce((sum, r) => sum + r.identityProposals.length, 0);
|
||||||
|
return { totalReflections, totalPatterns, totalImprovements, totalIdentityChanges };
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Brain className="w-5 h-5 text-purple-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={() => setShowConfig(!showConfig)}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
title="配置"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReflect}
|
||||||
|
disabled={isReflecting}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-purple-500 hover:bg-purple-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isReflecting ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play 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.totalReflections}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-purple-600 dark:text-purple-400">
|
||||||
|
模式: <span className="font-medium">{stats.totalPatterns}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">
|
||||||
|
建议: <span className="font-medium">{stats.totalImprovements}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-yellow-600 dark:text-yellow-400">
|
||||||
|
变更: <span className="font-medium">{stats.totalIdentityChanges}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showConfig && (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">对话后触发反思</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={config.triggerAfterConversations}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig((prev) => ({ ...prev, triggerAfterConversations: parseInt(e.target.value) || 5 }))
|
||||||
|
}
|
||||||
|
className="w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">允许修改 SOUL.md</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfig((prev) => ({ ...prev, allowSoulModification: !prev.allowSoulModification }))}
|
||||||
|
className={`relative w-9 h-5 rounded-full transition-colors ${
|
||||||
|
config.allowSoulModification ? 'bg-purple-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ x: config.allowSoulModification ? 18 : 0 }}
|
||||||
|
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">变更需审批</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfig((prev) => ({ ...prev, requireApproval: !prev.requireApproval }))}
|
||||||
|
className={`relative w-9 h-5 rounded-full transition-colors ${
|
||||||
|
config.requireApproval ? 'bg-purple-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ x: config.requireApproval ? 18 : 0 }}
|
||||||
|
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Pending Proposals */}
|
||||||
|
{pendingProposals.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-yellow-700 dark:text-yellow-300">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
待审批变更 ({pendingProposals.length})
|
||||||
|
</h3>
|
||||||
|
{pendingProposals.map((proposal) => (
|
||||||
|
<ProposalCard
|
||||||
|
key={proposal.id}
|
||||||
|
proposal={proposal}
|
||||||
|
onApprove={() => handleApproveProposal(proposal)}
|
||||||
|
onReject={() => handleRejectProposal(proposal)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
反思历史
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<Brain className="w-8 h-8 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">暂无反思记录</p>
|
||||||
|
<button
|
||||||
|
onClick={handleReflect}
|
||||||
|
className="mt-2 text-purple-500 hover:text-purple-600 text-sm"
|
||||||
|
>
|
||||||
|
触发第一次反思
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
history.map((result, i) => (
|
||||||
|
<ReflectionEntry
|
||||||
|
key={result.timestamp}
|
||||||
|
result={result}
|
||||||
|
isExpanded={expandedId === result.timestamp}
|
||||||
|
onToggle={() => setExpandedId((prev) => (prev === result.timestamp ? null : result.timestamp))}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReflectionLog;
|
||||||
473
desktop/src/components/SkillMarket.tsx
Normal file
473
desktop/src/components/SkillMarket.tsx
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
/**
|
||||||
|
* SkillMarket - Skill browsing, search, and management UI
|
||||||
|
*
|
||||||
|
* Displays available skills (12 built-in + custom), allows users to:
|
||||||
|
* - Browse skills by category
|
||||||
|
* - Search skills by keyword/capability
|
||||||
|
* - View skill details and capabilities
|
||||||
|
* - Install/uninstall skills (with L4 autonomy integration)
|
||||||
|
*
|
||||||
|
* Part of ZCLAW L4 Self-Evolution capability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Package,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Sparkles,
|
||||||
|
Tag,
|
||||||
|
Layers,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
RefreshCw,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
SkillDiscoveryEngine,
|
||||||
|
type SkillInfo,
|
||||||
|
type SkillSuggestion,
|
||||||
|
} from '../lib/skill-discovery';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
interface SkillMarketProps {
|
||||||
|
className?: string;
|
||||||
|
onSkillInstall?: (skill: SkillInfo) => void;
|
||||||
|
onSkillUninstall?: (skill: SkillInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryFilter = 'all' | 'development' | 'security' | 'analytics' | 'content' | 'ops' | 'management' | 'testing' | 'business' | 'marketing';
|
||||||
|
|
||||||
|
// === Category Config ===
|
||||||
|
|
||||||
|
const CATEGORY_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||||
|
development: { label: '开发', color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/30' },
|
||||||
|
security: { label: '安全', color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/30' },
|
||||||
|
analytics: { label: '分析', color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30' },
|
||||||
|
content: { label: '内容', color: 'text-pink-600 dark:text-pink-400', bgColor: 'bg-pink-100 dark:bg-pink-900/30' },
|
||||||
|
ops: { label: '运维', color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-900/30' },
|
||||||
|
management: { label: '管理', color: 'text-cyan-600 dark:text-cyan-400', bgColor: 'bg-cyan-100 dark:bg-cyan-900/30' },
|
||||||
|
testing: { label: '测试', color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-100 dark:bg-green-900/30' },
|
||||||
|
business: { label: '商业', color: 'text-yellow-600 dark:text-yellow-400', bgColor: 'bg-yellow-100 dark:bg-yellow-900/30' },
|
||||||
|
marketing: { label: '营销', color: 'text-indigo-600 dark:text-indigo-400', bgColor: 'bg-indigo-100 dark:bg-indigo-900/30' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Components ===
|
||||||
|
|
||||||
|
function CategoryBadge({ category }: { category?: string }) {
|
||||||
|
if (!category) return null;
|
||||||
|
const config = CATEGORY_CONFIG[category] || {
|
||||||
|
label: category,
|
||||||
|
color: 'text-gray-600 dark:text-gray-400',
|
||||||
|
bgColor: 'bg-gray-100 dark:bg-gray-800',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${config.bgColor} ${config.color}`}>
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillCard({
|
||||||
|
skill,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onInstall,
|
||||||
|
onUninstall,
|
||||||
|
}: {
|
||||||
|
skill: SkillInfo;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onInstall: () => void;
|
||||||
|
onUninstall: () => void;
|
||||||
|
}) {
|
||||||
|
const config = CATEGORY_CONFIG[skill.category || ''] || CATEGORY_CONFIG.development;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border rounded-lg overflow-hidden transition-all ${
|
||||||
|
skill.installed
|
||||||
|
? 'border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-900/10'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/30 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">
|
||||||
|
<Package className={`w-4 h-4 ${config.color}`} />
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{skill.name}
|
||||||
|
</h3>
|
||||||
|
{skill.installed && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
已安装
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<CategoryBadge category={skill.category} />
|
||||||
|
{skill.capabilities.slice(0, 3).map((cap) => (
|
||||||
|
<span
|
||||||
|
key={cap}
|
||||||
|
className="px-1.5 py-0.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded"
|
||||||
|
>
|
||||||
|
{cap}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{skill.capabilities.length > 3 && (
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
+{skill.capabilities.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<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-4">
|
||||||
|
{/* Triggers */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
触发词
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{skill.triggers.map((trigger) => (
|
||||||
|
<span
|
||||||
|
key={trigger}
|
||||||
|
className="px-2 py-0.5 text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded"
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capabilities */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
能力
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{skill.capabilities.map((cap) => (
|
||||||
|
<span
|
||||||
|
key={cap}
|
||||||
|
className="px-2 py-0.5 text-xs bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400 rounded"
|
||||||
|
>
|
||||||
|
{cap}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Dependencies */}
|
||||||
|
{skill.toolDeps.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
工具依赖
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{skill.toolDeps.map((dep) => (
|
||||||
|
<span
|
||||||
|
key={dep}
|
||||||
|
className="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded font-mono"
|
||||||
|
>
|
||||||
|
{dep}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
{skill.installed ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUninstall();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
卸载
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onInstall();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
安装
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestionCard({ suggestion }: { suggestion: SkillSuggestion }) {
|
||||||
|
const confidencePercent = Math.round(suggestion.confidence * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{suggestion.skill.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400 ml-auto">
|
||||||
|
{confidencePercent}% 匹配
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-300 mb-2">{suggestion.reason}</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{suggestion.matchedPatterns.map((pattern) => (
|
||||||
|
<span
|
||||||
|
key={pattern}
|
||||||
|
className="px-1.5 py-0.5 text-xs bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded"
|
||||||
|
>
|
||||||
|
{pattern}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Main Component ===
|
||||||
|
|
||||||
|
export function SkillMarket({
|
||||||
|
className = '',
|
||||||
|
onSkillInstall,
|
||||||
|
onSkillUninstall,
|
||||||
|
}: SkillMarketProps) {
|
||||||
|
const [engine] = useState(() => new SkillDiscoveryEngine());
|
||||||
|
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||||
|
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
||||||
|
const [suggestions, setSuggestions] = useState<SkillSuggestion[]>([]);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Load skills
|
||||||
|
useEffect(() => {
|
||||||
|
const allSkills = engine.getAllSkills();
|
||||||
|
setSkills(allSkills);
|
||||||
|
}, [engine]);
|
||||||
|
|
||||||
|
// Filter skills
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
let result = skills;
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if (categoryFilter !== 'all') {
|
||||||
|
result = result.filter((s) => s.category === categoryFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const searchResult = engine.searchSkills(searchQuery);
|
||||||
|
const matchingIds = new Set(searchResult.results.map((s) => s.id));
|
||||||
|
result = result.filter((s) => matchingIds.has(s.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [skills, categoryFilter, searchQuery, engine]);
|
||||||
|
|
||||||
|
// Get categories from skills
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = new Set(skills.map((s) => s.category).filter(Boolean));
|
||||||
|
return ['all', ...Array.from(cats)] as CategoryFilter[];
|
||||||
|
}, [skills]);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const installed = skills.filter((s) => s.installed).length;
|
||||||
|
return { total: skills.length, installed };
|
||||||
|
}, [skills]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
engine.refreshIndex();
|
||||||
|
setSkills(engine.getAllSkills());
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}, [engine]);
|
||||||
|
|
||||||
|
const handleInstall = useCallback(
|
||||||
|
(skill: SkillInfo) => {
|
||||||
|
engine.installSkill(skill.id);
|
||||||
|
setSkills(engine.getAllSkills());
|
||||||
|
onSkillInstall?.(skill);
|
||||||
|
},
|
||||||
|
[engine, onSkillInstall]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUninstall = useCallback(
|
||||||
|
(skill: SkillInfo) => {
|
||||||
|
engine.uninstallSkill(skill.id);
|
||||||
|
setSkills(engine.getAllSkills());
|
||||||
|
onSkillUninstall?.(skill);
|
||||||
|
},
|
||||||
|
[engine, onSkillUninstall]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (query.trim()) {
|
||||||
|
// Get suggestions based on search
|
||||||
|
const mockConversation = [{ role: 'user' as const, content: query }];
|
||||||
|
const newSuggestions = engine.suggestSkills(mockConversation);
|
||||||
|
setSuggestions(newSuggestions.slice(0, 3));
|
||||||
|
} else {
|
||||||
|
setSuggestions([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[engine]
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Package className="w-5 h-5 text-purple-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>
|
||||||
|
</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-green-600 dark:text-green-400">
|
||||||
|
已安装: <span className="font-medium">{stats.installed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
placeholder="搜索技能、能力、触发词..."
|
||||||
|
className="w-full pl-9 pr-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-purple-500 focus:border-transparent text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="mt-3 space-y-2"
|
||||||
|
>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
推荐技能
|
||||||
|
</h4>
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<SuggestionCard key={suggestion.skill.id} suggestion={suggestion} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="flex gap-1 px-4 py-2 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setCategoryFilter(cat)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap transition-colors ${
|
||||||
|
categoryFilter === cat
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat === 'all' ? '全部' : CATEGORY_CONFIG[cat]?.label || cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skill List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{filteredSkills.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
<Layers className="w-8 h-8 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{searchQuery ? '未找到匹配的技能' : '暂无技能'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSkills.map((skill) => (
|
||||||
|
<SkillCard
|
||||||
|
key={skill.id}
|
||||||
|
skill={skill}
|
||||||
|
isExpanded={expandedSkillId === skill.id}
|
||||||
|
onToggle={() => setExpandedSkillId((prev) => (prev === skill.id ? null : skill.id))}
|
||||||
|
onInstall={() => handleInstall(skill)}
|
||||||
|
onUninstall={() => handleUninstall(skill)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkillMarket;
|
||||||
591
desktop/src/components/SwarmDashboard.tsx
Normal file
591
desktop/src/components/SwarmDashboard.tsx
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
Pause,
|
||||||
|
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 { agents } = 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 = agents.find((a) => a.id === agentId);
|
||||||
|
return agent?.name || agentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border rounded-lg overflow-hidden transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-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-blue-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-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-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-blue-500 hover:bg-blue-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-blue-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-blue-500 hover:bg-blue-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-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-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-blue-500 hover:text-blue-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;
|
||||||
Reference in New Issue
Block a user