Files
zclaw_openfang/desktop/src/components/HeartbeatConfig.tsx
iven 215c079d29
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
Rust 后端 (heartbeat.rs):
- 告警实时推送: OnceLock<AppHandle> + Tauri emit heartbeat:alert
- 动态间隔: tokio::select! + Notify 替代不可变 interval
- Config 持久化: update_config 写入 VikingStorage
- heartbeat_init 从 VikingStorage 恢复 config
- 移除 dead code (subscribe, HeartbeatCheckFn)
- Memory stats fallback 分层处理

新增 health_snapshot.rs:
- HealthSnapshot Tauri 命令 — 按需查询引擎/记忆状态
- 注册到 lib.rs invoke_handler

前端修复:
- HeartbeatConfig handleSave 同步到 Rust 后端
- App.tsx 读 localStorage 持久化配置 + heartbeat:alert 监听 + toast
- saasStore 降级后指数退避探测恢复 + saas-recovered 事件
- 新增 HealthPanel.tsx 只读健康面板 (4卡片 + 告警列表)
- SettingsLayout 添加 health 导航入口

清理:
- 删除 intelligence-client/ 目录版 (9文件 -1640行, 单文件版是活跃代码)
2026-04-15 23:19:24 +08:00

549 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';
import { createLogger } from '../lib/logger';
const log = createLogger('HeartbeatConfig');
// === Default Config ===
const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfigType = {
enabled: true,
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(async () => {
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
// Sync to Rust backend (non-blocking — UI updates immediately)
try {
await intelligenceClient.heartbeat.updateConfig('zclaw-main', config);
} catch (err) {
log.warn('[HeartbeatConfig] Backend sync failed:', err);
}
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;