feat(automation): implement unified automation system with Hands and Workflows

Phase 1 - Core Fixes:
- Fix parameter passing in HandsPanel (params now passed to triggerHand)
- Migrate HandsPanel from useGatewayStore to useHandStore
- Add type adapters and category mapping for 7 Hands
- Create useAutomationEvents hook for WebSocket event handling

Phase 2 - UI Components:
- Create AutomationPanel as unified entry point
- Create AutomationCard with grid/list view support
- Create AutomationFilters with category tabs and search
- Create BatchActionBar for batch operations

Phase 3 - Advanced Features:
- Create ScheduleEditor with visual scheduling (no cron syntax)
- Support frequency: once, daily, weekly, monthly, custom
- Add timezone selection and end date options

Technical Details:
- AutomationItem type unifies Hand and Workflow
- CategoryType: research, data, automation, communication, content, productivity
- ScheduleInfo interface for scheduling configuration
- WebSocket events: hand, workflow, approval

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-18 16:32:18 +08:00
parent dfeb286591
commit 3a7631e035
11 changed files with 2321 additions and 40 deletions

View File

@@ -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<string, unknown>) => 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<string, unknown>) => 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<Hand | null>(null);
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>('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<string, unknown>) => {
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<string, unknown>) => {
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() {
</button>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
</span>
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('hands')}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'hands'
? 'text-orange-600 dark:text-orange-400 border-orange-500'
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<Zap className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('approvals')}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'approvals'
? 'text-orange-600 dark:text-orange-400 border-orange-500'
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<Clock className="w-4 h-4" />
</button>
</div>
{/* Hand Cards Grid */}
<div className="grid gap-3">
{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' ? (
<ApprovalsPanel />
) : (
<>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
</span>
</div>
{/* Hand Cards Grid */}
<div className="grid gap-3">
{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 ? (
<BrowserHandCard
@@ -571,17 +622,19 @@ export function HandsPanel() {
/>
);
})}
</div>
</div>
{/* Details Modal */}
{selectedHand && (
<HandDetailsModal
hand={selectedHand}
isOpen={showModal}
onClose={handleCloseModal}
onActivate={handleModalActivate}
isActivating={activatingHandId === selectedHand.id}
/>
{/* Details Modal */}
{selectedHand && (
<HandDetailsModal
hand={selectedHand}
isOpen={showModal}
onClose={handleCloseModal}
onActivate={handleModalActivate}
isActivating={activatingHandId === selectedHand.id}
/>
)}
</>
)}
</div>
);