- Unify all intelligence modules to use intelligenceClient - Delete legacy TS implementations (agent-memory, reflection-engine, heartbeat-engine, context-compactor, agent-identity, memory-index) - Update all consumers to use snake_case backend types - Remove deprecated llm-integration.test.ts This eliminates code duplication between frontend and backend, resolves localStorage limitations, and enables persistent intelligence features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
/**
|
||
* 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 {
|
||
intelligenceClient,
|
||
type HeartbeatConfig as HeartbeatConfigType,
|
||
type HeartbeatResult,
|
||
type HeartbeatAlert,
|
||
} from '../lib/intelligence-client';
|
||
|
||
// === Default Config ===
|
||
|
||
const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfigType = {
|
||
enabled: false,
|
||
interval_minutes: 30,
|
||
quiet_hours_start: null,
|
||
quiet_hours_end: null,
|
||
notify_channel: 'ui',
|
||
proactivity_level: 'standard',
|
||
max_alerts_per_tick: 5,
|
||
};
|
||
|
||
// === 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 {
|
||
await intelligenceClient.heartbeat.init('zclaw-main', config);
|
||
const result = await intelligenceClient.heartbeat.tick('zclaw-main');
|
||
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.interval_minutes}
|
||
onChange={(e) => updateConfig({ interval_minutes: 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.interval_minutes} 分钟
|
||
</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.proactivity_level}
|
||
onChange={(level) => updateConfig({ proactivity_level: level })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quiet Hours */}
|
||
<div className="space-y-2">
|
||
<QuietHoursConfig
|
||
start={config.quiet_hours_start ?? undefined}
|
||
end={config.quiet_hours_end ?? undefined}
|
||
enabled={!!config.quiet_hours_start}
|
||
onStartChange={(time) => updateConfig({ quiet_hours_start: time })}
|
||
onEndChange={(time) => updateConfig({ quiet_hours_end: time })}
|
||
onToggle={(enabled) =>
|
||
updateConfig({
|
||
quiet_hours_start: enabled ? '22:00' : null,
|
||
quiet_hours_end: enabled ? '08:00' : null,
|
||
})
|
||
}
|
||
/>
|
||
</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.checked_items} 项
|
||
{lastResult.alerts.length > 0 && ` · ${lastResult.alerts.length} 个提醒`}
|
||
</div>
|
||
{lastResult.alerts.length > 0 && (
|
||
<div className="mt-2 space-y-1">
|
||
{lastResult.alerts.map((alert: HeartbeatAlert, i: number) => (
|
||
<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;
|