diff --git a/desktop/src/components/Automation/AutomationCard.tsx b/desktop/src/components/Automation/AutomationCard.tsx new file mode 100644 index 0000000..19f9a3d --- /dev/null +++ b/desktop/src/components/Automation/AutomationCard.tsx @@ -0,0 +1,402 @@ +/** + * AutomationCard - Unified Card for Hands and Workflows + * + * Displays automation items with status, parameters, and actions. + * Supports both grid and list view modes. + * + * @module components/Automation/AutomationCard + */ + +import { useState, useCallback } from 'react'; +import type { AutomationItem, AutomationStatus } from '../../types/automation'; +import { CATEGORY_CONFIGS } from '../../types/automation'; +import type { HandParameter } from '../../types/hands'; +import { HandParamsForm } from '../HandParamsForm'; +import { + Zap, + Clock, + CheckCircle, + XCircle, + AlertTriangle, + Loader2, + Settings, + Play, + MoreVertical, +} from 'lucide-react'; + +// === Status Config === + +const STATUS_CONFIG: Record = { + idle: { + label: '就绪', + className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + dotClass: 'bg-green-500', + }, + running: { + label: '运行中', + className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + dotClass: 'bg-blue-500 animate-pulse', + icon: Loader2, + }, + needs_approval: { + label: '待审批', + className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + dotClass: 'bg-yellow-500', + icon: AlertTriangle, + }, + error: { + label: '错误', + className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + dotClass: 'bg-red-500', + icon: XCircle, + }, + unavailable: { + label: '不可用', + className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400', + dotClass: 'bg-gray-400', + }, + setup_needed: { + label: '需配置', + className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + dotClass: 'bg-orange-500', + icon: Settings, + }, + completed: { + label: '已完成', + className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + dotClass: 'bg-green-500', + icon: CheckCircle, + }, + paused: { + label: '已暂停', + className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + dotClass: 'bg-gray-400', + }, +}; + +// === Component Props === + +interface AutomationCardProps { + item: AutomationItem; + viewMode?: 'grid' | 'list'; + isSelected?: boolean; + isExecuting?: boolean; + onSelect?: (selected: boolean) => void; + onExecute?: (params?: Record) => void; + onClick?: () => void; +} + +// === Status Badge Component === + +function StatusBadge({ status }: { status: AutomationStatus }) { + const config = STATUS_CONFIG[status] || STATUS_CONFIG.unavailable; + const Icon = config.icon; + + return ( + + {Icon ? ( + + ) : ( + + )} + {config.label} + + ); +} + +// === Type Badge Component === + +function TypeBadge({ type }: { type: 'hand' | 'workflow' }) { + const isHand = type === 'hand'; + return ( + + {isHand ? 'Hand' : '工作流'} + + ); +} + +// === Category Badge Component === + +function CategoryBadge({ category }: { category: string }) { + const config = CATEGORY_CONFIGS[category as keyof typeof CATEGORY_CONFIGS]; + if (!config) return null; + + return ( + + {config.label} + + ); +} + +// === Main Component === + +export function AutomationCard({ + item, + viewMode = 'grid', + isSelected = false, + isExecuting = false, + onSelect, + onExecute, + onClick, +}: AutomationCardProps) { + const [showParams, setShowParams] = useState(false); + const [paramValues, setParamValues] = useState>({}); + const [paramErrors, setParamErrors] = useState>({}); + + const hasParameters = item.parameters && item.parameters.length > 0; + const canActivate = item.status === 'idle' || item.status === 'setup_needed'; + + // Initialize default parameter values + const initializeDefaults = useCallback(() => { + if (item.parameters) { + const defaults: Record = {}; + item.parameters.forEach(p => { + if (p.defaultValue !== undefined) { + defaults[p.name] = p.defaultValue; + } + }); + setParamValues(defaults); + } + }, [item.parameters]); + + // Handle execute click + const handleExecuteClick = useCallback(() => { + if (hasParameters && !showParams) { + initializeDefaults(); + setShowParams(true); + return; + } + + // Validate parameters + if (showParams && item.parameters) { + const errors: Record = {}; + item.parameters.forEach(param => { + if (param.required) { + const value = paramValues[param.name]; + if (value === undefined || value === null || value === '') { + errors[param.name] = `${param.label} is required`; + } + } + }); + + if (Object.keys(errors).length > 0) { + setParamErrors(errors); + return; + } + } + + onExecute?.(showParams ? paramValues : undefined); + setShowParams(false); + setParamErrors({}); + }, [hasParameters, showParams, initializeDefaults, item.parameters, paramValues, onExecute]); + + // Handle checkbox change + const handleCheckboxChange = useCallback((e: React.ChangeEvent) => { + e.stopPropagation(); + onSelect?.(e.target.checked); + }, [onSelect]); + + // Get icon for item + const getItemIcon = () => { + if (item.icon) { + // Map string icon names to components + const iconMap: Record = { + Video: '🎬', + UserPlus: '👤', + Database: '🗄️', + TrendingUp: '📈', + Search: '🔍', + Twitter: '🐦', + Globe: '🌐', + Zap: '⚡', + }; + return iconMap[item.icon] || '🤖'; + } + return item.type === 'hand' ? '🤖' : '📋'; + }; + + if (viewMode === 'list') { + return ( +
+ {/* Checkbox */} + e.stopPropagation()} + /> + + {/* Icon */} + {getItemIcon()} + + {/* Info */} +
+
+

{item.name}

+ +
+

{item.description}

+
+ + {/* Status */} + + + {/* Actions */} +
+ + +
+
+ ); + } + + // Grid view + return ( +
+ {/* Selection checkbox */} +
+ e.stopPropagation()} + /> +
+ + {/* Content */} +
+ {/* Header */} +
+
+ {getItemIcon()} +

{item.name}

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

{item.description}

+ + {/* Meta */} +
+ + + {item.schedule?.enabled && ( + + + 已调度 + + )} +
+ + {/* Parameters Form (shown when activating) */} + {showParams && item.parameters && item.parameters.length > 0 && ( +
+ +
+ )} + + {/* Actions */} +
+ + +
+ + {/* Schedule indicator */} + {item.schedule?.nextRun && ( +
+ 下次运行: {new Date(item.schedule.nextRun).toLocaleString('zh-CN')} +
+ )} +
+
+ ); +} + +export default AutomationCard; diff --git a/desktop/src/components/Automation/AutomationFilters.tsx b/desktop/src/components/Automation/AutomationFilters.tsx new file mode 100644 index 0000000..12f520e --- /dev/null +++ b/desktop/src/components/Automation/AutomationFilters.tsx @@ -0,0 +1,204 @@ +/** + * AutomationFilters - Category and Search Filters + * + * Provides category tabs, search input, and view mode toggle + * for the automation panel. + * + * @module components/Automation/AutomationFilters + */ + +import { useState, useCallback } from 'react'; +import type { CategoryType, CategoryStats } from '../../types/automation'; +import { CATEGORY_CONFIGS } from '../../types/automation'; +import { + Search, + Grid, + List, + Layers, + Database, + MessageSquare, + Video, + TrendingUp, + Zap, + ChevronDown, +} from 'lucide-react'; + +// === Icon Map === + +const CATEGORY_ICONS: Record = { + all: Layers, + research: Search, + data: Database, + automation: Zap, + communication: MessageSquare, + content: Video, + productivity: TrendingUp, +}; + +// === Component Props === + +interface AutomationFiltersProps { + selectedCategory: CategoryType; + onCategoryChange: (category: CategoryType) => void; + searchQuery: string; + onSearchChange: (query: string) => void; + viewMode: 'grid' | 'list'; + onViewModeChange: (mode: 'grid' | 'list') => void; + categoryStats: CategoryStats; +} + +// === Main Component === + +export function AutomationFilters({ + selectedCategory, + onCategoryChange, + searchQuery, + onSearchChange, + viewMode, + onViewModeChange, + categoryStats, +}: AutomationFiltersProps) { + const [showCategoryDropdown, setShowCategoryDropdown] = useState(false); + + // Handle search input + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + onSearchChange(e.target.value); + }, [onSearchChange]); + + // Handle category click + const handleCategoryClick = useCallback((category: CategoryType) => { + onCategoryChange(category); + setShowCategoryDropdown(false); + }, [onCategoryChange]); + + // Get categories with counts + const categories = Object.entries(CATEGORY_CONFIGS).map(([key, config]) => ({ + ...config, + count: categoryStats[key as CategoryType] || 0, + })); + + // Selected category config + const selectedConfig = CATEGORY_CONFIGS[selectedCategory]; + + return ( +
+ {/* Search and View Mode Row */} +
+ {/* Search Input */} +
+ + +
+ + {/* View Mode Toggle */} +
+ + +
+
+ + {/* Category Tabs (Desktop) */} +
+ {categories.map(({ id, label, count }) => { + const Icon = CATEGORY_ICONS[id]; + const isSelected = selectedCategory === id; + + return ( + + ); + })} +
+ + {/* Category Dropdown (Mobile) */} +
+ + + {showCategoryDropdown && ( +
+ {categories.map(({ id, label, count }) => { + const Icon = CATEGORY_ICONS[id]; + const isSelected = selectedCategory === id; + + return ( + + ); + })} +
+ )} +
+
+ ); +} + +export default AutomationFilters; diff --git a/desktop/src/components/Automation/AutomationPanel.tsx b/desktop/src/components/Automation/AutomationPanel.tsx new file mode 100644 index 0000000..9214008 --- /dev/null +++ b/desktop/src/components/Automation/AutomationPanel.tsx @@ -0,0 +1,278 @@ +/** + * AutomationPanel - Unified Automation Entry Point + * + * Combines Hands and Workflows into a single unified view, + * with category filtering, batch operations, and scheduling. + * + * @module components/Automation/AutomationPanel + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useHandStore } from '../../store/handStore'; +import { useWorkflowStore } from '../../store/workflowStore'; +import { + type AutomationItem, + type CategoryType, + type CategoryStats, + adaptToAutomationItems, + calculateCategoryStats, + filterByCategory, + searchAutomationItems, +} from '../../types/automation'; +import { AutomationCard } from './AutomationCard'; +import { AutomationFilters } from './AutomationFilters'; +import { BatchActionBar } from './BatchActionBar'; +import { + Zap, + RefreshCw, + Plus, + Calendar, + Search, +} from 'lucide-react'; +import { useToast } from '../ui/Toast'; + +// === View Mode === + +type ViewMode = 'grid' | 'list'; + +// === Component Props === + +interface AutomationPanelProps { + initialCategory?: CategoryType; + onSelect?: (item: AutomationItem) => void; + showBatchActions?: boolean; +} + +// === Main Component === + +export function AutomationPanel({ + initialCategory = 'all', + onSelect, + showBatchActions = true, +}: AutomationPanelProps) { + // Store state + const hands = useHandStore(s => s.hands); + const workflows = useWorkflowStore(s => s.workflows); + const isLoadingHands = useHandStore(s => s.isLoading); + const isLoadingWorkflows = useWorkflowStore(s => s.isLoading); + const loadHands = useHandStore(s => s.loadHands); + const loadWorkflows = useWorkflowStore(s => s.loadWorkflows); + const triggerHand = useHandStore(s => s.triggerHand); + const triggerWorkflow = useWorkflowStore(s => s.triggerWorkflow); + + // UI state + const [selectedCategory, setSelectedCategory] = useState(initialCategory); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState('grid'); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [executingIds, setExecutingIds] = useState>(new Set()); + + const { toast } = useToast(); + + // Load data on mount + useEffect(() => { + loadHands(); + loadWorkflows(); + }, [loadHands, loadWorkflows]); + + // Adapt hands and workflows to automation items + const automationItems = useMemo(() => { + return adaptToAutomationItems(hands, workflows); + }, [hands, workflows]); + + // Calculate category stats + const categoryStats = useMemo(() => { + return calculateCategoryStats(automationItems); + }, [automationItems]); + + // Filter and search items + const filteredItems = useMemo(() => { + let items = filterByCategory(automationItems, selectedCategory); + if (searchQuery.trim()) { + items = searchAutomationItems(items, searchQuery); + } + return items; + }, [automationItems, selectedCategory, searchQuery]); + + // Selection handlers + const handleSelect = useCallback((id: string, selected: boolean) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (selected) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }, []); + + const handleSelectAll = useCallback(() => { + setSelectedIds(new Set(filteredItems.map(item => item.id))); + }, [filteredItems]); + + const handleDeselectAll = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + // Execute handler + const handleExecute = useCallback(async (item: AutomationItem, params?: Record) => { + setExecutingIds(prev => new Set(prev).add(item.id)); + + try { + if (item.type === 'hand') { + await triggerHand(item.id, params); + } else { + await triggerWorkflow(item.id, params); + } + toast(`${item.name} 执行成功`, 'success'); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast(`${item.name} 执行失败: ${errorMsg}`, 'error'); + } finally { + setExecutingIds(prev => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + } + }, [triggerHand, triggerWorkflow, toast]); + + // Batch execute + const handleBatchExecute = useCallback(async () => { + const itemsToExecute = filteredItems.filter(item => selectedIds.has(item.id)); + let successCount = 0; + let failCount = 0; + + for (const item of itemsToExecute) { + try { + if (item.type === 'hand') { + await triggerHand(item.id); + } else { + await triggerWorkflow(item.id); + } + successCount++; + } catch { + failCount++; + } + } + + if (successCount > 0) { + toast(`成功执行 ${successCount} 个项目`, 'success'); + } + if (failCount > 0) { + toast(`${failCount} 个项目执行失败`, 'error'); + } + + setSelectedIds(new Set()); + }, [filteredItems, selectedIds, triggerHand, triggerWorkflow, toast]); + + // Refresh handler + const handleRefresh = useCallback(async () => { + await Promise.all([loadHands(), loadWorkflows()]); + toast('数据已刷新', 'success'); + }, [loadHands, loadWorkflows, toast]); + + const isLoading = isLoadingHands || isLoadingWorkflows; + + return ( +
+ {/* Header */} +
+
+ +

+ 自动化 +

+ + ({automationItems.length}) + +
+
+ + + +
+
+ + {/* Filters */} + + + {/* Content */} +
+ {isLoading && automationItems.length === 0 ? ( +
+ +
+ ) : filteredItems.length === 0 ? ( +
+ +

+ {searchQuery ? '没有找到匹配的项目' : '暂无自动化项目'} +

+
+ ) : ( +
+ {filteredItems.map(item => ( + handleSelect(item.id, selected)} + onExecute={(params) => handleExecute(item, params)} + onClick={() => onSelect?.(item)} + /> + ))} +
+ )} +
+ + {/* Batch Actions */} + {showBatchActions && selectedIds.size > 0 && ( + { + toast('批量调度功能开发中', 'info'); + }} + /> + )} +
+ ); +} + +export default AutomationPanel; diff --git a/desktop/src/components/Automation/BatchActionBar.tsx b/desktop/src/components/Automation/BatchActionBar.tsx new file mode 100644 index 0000000..69b9b47 --- /dev/null +++ b/desktop/src/components/Automation/BatchActionBar.tsx @@ -0,0 +1,216 @@ +/** + * BatchActionBar - Batch Operations Action Bar + * + * Provides batch action buttons for selected automation items. + * Supports batch execute, approve, reject, and schedule. + * + * @module components/Automation/BatchActionBar + */ + +import { useState, useCallback } from 'react'; +import { + Play, + Check, + X, + Clock, + XCircle, + MoreHorizontal, + Trash2, + Copy, +} from 'lucide-react'; + +// === Component Props === + +interface BatchActionBarProps { + selectedCount: number; + totalCount?: number; // Optional - for "select all X items" display + onSelectAll: () => void; + onDeselectAll: () => void; + onBatchExecute: () => Promise; + onBatchApprove?: () => Promise; + onBatchReject?: () => Promise; + onBatchSchedule?: () => void; + onBatchDelete?: () => Promise; + onBatchDuplicate?: () => Promise; +} + +// === Main Component === + +export function BatchActionBar({ + selectedCount, + totalCount: _totalCount, // Used for future "select all X items" display + onSelectAll, + onDeselectAll, + onBatchExecute, + onBatchApprove, + onBatchReject, + onBatchSchedule, + onBatchDelete, + onBatchDuplicate, +}: BatchActionBarProps) { + const [isExecuting, setIsExecuting] = useState(false); + const [showMoreMenu, setShowMoreMenu] = useState(false); + + // Handle batch execute + const handleExecute = useCallback(async () => { + setIsExecuting(true); + try { + await onBatchExecute(); + } finally { + setIsExecuting(false); + } + }, [onBatchExecute]); + + // Handle batch approve + const handleApprove = useCallback(async () => { + if (onBatchApprove) { + setIsExecuting(true); + try { + await onBatchApprove(); + } finally { + setIsExecuting(false); + } + } + }, [onBatchApprove]); + + // Handle batch reject + const handleReject = useCallback(async () => { + if (onBatchReject) { + setIsExecuting(true); + try { + await onBatchReject(); + } finally { + setIsExecuting(false); + } + } + }, [onBatchReject]); + + return ( +
+
+ {/* Selection Info */} +
+ + 已选择 {selectedCount} 项 + +
+ + | + +
+
+ + {/* Action Buttons */} +
+ {/* Execute */} + + + {/* Approve (if handler provided) */} + {onBatchApprove && ( + + )} + + {/* Reject (if handler provided) */} + {onBatchReject && ( + + )} + + {/* Schedule */} + {onBatchSchedule && ( + + )} + + {/* More Actions */} + {(onBatchDelete || onBatchDuplicate) && ( +
+ + + {showMoreMenu && ( +
+ {onBatchDuplicate && ( + + )} + {onBatchDelete && ( + + )} +
+ )} +
+ )} + + {/* Close */} + +
+
+
+ ); +} + +export default BatchActionBar; diff --git a/desktop/src/components/Automation/ScheduleEditor.tsx b/desktop/src/components/Automation/ScheduleEditor.tsx new file mode 100644 index 0000000..694d6b3 --- /dev/null +++ b/desktop/src/components/Automation/ScheduleEditor.tsx @@ -0,0 +1,378 @@ +/** + * ScheduleEditor - Visual Schedule Configuration + * + * Provides a visual interface for configuring schedules + * without requiring knowledge of cron syntax. + * + * @module components/Automation/ScheduleEditor + */ + +import { useState, useCallback, useMemo } from 'react'; +import type { ScheduleInfo } from '../../types/automation'; +import { + Calendar, + Info, +} from 'lucide-react'; +import { useToast } from '../ui/Toast'; + +// === Frequency Types === + +type Frequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'custom'; + +// === Timezones === + +const COMMON_TIMEZONES = [ + { value: 'Asia/Shanghai', label: '北京时间 (UTC+8)' }, + { value: 'Asia/Tokyo', label: '东京时间 (UTC+9)' }, + { value: 'Asia/Singapore', label: '新加坡时间 (UTC+8)' }, + { value: 'America/New_York', label: '纽约时间 (UTC-5)' }, + { value: 'America/Los_Angeles', label: '洛杉矶时间 (UTC-8)' }, + { value: 'Europe/London', label: '伦敦时间 (UTC+0)' }, + { value: 'UTC', label: '协调世界时 (UTC)' }, +]; + +// === Day Names === + +const DAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']; +const DAY_NAMES_FULL = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + +// === Component Props === + +interface ScheduleEditorProps { + schedule?: ScheduleInfo; + onSave: (schedule: ScheduleInfo) => void; + onCancel: () => void; + itemName?: string; +} + +// === Helper Functions === + +function formatSchedulePreview(schedule: ScheduleInfo): string { + const { frequency, time, daysOfWeek, dayOfMonth, timezone } = schedule; + const timeStr = `${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}`; + const tzLabel = COMMON_TIMEZONES.find(tz => tz.value === timezone)?.label || timezone; + + switch (frequency) { + case 'once': + return `一次性执行于 ${timeStr} (${tzLabel})`; + case 'daily': + return `每天 ${timeStr} (${tzLabel})`; + case 'weekly': + const days = (daysOfWeek || []).map(d => DAY_NAMES_FULL[d]).join('、'); + return `每${days} ${timeStr} (${tzLabel})`; + case 'monthly': + return `每月${dayOfMonth || 1}日 ${timeStr} (${tzLabel})`; + case 'custom': + return schedule.customCron || '自定义调度'; + default: + return '未设置'; + } +} + +// === Main Component === + +export function ScheduleEditor({ + schedule, + onSave, + onCancel, + itemName = '自动化项目', +}: ScheduleEditorProps) { + const { toast } = useToast(); + + // Initialize state from existing schedule + const [frequency, setFrequency] = useState(schedule?.frequency || 'daily'); + const [time, setTime] = useState(schedule?.time || { hour: 9, minute: 0 }); + const [daysOfWeek, setDaysOfWeek] = useState(schedule?.daysOfWeek || [1, 2, 3, 4, 5]); + const [dayOfMonth, setDayOfMonth] = useState(schedule?.dayOfMonth || 1); + const [timezone, setTimezone] = useState(schedule?.timezone || 'Asia/Shanghai'); + const [endDate, setEndDate] = useState(schedule?.endDate || ''); + const [customCron, setCustomCron] = useState(schedule?.customCron || ''); + const [enabled, setEnabled] = useState(schedule?.enabled ?? true); + + // Toggle day of week + const toggleDayOfWeek = useCallback((day: number) => { + setDaysOfWeek(prev => + prev.includes(day) + ? prev.filter(d => d !== day) + : [...prev, day].sort() + ); + }, []); + + // Handle save + const handleSave = useCallback(() => { + // Validate + if (frequency === 'weekly' && daysOfWeek.length === 0) { + toast('请选择至少一个重复日期', 'error'); + return; + } + + if (frequency === 'custom' && !customCron) { + toast('请输入自定义 cron 表达式', 'error'); + return; + } + + const newSchedule: ScheduleInfo = { + enabled, + frequency, + time, + daysOfWeek: frequency === 'weekly' ? daysOfWeek : undefined, + dayOfMonth: frequency === 'monthly' ? dayOfMonth : undefined, + customCron: frequency === 'custom' ? customCron : undefined, + timezone, + endDate: endDate || undefined, + }; + + onSave(newSchedule); + toast('调度设置已保存', 'success'); + }, [frequency, daysOfWeek, customCron, enabled, time, dayOfMonth, timezone, endDate, onSave, toast]); + + // Generate preview + const preview = useMemo(() => { + return formatSchedulePreview({ + enabled, + frequency, + time, + daysOfWeek, + dayOfMonth, + customCron, + timezone, + }); + }, [enabled, frequency, time, daysOfWeek, dayOfMonth, customCron, timezone]); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ +
+
+

+ 调度设置 +

+ {itemName && ( +

{itemName}

+ )} +
+
+ +
+ + {/* Body */} +
+ {/* Enable Toggle */} +
+
+

+ 启用调度 +

+

+ 开启后,此项目将按照设定的时间自动执行 +

+
+ +
+ + {/* Frequency Selection */} +
+ +
+ {[ + { value: 'once', label: '一次' }, + { value: 'daily', label: '每天' }, + { value: 'weekly', label: '每周' }, + { value: 'monthly', label: '每月' }, + { value: 'custom', label: '自定义' }, + ].map(option => ( + + ))} +
+
+ + {/* Time Selection */} + {frequency !== 'custom' && ( +
+
+ +
+ setTime(prev => ({ ...prev, hour: parseInt(e.target.value) || 0 }))} + className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + /> + : + setTime(prev => ({ ...prev, minute: parseInt(e.target.value) || 0 }))} + className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + /> +
+
+ +
+ + +
+
+ )} + + {/* Weekly Days Selection */} + {frequency === 'weekly' && ( +
+ +
+ {DAY_NAMES.map((day, index) => ( + + ))} +
+
+ )} + + {/* Monthly Day Selection */} + {frequency === 'monthly' && ( +
+ + +
+ )} + + {/* Custom Cron Input */} + {frequency === 'custom' && ( +
+ + setCustomCron(e.target.value)} + placeholder="* * * * * (分 时 日 月 周)" + className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm" + /> +

+ + 示例: "0 9 * * *" 表示每天 9:00 执行 +

+
+ )} + + {/* End Date */} + {frequency !== 'once' && ( +
+ + setEndDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + /> +
+ )} + + {/* Preview */} +
+

预览

+

{preview}

+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + +export default ScheduleEditor; diff --git a/desktop/src/components/Automation/index.ts b/desktop/src/components/Automation/index.ts new file mode 100644 index 0000000..42df908 --- /dev/null +++ b/desktop/src/components/Automation/index.ts @@ -0,0 +1,24 @@ +/** + * Automation Components + * + * Unified automation system components for Hands and Workflows. + * + * @module components/Automation + */ + +export { AutomationPanel, default as AutomationPanelDefault } from './AutomationPanel'; +export { AutomationCard } from './AutomationCard'; +export { AutomationFilters } from './AutomationFilters'; +export { BatchActionBar } from './BatchActionBar'; +export { ScheduleEditor } from './ScheduleEditor'; + +// Re-export types +export type { + AutomationItem, + AutomationStatus, + AutomationType, + CategoryType, + CategoryStats, + RunInfo, + ScheduleInfo, +} from '../../types/automation'; diff --git a/desktop/src/components/HandsPanel.tsx b/desktop/src/components/HandsPanel.tsx index 1686d18..6c85ef7 100644 --- a/desktop/src/components/HandsPanel.tsx +++ b/desktop/src/components/HandsPanel.tsx @@ -8,12 +8,17 @@ */ import { useState, useEffect, useCallback } from 'react'; -import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore'; -import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play } from 'lucide-react'; +import { useHandStore, type Hand, type HandRequirement } from '../store/handStore'; +import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play, Clock } from 'lucide-react'; import { BrowserHandCard } from './BrowserHand'; import type { HandParameter } from '../types/hands'; import { HAND_DEFINITIONS } from '../types/hands'; import { HandParamsForm } from './HandParamsForm'; +import { ApprovalsPanel } from './ApprovalsPanel'; +import { useToast } from './ui/Toast'; + +// === Tab Type === +type TabType = 'hands' | 'approvals'; // === Status Badge Component === @@ -133,7 +138,7 @@ interface HandDetailsModalProps { hand: Hand; isOpen: boolean; onClose: () => void; - onActivate: () => void; + onActivate: (params?: Record) => void; isActivating: boolean; } @@ -183,7 +188,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H return; } // Pass parameters to onActivate - onActivate(); + onActivate(paramValues); } else { onActivate(); } @@ -366,7 +371,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H interface HandCardProps { hand: Hand; onDetails: (hand: Hand) => void; - onActivate: (hand: Hand) => void; + onActivate: (hand: Hand, params?: Record) => void; isActivating: boolean; } @@ -450,10 +455,12 @@ function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps) // === Main HandsPanel Component === export function HandsPanel() { - const { hands, loadHands, triggerHand, isLoading } = useGatewayStore(); + const { hands, loadHands, triggerHand, isLoading, error: storeError, getHandDetails } = useHandStore(); const [selectedHand, setSelectedHand] = useState(null); const [activatingHandId, setActivatingHandId] = useState(null); const [showModal, setShowModal] = useState(false); + const [activeTab, setActiveTab] = useState('hands'); + const { toast } = useToast(); useEffect(() => { loadHands(); @@ -461,34 +468,47 @@ export function HandsPanel() { const handleDetails = useCallback(async (hand: Hand) => { // Load full details before showing modal - const { getHandDetails } = useGatewayStore.getState(); const details = await getHandDetails(hand.id); setSelectedHand(details || hand); setShowModal(true); - }, []); + }, [getHandDetails]); - const handleActivate = useCallback(async (hand: Hand) => { + const handleActivate = useCallback(async (hand: Hand, params?: Record) => { setActivatingHandId(hand.id); + console.log(`[HandsPanel] Activating hand: ${hand.id} (${hand.name})`, params ? 'with params:' : '', params); + try { - await triggerHand(hand.id); - // Refresh hands after activation - await loadHands(); - } catch { - // Error is handled in store + const result = await triggerHand(hand.id, params); + console.log(`[HandsPanel] Hand activation result:`, result); + + if (result) { + toast(`Hand "${hand.name}" 已成功激活`, 'success'); + // Refresh hands after activation + await loadHands(); + } else { + // Check if there's an error in the store + const errorMsg = storeError || '激活失败,请检查后端连接'; + console.error(`[HandsPanel] Hand activation failed:`, errorMsg); + toast(`Hand "${hand.name}" 激活失败: ${errorMsg}`, 'error'); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`[HandsPanel] Hand activation error:`, errorMsg); + toast(`Hand "${hand.name}" 激活异常: ${errorMsg}`, 'error'); } finally { setActivatingHandId(null); } - }, [triggerHand, loadHands]); + }, [triggerHand, loadHands, toast, storeError]); const handleCloseModal = useCallback(() => { setShowModal(false); setSelectedHand(null); }, []); - const handleModalActivate = useCallback(async () => { + const handleModalActivate = useCallback(async (params?: Record) => { if (!selectedHand) return; setShowModal(false); - await handleActivate(selectedHand); + await handleActivate(selectedHand, params); }, [selectedHand, handleActivate]); if (isLoading && hands.length === 0) { @@ -540,21 +560,52 @@ export function HandsPanel() {
- {/* Stats */} -
- - 可用 {hands.length} - - - 就绪 {hands.filter(h => h.status === 'idle').length} - + {/* Tabs */} +
+ +
- {/* Hand Cards Grid */} -
- {hands.map((hand) => { - // Check if this is a Browser Hand - const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser'); + {/* Tab Content */} + {activeTab === 'approvals' ? ( + + ) : ( + <> + {/* Stats */} +
+ + 可用 {hands.length} + + + 就绪 {hands.filter(h => h.status === 'idle').length} + +
+ + {/* Hand Cards Grid */} +
+ {hands.map((hand) => { + // Check if this is a Browser Hand + const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser'); return isBrowserHand ? ( ); })} -
+
- {/* Details Modal */} - {selectedHand && ( - + {/* Details Modal */} + {selectedHand && ( + + )} + )}
); diff --git a/desktop/src/hooks/index.ts b/desktop/src/hooks/index.ts new file mode 100644 index 0000000..a2f38f6 --- /dev/null +++ b/desktop/src/hooks/index.ts @@ -0,0 +1,17 @@ +/** + * Custom React Hooks for ZCLAW Desktop + * + * @module hooks + */ + +export { + useAutomationEvents, + useHandEvents, + useWorkflowEvents, +} from './useAutomationEvents'; + +// Re-export types from useAutomationEvents +export type { + UseAutomationEventsOptions, +} from './useAutomationEvents'; + diff --git a/desktop/src/hooks/useAutomationEvents.ts b/desktop/src/hooks/useAutomationEvents.ts new file mode 100644 index 0000000..1a41462 --- /dev/null +++ b/desktop/src/hooks/useAutomationEvents.ts @@ -0,0 +1,318 @@ +/** + * useAutomationEvents - WebSocket Event Hook for Automation System + * + * Subscribes to hand and workflow events from OpenFang WebSocket + * and updates the corresponding stores. + * + * @module hooks/useAutomationEvents + */ + +import { useEffect, useRef } from 'react'; +import { useHandStore } from '../store/handStore'; +import { useWorkflowStore } from '../store/workflowStore'; +import { useChatStore } from '../store/chatStore'; +import type { GatewayClient } from '../lib/gateway-client'; + +// === Event Types === + +interface HandEventData { + hand_name: string; + hand_status: 'triggered' | 'running' | 'completed' | 'failed' | 'needs_approval'; + hand_result?: unknown; + hand_error?: string; + run_id?: string; + timestamp?: number; +} + +interface WorkflowEventData { + workflow_id: string; + workflow_status: 'started' | 'step_completed' | 'completed' | 'failed' | 'paused'; + current_step?: number; + total_steps?: number; + step_name?: string; + result?: unknown; + error?: string; + run_id?: string; + timestamp?: number; +} + +interface ApprovalEventData { + approval_id: string; + hand_name?: string; + workflow_id?: string; + run_id?: string; + status: 'requested' | 'approved' | 'rejected' | 'expired'; + reason?: string; + requested_by?: string; + timestamp?: number; +} + +// === Hook Options === + +export interface UseAutomationEventsOptions { + /** Whether to inject hand results into chat as messages */ + injectResultsToChat?: boolean; + /** Whether to auto-refresh hands on status change */ + refreshOnStatusChange?: boolean; + /** Custom event handlers */ + onHandEvent?: (data: HandEventData) => void; + onWorkflowEvent?: (data: WorkflowEventData) => void; + onApprovalEvent?: (data: ApprovalEventData) => void; +} + +// === Helper Functions === + +function isHandEvent(data: unknown): data is HandEventData { + return typeof data === 'object' && data !== null && 'hand_name' in data && 'hand_status' in data; +} + +function isWorkflowEvent(data: unknown): data is WorkflowEventData { + return typeof data === 'object' && data !== null && 'workflow_id' in data && 'workflow_status' in data; +} + +function isApprovalEvent(data: unknown): data is ApprovalEventData { + return typeof data === 'object' && data !== null && 'approval_id' in data && 'status' in data; +} + +// === Main Hook === + +/** + * Hook for subscribing to automation-related WebSocket events. + * + * @param client - The GatewayClient instance (optional, will try to get from store if not provided) + * @param options - Configuration options + * + * @example + * ```tsx + * function AutomationPanel() { + * const client = useConnectionStore(s => s.client); + * useAutomationEvents(client, { + * injectResultsToChat: true, + * refreshOnStatusChange: true, + * }); + * // ... + * } + * ``` + */ +export function useAutomationEvents( + client: GatewayClient | null, + options: UseAutomationEventsOptions = {} +): void { + const { + injectResultsToChat = true, + refreshOnStatusChange = true, + onHandEvent, + onWorkflowEvent, + onApprovalEvent, + } = options; + + // Store references + const loadHands = useHandStore(s => s.loadHands); + const loadHandRuns = useHandStore(s => s.loadHandRuns); + const loadApprovals = useHandStore(s => s.loadApprovals); + const loadWorkflows = useWorkflowStore(s => s.loadWorkflows); + const loadWorkflowRuns = useWorkflowStore(s => s.loadWorkflowRuns); + const addMessage = useChatStore(s => s.addMessage); + + // Track subscriptions for cleanup + const unsubscribersRef = useRef void>>([]); + + useEffect(() => { + if (!client) { + return; + } + + // Clean up any existing subscriptions + unsubscribersRef.current.forEach(unsub => unsub()); + unsubscribersRef.current = []; + + // === Hand Event Handler === + const handleHandEvent = (data: unknown) => { + if (!isHandEvent(data)) return; + + const eventData = data as HandEventData; + console.log('[useAutomationEvents] Hand event:', eventData); + + // Refresh hands if status changed + if (refreshOnStatusChange) { + loadHands(); + } + + // Load updated runs for this hand + if (eventData.run_id) { + loadHandRuns(eventData.hand_name); + } + + // Inject result into chat + if (injectResultsToChat && eventData.hand_status === 'completed') { + const resultContent = eventData.hand_result + ? typeof eventData.hand_result === 'string' + ? eventData.hand_result + : JSON.stringify(eventData.hand_result, null, 2) + : 'Hand completed successfully'; + + addMessage({ + id: `hand-${eventData.run_id || Date.now()}`, + role: 'hand', + content: `**${eventData.hand_name}** 执行完成\n\n${resultContent}`, + timestamp: new Date(), + handName: eventData.hand_name, + handStatus: eventData.hand_status, + handResult: eventData.hand_result, + runId: eventData.run_id, + }); + } + + // Handle error status + if (eventData.hand_status === 'failed' && eventData.hand_error) { + addMessage({ + id: `hand-error-${eventData.run_id || Date.now()}`, + role: 'hand', + content: `**${eventData.hand_name}** 执行失败\n\n错误: ${eventData.hand_error}`, + timestamp: new Date(), + handName: eventData.hand_name, + handStatus: eventData.hand_status, + error: eventData.hand_error, + runId: eventData.run_id, + }); + } + + // Handle approval needed + if (eventData.hand_status === 'needs_approval') { + loadApprovals('pending'); + } + + // Call custom handler + onHandEvent?.(eventData); + }; + + // === Workflow Event Handler === + const handleWorkflowEvent = (data: unknown) => { + if (!isWorkflowEvent(data)) return; + + const eventData = data as WorkflowEventData; + console.log('[useAutomationEvents] Workflow event:', eventData); + + // Refresh workflows if status changed + if (refreshOnStatusChange) { + loadWorkflows(); + } + + // Load updated runs for this workflow + if (eventData.run_id) { + loadWorkflowRuns(eventData.workflow_id); + } + + // Inject result into chat + if (injectResultsToChat && eventData.workflow_status === 'completed') { + const resultContent = eventData.result + ? typeof eventData.result === 'string' + ? eventData.result + : JSON.stringify(eventData.result, null, 2) + : 'Workflow completed successfully'; + + addMessage({ + id: `workflow-${eventData.run_id || Date.now()}`, + role: 'workflow', + content: `**工作流: ${eventData.workflow_id}** 执行完成\n\n${resultContent}`, + timestamp: new Date(), + workflowId: eventData.workflow_id, + workflowStatus: eventData.workflow_status, + workflowResult: eventData.result, + runId: eventData.run_id, + }); + } + + // Call custom handler + onWorkflowEvent?.(eventData); + }; + + // === Approval Event Handler === + const handleApprovalEvent = (data: unknown) => { + if (!isApprovalEvent(data)) return; + + const eventData = data as ApprovalEventData; + console.log('[useAutomationEvents] Approval event:', eventData); + + // Refresh approvals list + loadApprovals(); + + // Call custom handler + onApprovalEvent?.(eventData); + }; + + // Subscribe to events + const unsubHand = client.on('hand', handleHandEvent); + const unsubWorkflow = client.on('workflow', handleWorkflowEvent); + const unsubApproval = client.on('approval', handleApprovalEvent); + + unsubscribersRef.current = [unsubHand, unsubWorkflow, unsubApproval]; + + // Cleanup on unmount or client change + return () => { + unsubscribersRef.current.forEach(unsub => unsub()); + unsubscribersRef.current = []; + }; + }, [ + client, + injectResultsToChat, + refreshOnStatusChange, + loadHands, + loadHandRuns, + loadApprovals, + loadWorkflows, + loadWorkflowRuns, + addMessage, + onHandEvent, + onWorkflowEvent, + onApprovalEvent, + ]); +} + +// === Utility Hooks === + +/** + * Hook for subscribing to a specific hand's events only + */ +export function useHandEvents( + client: GatewayClient | null, + handName: string, + onEvent?: (data: HandEventData) => void +): void { + useEffect(() => { + if (!client || !handName) return; + + const handler = (data: unknown) => { + if (isHandEvent(data) && (data as HandEventData).hand_name === handName) { + onEvent?.(data as HandEventData); + } + }; + + const unsub = client.on('hand', handler); + return unsub; + }, [client, handName, onEvent]); +} + +/** + * Hook for subscribing to a specific workflow's events only + */ +export function useWorkflowEvents( + client: GatewayClient | null, + workflowId: string, + onEvent?: (data: WorkflowEventData) => void +): void { + useEffect(() => { + if (!client || !workflowId) return; + + const handler = (data: unknown) => { + if (isWorkflowEvent(data) && (data as WorkflowEventData).workflow_id === workflowId) { + onEvent?.(data as WorkflowEventData); + } + }; + + const unsub = client.on('workflow', handler); + return unsub; + }, [client, workflowId, onEvent]); +} + +export default useAutomationEvents; diff --git a/desktop/src/types/automation.ts b/desktop/src/types/automation.ts new file mode 100644 index 0000000..ec65838 --- /dev/null +++ b/desktop/src/types/automation.ts @@ -0,0 +1,363 @@ +/** + * Automation Type Adapters for ZCLAW + * + * This module provides unified types for the Automation system, + * combining Hands and Workflows into a single AutomationItem type. + * + * @module types/automation + */ + +import type { Hand, HandStatus, HandParameter } from './hands'; +import type { Workflow, WorkflowRunStatus } from './workflow'; + +// === Category Types === + +/** + * Category types for classifying automation items + */ +export type CategoryType = 'all' | 'research' | 'data' | 'automation' | 'communication' | 'content' | 'productivity'; + +/** + * Category configuration for display + */ +export interface CategoryConfig { + id: CategoryType; + label: string; + icon: string; + description: string; +} + +/** + * Category statistics for filtering UI + */ +export interface CategoryStats { + all: number; + research: number; + data: number; + automation: number; + communication: number; + content: number; + productivity: number; +} + +// === Category Mapping for Hands === + +/** + * Maps Hand IDs to their categories + */ +export const HAND_CATEGORY_MAP: Record = { + researcher: 'research', + browser: 'research', + collector: 'data', + predictor: 'data', + lead: 'communication', + twitter: 'communication', + clip: 'content', +}; + +/** + * Category configurations for UI display + */ +export const CATEGORY_CONFIGS: Record = { + all: { + id: 'all', + label: '全部', + icon: 'Layers', + description: '所有自动化项目', + }, + research: { + id: 'research', + label: '研究', + icon: 'Search', + description: '深度研究和浏览器自动化', + }, + data: { + id: 'data', + label: '数据', + icon: 'Database', + description: '数据收集和预测分析', + }, + automation: { + id: 'automation', + label: '自动化', + icon: 'Zap', + description: '工作流和触发器', + }, + communication: { + id: 'communication', + label: '通信', + icon: 'MessageSquare', + description: '销售线索和社交媒体', + }, + content: { + id: 'content', + label: '内容', + icon: 'Video', + description: '视频和内容处理', + }, + productivity: { + id: 'productivity', + label: '生产力', + icon: 'TrendingUp', + description: '效率提升工具', + }, +}; + +// === Automation Item (Unified Type) === + +/** + * Execution status for automation items + */ +export type AutomationStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed' | 'completed' | 'paused'; + +/** + * Item type discriminator + */ +export type AutomationType = 'hand' | 'workflow'; + +/** + * Run information for last execution + */ +export interface RunInfo { + runId: string; + status: AutomationStatus; + startedAt: string; + completedAt?: string; + duration?: number; + output?: unknown; + error?: string; +} + +/** + * Schedule information for automation items + */ +export interface ScheduleInfo { + enabled: boolean; + frequency: 'once' | 'daily' | 'weekly' | 'monthly' | 'custom'; + time: { hour: number; minute: number }; + daysOfWeek?: number[]; // 0-6 for weekly + dayOfMonth?: number; // 1-31 for monthly + customCron?: string; // Advanced mode + timezone: string; + endDate?: string; + nextRun?: string; +} + +/** + * Unified automation item type + * Adapts both Hand and Workflow into a common interface + */ +export interface AutomationItem { + // Identity + id: string; + name: string; + description: string; + type: AutomationType; + category: CategoryType; + + // Status + status: AutomationStatus; + error?: string; + + // Configuration + parameters?: HandParameter[]; + requiresApproval: boolean; + + // Execution info + lastRun?: RunInfo; + schedule?: ScheduleInfo; + currentRunId?: string; + + // Display + icon?: string; + + // Type-specific data + handData?: Hand; + workflowData?: Workflow; +} + +// === Type Adapters === + +/** + * Converts Hand status to Automation status + */ +export function handStatusToAutomationStatus(status: HandStatus): AutomationStatus { + const statusMap: Record = { + idle: 'idle', + running: 'running', + needs_approval: 'needs_approval', + error: 'error', + unavailable: 'unavailable', + setup_needed: 'setup_needed', + }; + return statusMap[status] || 'unavailable'; +} + +/** + * Converts Workflow run status to Automation status + */ +export function workflowStatusToAutomationStatus(status: WorkflowRunStatus): AutomationStatus { + const statusMap: Record = { + pending: 'idle', + running: 'running', + completed: 'completed', + failed: 'error', + cancelled: 'idle', + paused: 'paused', + }; + return statusMap[status] || 'idle'; +} + +/** + * Adapts a Hand to an AutomationItem + */ +export function handToAutomationItem(hand: Hand): AutomationItem { + const category = HAND_CATEGORY_MAP[hand.id] || HAND_CATEGORY_MAP[hand.name.toLowerCase()] || 'productivity'; + + return { + id: hand.id, + name: hand.name, + description: hand.description, + type: 'hand', + category, + status: handStatusToAutomationStatus(hand.status), + error: hand.error, + parameters: hand.parameters, + requiresApproval: false, // Will be determined by execution result + lastRun: hand.lastRun ? { + runId: hand.lastRun, + status: 'completed', + startedAt: hand.lastRun, + } : undefined, + icon: hand.icon, + handData: hand, + }; +} + +/** + * Adapts a Workflow to an AutomationItem + * Handles both store Workflow (steps: number) and full Workflow (steps: WorkflowStep[]) + */ +export function workflowToAutomationItem(workflow: Workflow | { id: string; name: string; steps: number; description?: string; createdAt?: string }): AutomationItem { + // For store workflows with steps as number, default to automation category + const stepsArray = Array.isArray(workflow.steps) ? workflow.steps : []; + + // Determine category based on workflow steps (only if steps is an array) + let category: CategoryType = 'automation'; + if (stepsArray.length > 0 && 'handName' in stepsArray[0]) { + const typedSteps = stepsArray as Array<{ handName?: string }>; + if (typedSteps.some(s => s.handName === 'researcher' || s.handName === 'browser')) { + category = 'research'; + } else if (typedSteps.some(s => s.handName === 'collector' || s.handName === 'predictor')) { + category = 'data'; + } else if (typedSteps.some(s => s.handName === 'lead' || s.handName === 'twitter')) { + category = 'communication'; + } else if (typedSteps.some(s => s.handName === 'clip')) { + category = 'content'; + } + } + + return { + id: workflow.id, + name: workflow.name, + description: workflow.description || '', + type: 'workflow', + category, + status: 'idle', + requiresApproval: false, + workflowData: 'steps' in workflow && Array.isArray(workflow.steps) ? workflow as Workflow : undefined, + }; +} + +/** + * Store Workflow type (from gatewayStore/workflowStore) + * Has steps as number (count) instead of array + */ +export interface StoreWorkflow { + id: string; + name: string; + steps: number; + description?: string; + createdAt?: string; +} + +/** + * Adapts an array of Hands and Workflows to AutomationItems + * Accepts both full Workflow type and store Workflow type + */ +export function adaptToAutomationItems( + hands: Hand[] = [], + workflows: (Workflow | StoreWorkflow)[] = [] +): AutomationItem[] { + const handItems = hands.map(handToAutomationItem); + const workflowItems = workflows.map(workflowToAutomationItem); + return [...handItems, ...workflowItems]; +} + +/** + * Calculates category statistics from automation items + */ +export function calculateCategoryStats(items: AutomationItem[]): CategoryStats { + const stats: CategoryStats = { + all: items.length, + research: 0, + data: 0, + automation: 0, + communication: 0, + content: 0, + productivity: 0, + }; + + for (const item of items) { + if (item.category !== 'all') { + stats[item.category]++; + } + } + + return stats; +} + +/** + * Filters automation items by category + */ +export function filterByCategory(items: AutomationItem[], category: CategoryType): AutomationItem[] { + if (category === 'all') { + return items; + } + return items.filter(item => item.category === category); +} + +/** + * Filters automation items by type + */ +export function filterByType(items: AutomationItem[], type: AutomationType | 'all'): AutomationItem[] { + if (type === 'all') { + return items; + } + return items.filter(item => item.type === type); +} + +/** + * Filters automation items by status + */ +export function filterByStatus(items: AutomationItem[], statuses: AutomationStatus[]): AutomationItem[] { + if (statuses.length === 0) { + return items; + } + return items.filter(item => statuses.includes(item.status)); +} + +/** + * Searches automation items by name or description + */ +export function searchAutomationItems(items: AutomationItem[], query: string): AutomationItem[] { + if (!query.trim()) { + return items; + } + const lowerQuery = query.toLowerCase(); + return items.filter( + item => + item.name.toLowerCase().includes(lowerQuery) || + item.description.toLowerCase().includes(lowerQuery) + ); +} diff --git a/desktop/src/types/index.ts b/desktop/src/types/index.ts index 9b4c1cc..763ff69 100644 --- a/desktop/src/types/index.ts +++ b/desktop/src/types/index.ts @@ -149,3 +149,31 @@ export { createErrorResponse, createPaginatedResponse, } from './api-responses'; + +// Automation Types +export type { + CategoryType, + CategoryConfig, + CategoryStats, + AutomationStatus, + AutomationType, + RunInfo, + ScheduleInfo, + AutomationItem, +} from './automation'; + +// Automation Constants and Functions +export { + HAND_CATEGORY_MAP, + CATEGORY_CONFIGS, + handStatusToAutomationStatus, + workflowStatusToAutomationStatus, + handToAutomationItem, + workflowToAutomationItem, + adaptToAutomationItems, + calculateCategoryStats, + filterByCategory, + filterByType, + filterByStatus, + searchAutomationItems, +} from './automation';