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:
iven
2026-03-16 10:24:00 +08:00
parent 721e400bd0
commit 85e39ecafd
4 changed files with 2199 additions and 0 deletions

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

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

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

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