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;
|
||||
Reference in New Issue
Block a user