Files
zclaw_openfang/desktop/src/components/HeartbeatConfig.tsx
iven f3ec3c8d4c feat(intelligence): complete migration to Rust backend
- 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>
2026-03-21 15:17:39 +08:00

540 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;