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;