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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user