From 5599c1a4dbe65d57b634ffd05bb8de3354b75daa Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 15 Mar 2026 01:38:34 +0800 Subject: [PATCH] feat(phase2): complete P1 tasks - Channels, Triggers, Skills CRUD and UI enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 P1 Tasks Completed: API Layer (gateway-client.ts, gatewayStore.ts): - Add Channels CRUD: getChannel, createChannel, updateChannel, deleteChannel - Add Triggers CRUD: getTrigger, createTrigger, updateTrigger, deleteTrigger - Add Skills CRUD: getSkill, createSkill, updateSkill, deleteSkill - Add Scheduled Tasks API: createScheduledTask, deleteScheduledTask, toggleScheduledTask - Add loadModels action for dynamic model list UI Components: - ModelsAPI.tsx: Dynamic model loading from API with loading/error states - SchedulerPanel.tsx: Full CreateJobModal with cron/interval/once scheduling - SecurityStatus.tsx: Loading states, error handling, retry functionality - WorkflowEditor.tsx: New workflow creation/editing modal (new file) - WorkflowHistory.tsx: Workflow execution history viewer (new file) - WorkflowList.tsx: Integrated editor and history access Configuration: - Add 4 Hands TOML configs: clip, collector, predictor, twitter Documentation (SYSTEM_ANALYSIS.md): - Update API coverage: 65% → 89% (53/62 endpoints) - Update UI completion: 85% → 92% - Mark Phase 2 P1 tasks as completed - Update technical debt cleanup status Co-Authored-By: Claude Opus 4.6 --- desktop/src/components/HandTaskPanel.tsx | 131 +-- desktop/src/components/SchedulerPanel.tsx | 809 +++++++++++++++--- desktop/src/components/SecurityStatus.tsx | 55 +- desktop/src/components/Settings/ModelsAPI.tsx | 163 +++- desktop/src/components/WorkflowEditor.tsx | 437 ++++++++++ desktop/src/components/WorkflowHistory.tsx | 277 ++++++ desktop/src/components/WorkflowList.tsx | 107 ++- desktop/src/lib/gateway-client.ts | 241 ++++++ desktop/src/store/gatewayStore.ts | 578 ++++++++++++- docs/SYSTEM_ANALYSIS.md | 116 +-- hands/clip.HAND.toml | 133 +++ hands/collector.HAND.toml | 135 +++ hands/predictor.HAND.toml | 143 ++++ hands/twitter.HAND.toml | 156 ++++ tests/desktop/gatewayStore.test.ts | 31 +- 15 files changed, 3216 insertions(+), 296 deletions(-) create mode 100644 desktop/src/components/WorkflowEditor.tsx create mode 100644 desktop/src/components/WorkflowHistory.tsx create mode 100644 hands/clip.HAND.toml create mode 100644 hands/collector.HAND.toml create mode 100644 hands/predictor.HAND.toml create mode 100644 hands/twitter.HAND.toml diff --git a/desktop/src/components/HandTaskPanel.tsx b/desktop/src/components/HandTaskPanel.tsx index c1a9cf7..ff370bc 100644 --- a/desktop/src/components/HandTaskPanel.tsx +++ b/desktop/src/components/HandTaskPanel.tsx @@ -2,10 +2,11 @@ * HandTaskPanel - Hand 任务和结果面板 * * 显示选中 Hand 的任务清单、执行历史和结果。 + * 使用真实 API 数据,移除了 Mock 数据。 */ import { useState, useEffect, useCallback } from 'react'; -import { useGatewayStore, type Hand } from '../store/gatewayStore'; +import { useGatewayStore, type Hand, type HandRun } from '../store/gatewayStore'; import { Zap, Loader2, @@ -16,6 +17,7 @@ import { ChevronRight, Play, ArrowLeft, + RefreshCw, } from 'lucide-react'; interface HandTaskPanelProps { @@ -31,77 +33,65 @@ const RUN_STATUS_CONFIG: Record(null); - const [tasks, setTasks] = useState([]); const [isActivating, setIsActivating] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + // Load hands on mount useEffect(() => { loadHands(); }, [loadHands]); + // Find selected hand useEffect(() => { const hand = hands.find(h => h.id === handId || h.name === handId); setSelectedHand(hand || null); }, [hands, handId]); - // 模拟加载任务历史 + // Load task history when hand is selected useEffect(() => { if (selectedHand) { - // TODO: 实际应从 API 获取任务历史 - // 目前使用模拟数据 - setTasks([ - { - id: '1', - name: `${selectedHand.name} - 任务 1`, - status: 'completed', - startedAt: new Date(Date.now() - 3600000).toISOString(), - completedAt: new Date(Date.now() - 3500000).toISOString(), - result: '任务执行成功,生成了 5 个输出文件。', - }, - { - id: '2', - name: `${selectedHand.name} - 任务 2`, - status: 'running', - startedAt: new Date(Date.now() - 1800000).toISOString(), - }, - { - id: '3', - name: `${selectedHand.name} - 任务 3`, - status: 'needs_approval', - startedAt: new Date(Date.now() - 600000).toISOString(), - }, - ]); + loadHandRuns(selectedHand.name, { limit: 50 }); } - }, [selectedHand]); + }, [selectedHand, loadHandRuns]); + // Get runs for this hand from store + const tasks: HandRun[] = selectedHand ? (handRuns[selectedHand.name] || []) : []; + + // Refresh task history + const handleRefresh = useCallback(async () => { + if (!selectedHand) return; + setIsRefreshing(true); + try { + await loadHandRuns(selectedHand.name, { limit: 50 }); + } finally { + setIsRefreshing(false); + } + }, [selectedHand, loadHandRuns]); + + // Trigger hand execution const handleActivate = useCallback(async () => { if (!selectedHand) return; setIsActivating(true); try { await triggerHand(selectedHand.name); - // 刷新 hands 列表 - await loadHands(); + // Refresh hands list and task history + await Promise.all([ + loadHands(), + loadHandRuns(selectedHand.name, { limit: 50 }), + ]); } catch { // Error is handled in store } finally { setIsActivating(false); } - }, [selectedHand, triggerHand, loadHands]); + }, [selectedHand, triggerHand, loadHands, loadHandRuns]); if (!selectedHand) { return ( @@ -113,29 +103,37 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) { } const runningTasks = tasks.filter(t => t.status === 'running'); - const completedTasks = tasks.filter(t => t.status === 'completed' || t.status === 'failed'); - const pendingTasks = tasks.filter(t => t.status === 'pending' || t.status === 'needs_approval'); + const completedTasks = tasks.filter(t => ['completed', 'success', 'failed', 'error', 'cancelled'].includes(t.status)); + const pendingTasks = tasks.filter(t => ['pending', 'needs_approval'].includes(t.status)); return (
{/* 头部 */} -
+
{onBack && ( )} {selectedHand.icon || '🤖'}
-

+

{selectedHand.name}

-

{selectedHand.description}

+

{selectedHand.description}

+
@@ -196,14 +202,14 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
{completedTasks.map(task => ( - + ))}
)} {/* 空状态 */} - {tasks.length === 0 && ( + {!isLoading && tasks.length === 0 && (
@@ -220,11 +226,16 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) { } // 任务卡片组件 -function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boolean }) { +function TaskCard({ task, expanded = false }: { task: HandRun; expanded?: boolean }) { const [isExpanded, setIsExpanded] = useState(expanded); const config = RUN_STATUS_CONFIG[task.status] || RUN_STATUS_CONFIG.pending; const StatusIcon = config.icon; + // Format result for display + const resultText = task.result + ? (typeof task.result === 'string' ? task.result : JSON.stringify(task.result, null, 2)) + : undefined; + return (
- {task.name} + 运行 #{task.runId.slice(0, 8)}
@@ -248,6 +259,10 @@ function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boole {/* 展开详情 */} {isExpanded && (
+
+ 运行 ID + {task.runId} +
开始时间 {new Date(task.startedAt).toLocaleString()} @@ -258,9 +273,9 @@ function TaskCard({ task, expanded = false }: { task: MockTask; expanded?: boole {new Date(task.completedAt).toLocaleString()}
)} - {task.result && ( -
- {task.result} + {resultText && ( +
+ {resultText}
)} {task.error && ( diff --git a/desktop/src/components/SchedulerPanel.tsx b/desktop/src/components/SchedulerPanel.tsx index d32d117..df85dc8 100644 --- a/desktop/src/components/SchedulerPanel.tsx +++ b/desktop/src/components/SchedulerPanel.tsx @@ -16,12 +16,50 @@ import { RefreshCw, Loader2, Calendar, + X, + AlertCircle, + CheckCircle, } from 'lucide-react'; // === Tab Types === type TabType = 'scheduled' | 'triggers' | 'history'; +// === Schedule Type === + +type ScheduleType = 'cron' | 'interval' | 'once'; +type TargetType = 'agent' | 'hand' | 'workflow'; + +// === Form State Interface === + +interface JobFormData { + name: string; + scheduleType: ScheduleType; + cronExpression: string; + intervalValue: number; + intervalUnit: 'minutes' | 'hours' | 'days'; + runOnceDate: string; + runOnceTime: string; + targetType: TargetType; + targetId: string; + description: string; + enabled: boolean; +} + +const initialFormData: JobFormData = { + name: '', + scheduleType: 'cron', + cronExpression: '', + intervalValue: 30, + intervalUnit: 'minutes', + runOnceDate: '', + runOnceTime: '', + targetType: 'hand', + targetId: '', + description: '', + enabled: true, +}; + // === Tab Button Component === function TabButton({ @@ -87,19 +125,523 @@ function EmptyState({ ); } +// === Create Job Modal Component === + +interface CreateJobModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +function CreateJobModal({ isOpen, onClose, onSuccess }: CreateJobModalProps) { + const { hands, workflows, clones, createScheduledTask, loadHands, loadWorkflows, loadClones } = useGatewayStore(); + const [formData, setFormData] = useState(initialFormData); + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [errorMessage, setErrorMessage] = useState(''); + + // Load available targets on mount + useEffect(() => { + if (isOpen) { + loadHands(); + loadWorkflows(); + loadClones(); + } + }, [isOpen, loadHands, loadWorkflows, loadClones]); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setFormData(initialFormData); + setErrors({}); + setSubmitStatus('idle'); + setErrorMessage(''); + } + }, [isOpen]); + + // Generate schedule string based on type + const generateScheduleString = useCallback((): string => { + switch (formData.scheduleType) { + case 'cron': + return formData.cronExpression; + case 'interval': { + const unitMap = { minutes: 'm', hours: 'h', days: 'd' }; + return `every ${formData.intervalValue}${unitMap[formData.intervalUnit]}`; + } + case 'once': { + if (formData.runOnceDate && formData.runOnceTime) { + return `once ${formData.runOnceDate}T${formData.runOnceTime}`; + } + return ''; + } + default: + return ''; + } + }, [formData]); + + // Validate form + const validateForm = useCallback((): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = '任务名称不能为空'; + } + + switch (formData.scheduleType) { + case 'cron': + if (!formData.cronExpression.trim()) { + newErrors.cronExpression = 'Cron 表达式不能为空'; + } else if (!isValidCron(formData.cronExpression)) { + newErrors.cronExpression = 'Cron 表达式格式无效'; + } + break; + case 'interval': + if (formData.intervalValue <= 0) { + newErrors.intervalValue = '间隔时间必须大于 0'; + } + break; + case 'once': + if (!formData.runOnceDate) { + newErrors.runOnceDate = '请选择执行日期'; + } + if (!formData.runOnceTime) { + newErrors.runOnceTime = '请选择执行时间'; + } + break; + } + + if (!formData.targetId) { + newErrors.targetId = '请选择要执行的目标'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, [formData]); + + // Simple cron expression validator + const isValidCron = (expression: string): boolean => { + // Basic validation: 5 or 6 fields separated by spaces + const parts = expression.trim().split(/\s+/); + return parts.length >= 5 && parts.length <= 6; + }; + + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setSubmitStatus('idle'); + setErrorMessage(''); + + try { + const schedule = generateScheduleString(); + await createScheduledTask({ + name: formData.name.trim(), + schedule, + scheduleType: formData.scheduleType, + target: { + type: formData.targetType, + id: formData.targetId, + }, + description: formData.description.trim() || undefined, + enabled: formData.enabled, + }); + + setSubmitStatus('success'); + setTimeout(() => { + onSuccess(); + onClose(); + }, 1500); + } catch (err) { + setSubmitStatus('error'); + setErrorMessage(err instanceof Error ? err.message : '创建任务失败'); + } finally { + setIsSubmitting(false); + } + }; + + // Update form field + const updateField = (field: K, value: JobFormData[K]) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear error when field is updated + if (errors[field]) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }; + + // Get available targets based on type + const getAvailableTargets = (): Array<{ id: string; name?: string }> => { + switch (formData.targetType) { + case 'agent': + return clones.map(c => ({ id: c.id, name: c.name })); + case 'hand': + return hands.map(h => ({ id: h.id, name: h.name })); + case 'workflow': + return workflows.map(w => ({ id: w.id, name: w.name })); + default: + return []; + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ +
+
+

+ 新建定时任务 +

+

+ 创建一个定时执行的任务 +

+
+
+ +
+ + {/* Form */} +
+ {/* Task Name */} +
+ + updateField('name', e.target.value)} + placeholder="例如: 每日数据同步" + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.name && ( +

+ + {errors.name} +

+ )} +
+ + {/* Schedule Type */} +
+ +
+ {[ + { value: 'cron', label: 'Cron 表达式' }, + { value: 'interval', label: '固定间隔' }, + { value: 'once', label: '执行一次' }, + ].map((option) => ( + + ))} +
+
+ + {/* Cron Expression */} + {formData.scheduleType === 'cron' && ( +
+ + updateField('cronExpression', e.target.value)} + placeholder="0 9 * * * (每天 9:00)" + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono ${ + errors.cronExpression ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.cronExpression && ( +

+ + {errors.cronExpression} +

+ )} +

+ 格式: 分 时 日 月 周 (例如: 0 9 * * * 表示每天 9:00) +

+
+ )} + + {/* Interval Settings */} + {formData.scheduleType === 'interval' && ( +
+
+ + updateField('intervalValue', parseInt(e.target.value) || 0)} + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.intervalValue ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.intervalValue && ( +

+ + {errors.intervalValue} +

+ )} +
+
+ + +
+
+ )} + + {/* Run Once Settings */} + {formData.scheduleType === 'once' && ( +
+
+ + updateField('runOnceDate', e.target.value)} + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.runOnceDate ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.runOnceDate && ( +

+ + {errors.runOnceDate} +

+ )} +
+
+ + updateField('runOnceTime', e.target.value)} + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.runOnceTime ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.runOnceTime && ( +

+ + {errors.runOnceTime} +

+ )} +
+
+ )} + + {/* Target Selection */} +
+ +
+ {[ + { value: 'hand', label: 'Hand' }, + { value: 'workflow', label: 'Workflow' }, + { value: 'agent', label: 'Agent' }, + ].map((option) => ( + + ))} +
+
+ + {/* Target Selection Dropdown */} +
+ + + {errors.targetId && ( +

+ + {errors.targetId} +

+ )} + {getAvailableTargets().length === 0 && ( +

+ 当前没有可用的 {formData.targetType === 'hand' ? 'Hands' : formData.targetType === 'workflow' ? 'Workflows' : 'Agents'} +

+ )} +
+ + {/* Description */} +
+ +