chore: 清理40个死代码文件 (~9,639行)

删除无任何活跃渲染路径引用的组件:
- Automation/ 全目录 (7文件, 2,598行)
- WorkflowBuilder/ 全目录 (14文件, 1,539行)
- SchedulerPanel + 依赖树 (5文件, 2,595行)
- 独立死组件 (14文件, 2,907行)
  含 SkillMarket, HandsPanel, ErrorNotification 等
- PipelineResultPreview 根目录副本 (534行, 活跃版在 pipeline/)
This commit is contained in:
iven
2026-04-11 00:26:04 +08:00
parent e790cf171a
commit 717f2eab4f
39 changed files with 0 additions and 10495 deletions

View File

@@ -1,352 +0,0 @@
/**
* ApprovalQueue - Approval Management Component
*
* Displays pending approvals for hand executions that require
* human approval, with approve/reject actions.
*
* @module components/Automation/ApprovalQueue
*/
import { useState, useEffect, useCallback } from 'react';
import { useHandStore } from '../../store/handStore';
import type { Approval, ApprovalStatus } from '../../store/handStore';
import {
Clock,
CheckCircle,
XCircle,
AlertTriangle,
RefreshCw,
} from 'lucide-react';
import { useToast } from '../ui/Toast';
// === Status Config ===
const STATUS_CONFIG: Record<ApprovalStatus, {
label: string;
className: string;
icon: typeof CheckCircle;
}> = {
pending: {
label: '待处理',
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
icon: Clock,
},
approved: {
label: '已批准',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
icon: CheckCircle,
},
rejected: {
label: '已拒绝',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
icon: XCircle,
},
expired: {
label: '已过期',
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
icon: AlertTriangle,
},
};
// === Component Props ===
interface ApprovalQueueProps {
showFilters?: boolean;
maxHeight?: string;
onApprove?: (approval: Approval) => void;
onReject?: (approval: Approval) => void;
}
// === Approval Card Component ===
interface ApprovalCardProps {
approval: Approval;
onApprove: () => Promise<void>;
onReject: (reason: string) => Promise<void>;
isProcessing: boolean;
}
function ApprovalCard({ approval, onApprove, onReject, isProcessing }: ApprovalCardProps) {
const [showRejectInput, setShowRejectInput] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const StatusIcon = STATUS_CONFIG[approval.status].icon;
const handleReject = useCallback(async () => {
if (!rejectReason.trim()) {
setShowRejectInput(true);
return;
}
await onReject(rejectReason);
setShowRejectInput(false);
setRejectReason('');
}, [rejectReason, onReject]);
const timeAgo = useCallback((dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return '刚刚';
if (diffMins < 60) return `${diffMins} 分钟前`;
if (diffHours < 24) return `${diffHours} 小时前`;
return `${diffDays} 天前`;
}, []);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_CONFIG[approval.status].className}`}>
<StatusIcon className="w-3 h-3 inline mr-1" />
{STATUS_CONFIG[approval.status].label}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{timeAgo(approval.requestedAt)}
</span>
</div>
</div>
{/* Content */}
<div className="mb-3">
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
{approval.handName}
</h4>
{approval.reason && (
<p className="text-sm text-gray-600 dark:text-gray-400">{approval.reason}</p>
)}
{approval.action && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
: {approval.action}
</p>
)}
</div>
{/* Params Preview */}
{approval.params && Object.keys(approval.params).length > 0 && (
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-900 rounded text-xs">
<p className="text-gray-500 dark:text-gray-400 mb-1">:</p>
<pre className="text-gray-700 dark:text-gray-300 overflow-x-auto">
{JSON.stringify(approval.params, null, 2)}
</pre>
</div>
)}
{/* Reject Input */}
{showRejectInput && (
<div className="mb-3">
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="请输入拒绝原因..."
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white resize-none"
rows={2}
/>
</div>
)}
{/* Actions */}
{approval.status === 'pending' && (
<div className="flex items-center gap-2">
<button
onClick={onApprove}
disabled={isProcessing}
className="flex-1 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
>
{isProcessing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={handleReject}
disabled={isProcessing}
className="flex-1 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
>
{isProcessing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
</button>
</div>
)}
{/* Response Info */}
{approval.status !== 'pending' && approval.respondedAt && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{approval.respondedBy && `${approval.respondedBy} `}
{STATUS_CONFIG[approval.status].label}
{approval.responseReason && ` - ${approval.responseReason}`}
</div>
)}
</div>
);
}
// === Main Component ===
export function ApprovalQueue({
showFilters = true,
maxHeight = '400px',
onApprove,
onReject,
}: ApprovalQueueProps) {
const { toast } = useToast();
// Store state
const approvals = useHandStore(s => s.approvals);
const loadApprovals = useHandStore(s => s.loadApprovals);
const respondToApproval = useHandStore(s => s.respondToApproval);
const isLoading = useHandStore(s => s.isLoading);
// Local state
const [statusFilter, setStatusFilter] = useState<ApprovalStatus | 'all'>('pending');
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
// Load approvals on mount
useEffect(() => {
loadApprovals(statusFilter === 'all' ? undefined : statusFilter);
}, [loadApprovals, statusFilter]);
// Handle approve
const handleApprove = useCallback(async (approval: Approval) => {
setProcessingIds(prev => new Set(prev).add(approval.id));
try {
await respondToApproval(approval.id, true);
toast(`已批准: ${approval.handName}`, 'success');
onApprove?.(approval);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
toast(`批准失败: ${errorMsg}`, 'error');
} finally {
setProcessingIds(prev => {
const next = new Set(prev);
next.delete(approval.id);
return next;
});
}
}, [respondToApproval, toast, onApprove]);
// Handle reject
const handleReject = useCallback(async (approval: Approval, reason: string) => {
setProcessingIds(prev => new Set(prev).add(approval.id));
try {
await respondToApproval(approval.id, false, reason);
toast(`已拒绝: ${approval.handName}`, 'success');
onReject?.(approval);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
toast(`拒绝失败: ${errorMsg}`, 'error');
} finally {
setProcessingIds(prev => {
const next = new Set(prev);
next.delete(approval.id);
return next;
});
}
}, [respondToApproval, toast, onReject]);
// Filter approvals
const filteredApprovals = statusFilter === 'all'
? approvals
: approvals.filter(a => a.status === statusFilter);
// Stats
const stats = {
pending: approvals.filter(a => a.status === 'pending').length,
approved: approvals.filter(a => a.status === 'approved').length,
rejected: approvals.filter(a => a.status === 'rejected').length,
expired: approvals.filter(a => a.status === 'expired').length,
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
{stats.pending > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 rounded-full">
{stats.pending}
</span>
)}
</div>
<button
onClick={() => loadApprovals(statusFilter === 'all' ? undefined : statusFilter)}
disabled={isLoading}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Filters */}
{showFilters && (
<div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
{[
{ value: 'pending', label: '待处理', count: stats.pending },
{ value: 'approved', label: '已批准', count: stats.approved },
{ value: 'rejected', label: '已拒绝', count: stats.rejected },
{ value: 'all', label: '全部', count: approvals.length },
].map(option => (
<button
key={option.value}
onClick={() => setStatusFilter(option.value as ApprovalStatus | 'all')}
className={`flex items-center gap-1 px-3 py-1 text-sm rounded-full whitespace-nowrap transition-colors ${
statusFilter === option.value
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{option.label}
<span className={`text-xs ${statusFilter === option.value ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
({option.count})
</span>
</button>
))}
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4" style={{ maxHeight }}>
{isLoading && approvals.length === 0 ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : filteredApprovals.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<Clock className="w-8 h-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{statusFilter === 'pending' ? '暂无待处理的审批' : '暂无审批记录'}
</p>
</div>
) : (
<div className="space-y-3">
{filteredApprovals.map(approval => (
<ApprovalCard
key={approval.id}
approval={approval}
onApprove={() => handleApprove(approval)}
onReject={(reason) => handleReject(approval, reason)}
isProcessing={processingIds.has(approval.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
export default ApprovalQueue;

View File

@@ -1,402 +0,0 @@
/**
* 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<AutomationStatus, {
label: string;
className: string;
dotClass: string;
icon?: typeof CheckCircle;
}> = {
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<string, unknown>) => void;
onClick?: () => void;
}
// === Status Badge Component ===
function StatusBadge({ status }: { status: AutomationStatus }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.unavailable;
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
{Icon ? (
<Icon className={`w-3 h-3 ${status === 'running' ? 'animate-spin' : ''}`} />
) : (
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
)}
{config.label}
</span>
);
}
// === Type Badge Component ===
function TypeBadge({ type }: { type: 'hand' | 'workflow' }) {
const isHand = type === 'hand';
return (
<span className={`px-2 py-0.5 rounded text-xs ${
isHand
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
}`}>
{isHand ? '自主能力' : '工作流'}
</span>
);
}
// === Category Badge Component ===
function CategoryBadge({ category }: { category: string }) {
const config = CATEGORY_CONFIGS[category as keyof typeof CATEGORY_CONFIGS];
if (!config) return null;
return (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
{config.label}
</span>
);
}
// === 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<Record<string, unknown>>({});
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
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<string, unknown> = {};
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<string, string> = {};
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<HTMLInputElement>) => {
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<string, string> = {
Video: '🎬',
UserPlus: '👤',
Database: '🗄️',
TrendingUp: '📈',
Search: '🔍',
Twitter: '🐦',
Globe: '🌐',
Zap: '⚡',
};
return iconMap[item.icon] || '🤖';
}
return item.type === 'hand' ? '🤖' : '📋';
};
if (viewMode === 'list') {
return (
<div
className={`flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border transition-all cursor-pointer ${
isSelected
? 'border-orange-500 ring-1 ring-orange-500'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={onClick}
>
{/* Checkbox */}
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
onClick={e => e.stopPropagation()}
/>
{/* Icon */}
<span className="text-xl flex-shrink-0">{getItemIcon()}</span>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
<TypeBadge type={item.type} />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{item.description}</p>
</div>
{/* Status */}
<StatusBadge status={item.status} />
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
handleExecuteClick();
}}
disabled={!canActivate || isExecuting}
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
{isExecuting ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</>
) : (
<>
<Play className="w-3.5 h-3.5" />
</>
)}
</button>
<button
onClick={e => e.stopPropagation()}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
);
}
// Grid view
return (
<div
className={`relative bg-white dark:bg-gray-800 rounded-lg border transition-all ${
isSelected
? 'border-orange-500 ring-1 ring-orange-500'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={onClick}
>
{/* Selection checkbox */}
<div className="absolute top-2 left-2">
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
onClick={e => e.stopPropagation()}
/>
</div>
{/* Content */}
<div className="p-4 pt-8">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl flex-shrink-0">{getItemIcon()}</span>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
</div>
<StatusBadge status={item.status} />
</div>
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{item.description}</p>
{/* Meta */}
<div className="flex items-center gap-2 mb-3">
<TypeBadge type={item.type} />
<CategoryBadge category={item.category} />
{item.schedule?.enabled && (
<span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<Clock className="w-3 h-3" />
</span>
)}
</div>
{/* Parameters Form (shown when activating) */}
{showParams && item.parameters && item.parameters.length > 0 && (
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<HandParamsForm
parameters={item.parameters as HandParameter[]}
values={paramValues}
onChange={setParamValues}
errors={paramErrors}
disabled={isExecuting}
presetKey={`${item.type}-${item.id}`}
/>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleExecuteClick();
}}
disabled={!canActivate || isExecuting}
className="flex-1 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
>
{isExecuting ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : showParams ? (
<>
<Play className="w-3.5 h-3.5" />
</>
) : (
<>
<Zap className="w-3.5 h-3.5" />
</>
)}
</button>
<button
onClick={e => e.stopPropagation()}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="更多选项"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
{/* Schedule indicator */}
{item.schedule?.nextRun && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
: {new Date(item.schedule.nextRun).toLocaleString('zh-CN')}
</div>
)}
</div>
</div>
);
}
export default AutomationCard;

View File

@@ -1,204 +0,0 @@
/**
* 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<CategoryType, typeof Layers> = {
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<HTMLInputElement>) => {
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 (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 space-y-3">
{/* Search and View Mode Row */}
<div className="flex items-center gap-3">
{/* Search Input */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索 Hands 或工作流..."
value={searchQuery}
onChange={handleSearchChange}
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
{/* View Mode Toggle */}
<div className="flex items-center border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => onViewModeChange('grid')}
className={`p-2 ${
viewMode === 'grid'
? 'bg-orange-500 text-white'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="网格视图"
>
<Grid className="w-4 h-4" />
</button>
<button
onClick={() => onViewModeChange('list')}
className={`p-2 ${
viewMode === 'list'
? 'bg-orange-500 text-white'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="列表视图"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
{/* Category Tabs (Desktop) */}
<div className="hidden md:flex items-center gap-1 overflow-x-auto pb-1">
{categories.map(({ id, label, count }) => {
const Icon = CATEGORY_ICONS[id];
const isSelected = selectedCategory === id;
return (
<button
key={id}
onClick={() => onCategoryChange(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full whitespace-nowrap transition-colors ${
isSelected
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
{count > 0 && (
<span className={`text-xs ${isSelected ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
({count})
</span>
)}
</button>
);
})}
</div>
{/* Category Dropdown (Mobile) */}
<div className="md:hidden relative">
<button
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<div className="flex items-center gap-2">
{(() => {
const Icon = CATEGORY_ICONS[selectedCategory];
return <Icon className="w-4 h-4" />;
})()}
<span>{selectedConfig.label}</span>
<span className="text-gray-500 dark:text-gray-400">
({categoryStats[selectedCategory] || 0})
</span>
</div>
<ChevronDown className={`w-4 h-4 transition-transform ${showCategoryDropdown ? 'rotate-180' : ''}`} />
</button>
{showCategoryDropdown && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10 max-h-64 overflow-y-auto">
{categories.map(({ id, label, count }) => {
const Icon = CATEGORY_ICONS[id];
const isSelected = selectedCategory === id;
return (
<button
key={id}
onClick={() => handleCategoryClick(id)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm ${
isSelected
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
<span>{label}</span>
</div>
<span className="text-gray-500 dark:text-gray-400">{count}</span>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
export default AutomationFilters;

View File

@@ -1,653 +0,0 @@
/**
* AutomationPanel - Unified Automation Entry Point
*
* Combines Pipelines, 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 { ScheduleEditor } from './ScheduleEditor';
import { PipelinesPanel } from '../PipelinesPanel';
import {
Zap,
RefreshCw,
Plus,
Calendar,
Search,
X,
Package,
Bot,
Workflow,
Trash2,
Clock,
} from 'lucide-react';
import { useToast } from '../ui/Toast';
import type { ScheduleInfo } from '../../types/automation';
// === View Mode ===
type ViewMode = 'grid' | 'list';
// === Tab Type ===
type AutomationTab = 'pipelines' | 'hands' | 'workflows';
// === Component Props ===
interface AutomationPanelProps {
initialCategory?: CategoryType;
initialTab?: AutomationTab;
onSelect?: (item: AutomationItem) => void;
showBatchActions?: boolean;
}
// === Tab Configuration ===
const TAB_CONFIG: { key: AutomationTab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'pipelines', label: 'Pipelines', icon: Package },
{ key: 'hands', label: 'Hands', icon: Bot },
{ key: 'workflows', label: 'Workflows', icon: Workflow },
];
// === Main Component ===
export function AutomationPanel({
initialCategory = 'all',
initialTab = 'pipelines',
onSelect,
showBatchActions = true,
}: AutomationPanelProps) {
// Store state - use domain stores
const hands = useHandStore((s) => s.hands);
const workflows = useWorkflowStore((s) => s.workflows);
const handLoading = useHandStore((s) => s.isLoading);
const workflowLoading = useWorkflowStore((s) => s.isLoading);
const isLoading = handLoading || workflowLoading;
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 [activeTab, setActiveTab] = useState<AutomationTab>(initialTab);
const [selectedCategory, setSelectedCategory] = useState<CategoryType>(initialCategory);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
const [showWorkflowDialog, setShowWorkflowDialog] = useState(false);
const [showSchedulerDialog, setShowSchedulerDialog] = useState(false);
const [showBatchScheduleDialog, setShowBatchScheduleDialog] = useState(false);
const [workflowName, setWorkflowName] = useState('');
const [workflowDescription, setWorkflowDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [schedules, setSchedules] = useState<Record<string, ScheduleInfo>>({});
const { toast } = useToast();
// Load data on mount
useEffect(() => {
loadHands();
loadWorkflows();
}, [loadHands, loadWorkflows]);
// Adapt hands and workflows to automation items
const automationItems = useMemo<AutomationItem[]>(() => {
return adaptToAutomationItems(hands, workflows);
}, [hands, workflows]);
// Calculate category stats
const categoryStats = useMemo<CategoryStats>(() => {
return calculateCategoryStats(automationItems);
}, [automationItems]);
// Filter and search items
const filteredItems = useMemo<AutomationItem[]>(() => {
let items = filterByCategory(automationItems, selectedCategory);
if (searchQuery.trim()) {
items = searchAutomationItems(items, searchQuery);
}
// Filter by tab
if (activeTab === 'hands') {
items = items.filter(item => item.type === 'hand');
} else if (activeTab === 'workflows') {
items = items.filter(item => item.type === 'workflow');
}
return items;
}, [automationItems, selectedCategory, searchQuery, activeTab]);
// 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());
}, []);
// Workflow dialog handlers
const handleCreateWorkflow = useCallback(() => {
setShowWorkflowDialog(true);
setWorkflowName('');
setWorkflowDescription('');
}, []);
const handleSchedulerManage = useCallback(() => {
setShowSchedulerDialog(true);
}, []);
// Create workflow handler
const handleWorkflowCreate = useCallback(async () => {
if (!workflowName.trim()) {
toast('请输入工作流名称', 'error');
return;
}
setIsCreating(true);
try {
const createWorkflow = useWorkflowStore.getState().createWorkflow;
const result = await createWorkflow({
name: workflowName.trim(),
description: workflowDescription.trim() || undefined,
steps: [], // Empty workflow, user will add steps later
});
if (result) {
toast(`工作流 "${result.name}" 创建成功`, 'success');
setShowWorkflowDialog(false);
setWorkflowName('');
setWorkflowDescription('');
// Reload workflows
await loadWorkflows();
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '创建工作流失败';
toast(errorMsg, 'error');
} finally {
setIsCreating(false);
}
}, [workflowName, workflowDescription, toast, loadWorkflows]);
// Batch schedule handler
const handleBatchSchedule = useCallback(() => {
if (selectedIds.size === 0) {
toast('请先选择要调度的项目', 'info');
return;
}
setShowBatchScheduleDialog(true);
}, [selectedIds.size, toast]);
// Save batch schedule
const handleSaveBatchSchedule = useCallback((schedule: ScheduleInfo) => {
// Save schedule for all selected items
const newSchedules: Record<string, ScheduleInfo> = {};
selectedIds.forEach(id => {
newSchedules[id] = schedule;
});
setSchedules(prev => ({ ...prev, ...newSchedules }));
setShowBatchScheduleDialog(false);
setSelectedIds(new Set());
const frequencyLabels = {
once: '一次性',
daily: '每天',
weekly: '每周',
monthly: '每月',
custom: '自定义',
};
toast(`已为 ${Object.keys(newSchedules).length} 个项目设置${frequencyLabels[schedule.frequency]}调度`, 'success');
}, [selectedIds, toast]);
// Delete schedule
const handleDeleteSchedule = useCallback((itemId: string) => {
setSchedules(prev => {
const { [itemId]: _, ...rest } = prev;
return rest;
});
toast('调度已删除', 'success');
}, [toast]);
// Toggle schedule enabled
const handleToggleScheduleEnabled = useCallback((itemId: string, enabled: boolean) => {
setSchedules(prev => ({
...prev,
[itemId]: { ...prev[itemId], enabled },
}));
}, []);
// Execute handler
const handleExecute = useCallback(async (item: AutomationItem, params?: Record<string, unknown>) => {
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]);
// If Pipelines tab is active, show PipelinesPanel directly
if (activeTab === 'pipelines') {
return (
<div className="flex flex-col h-full">
{/* Header with Tabs */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
</div>
{/* Tab Switcher */}
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
{TAB_CONFIG.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === key
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
</div>
{/* Pipelines Panel */}
<div className="flex-1 overflow-hidden">
<PipelinesPanel />
</div>
</div>
);
}
// Hands and Workflows tabs
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({automationItems.length})
</span>
</div>
<div className="flex items-center gap-2">
{/* Tab Switcher */}
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mr-2">
{TAB_CONFIG.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === key
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
<button
onClick={handleRefresh}
disabled={isLoading}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleCreateWorkflow}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title="新建工作流"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={handleSchedulerManage}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title="调度管理"
>
<Calendar className="w-4 h-4" />
</button>
</div>
</div>
{/* Filters */}
<AutomationFilters
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
viewMode={viewMode}
onViewModeChange={setViewMode}
categoryStats={categoryStats}
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading && automationItems.length === 0 ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<Search className="w-8 h-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{searchQuery ? '没有找到匹配的项目' : '暂无自动化项目'}
</p>
</div>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
: 'flex flex-col gap-2'
}>
{filteredItems.map(item => (
<AutomationCard
key={item.id}
item={item}
viewMode={viewMode}
isSelected={selectedIds.has(item.id)}
isExecuting={executingIds.has(item.id)}
onSelect={(selected) => handleSelect(item.id, selected)}
onExecute={(params) => handleExecute(item, params)}
onClick={() => onSelect?.(item)}
/>
))}
</div>
)}
</div>
{/* Batch Actions */}
{showBatchActions && selectedIds.size > 0 && (
<BatchActionBar
selectedCount={selectedIds.size}
totalCount={filteredItems.length}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
onBatchExecute={handleBatchExecute}
onBatchSchedule={handleBatchSchedule}
/>
)}
{/* Create Workflow Dialog */}
{showWorkflowDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowWorkflowDialog(false)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={workflowName}
onChange={(e) => setWorkflowName(e.target.value)}
placeholder="输入工作流名称..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isCreating}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={workflowDescription}
onChange={(e) => setWorkflowDescription(e.target.value)}
placeholder="描述这个工作流的用途..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
disabled={isCreating}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
</div>
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowWorkflowDialog(false)}
disabled={isCreating}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50"
>
</button>
<button
onClick={handleWorkflowCreate}
disabled={isCreating || !workflowName.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isCreating && <RefreshCw className="w-4 h-4 animate-spin" />}
{isCreating ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)}
{/* Scheduler Dialog */}
{showSchedulerDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col">
<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">
<Calendar className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"></h3>
</div>
<button
onClick={() => setShowSchedulerDialog(false)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{Object.keys(schedules).length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<Clock className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium mb-1"></p>
<p className="text-sm">"批量调度"</p>
</div>
) : (
<div className="space-y-3">
{Object.entries(schedules).map(([itemId, schedule]) => {
const item = automationItems.find(i => i.id === itemId);
if (!item) return null;
const frequencyLabels = {
once: '一次性',
daily: '每天',
weekly: '每周',
monthly: '每月',
custom: '自定义',
};
const timeStr = `${schedule.time.hour.toString().padStart(2, '0')}:${schedule.time.minute.toString().padStart(2, '0')}`;
return (
<div
key={itemId}
className={`flex items-center justify-between p-4 rounded-lg border ${
schedule.enabled
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
schedule.enabled
? 'bg-orange-100 dark:bg-orange-900/30'
: 'bg-gray-200 dark:bg-gray-700'
}`}>
<Clock className={`w-4 h-4 ${
schedule.enabled
? 'text-orange-600 dark:text-orange-400'
: 'text-gray-400'
}`} />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{item.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{frequencyLabels[schedule.frequency]} · {timeStr}
{schedule.nextRun && ` · 下次: ${new Date(schedule.nextRun).toLocaleString()}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Toggle Enabled */}
<button
onClick={() => handleToggleScheduleEnabled(itemId, !schedule.enabled)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
schedule.enabled ? 'bg-orange-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
schedule.enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
{/* Delete */}
<button
onClick={() => handleDeleteSchedule(itemId)}
className="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
title="删除调度"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-between items-center p-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400">
{Object.keys(schedules).length}
</p>
<button
onClick={() => setShowSchedulerDialog(false)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
</button>
</div>
</div>
</div>
)}
{/* Batch Schedule Dialog */}
{showBatchScheduleDialog && (
<ScheduleEditor
itemName={`已选择 ${selectedIds.size} 个项目`}
onSave={handleSaveBatchSchedule}
onCancel={() => setShowBatchScheduleDialog(false)}
/>
)}
</div>
);
}
export default AutomationPanel;

View File

@@ -1,216 +0,0 @@
/**
* 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<void>;
onBatchApprove?: () => Promise<void>;
onBatchReject?: () => Promise<void>;
onBatchSchedule?: () => void;
onBatchDelete?: () => Promise<void>;
onBatchDuplicate?: () => Promise<void>;
}
// === 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 (
<div className="sticky bottom-0 left-0 right-0 bg-orange-50 dark:bg-orange-900/20 border-t border-orange-200 dark:border-orange-800 px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Selection Info */}
<div className="flex items-center gap-3">
<span className="text-sm text-orange-700 dark:text-orange-300">
<span className="font-medium">{selectedCount}</span>
</span>
<div className="flex items-center gap-1">
<button
onClick={onSelectAll}
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
>
</button>
<span className="text-orange-400 dark:text-orange-600">|</span>
<button
onClick={onDeselectAll}
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
>
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Execute */}
<button
onClick={handleExecute}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="w-3.5 h-3.5" />
</button>
{/* Approve (if handler provided) */}
{onBatchApprove && (
<button
onClick={handleApprove}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Check className="w-3.5 h-3.5" />
</button>
)}
{/* Reject (if handler provided) */}
{onBatchReject && (
<button
onClick={handleReject}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{/* Schedule */}
{onBatchSchedule && (
<button
onClick={onBatchSchedule}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 rounded-md hover:bg-orange-100 dark:hover:bg-orange-900/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Clock className="w-3.5 h-3.5" />
</button>
)}
{/* More Actions */}
{(onBatchDelete || onBatchDuplicate) && (
<div className="relative">
<button
onClick={() => setShowMoreMenu(!showMoreMenu)}
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{showMoreMenu && (
<div className="absolute bottom-full right-0 mb-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[150px] z-10">
{onBatchDuplicate && (
<button
onClick={() => {
onBatchDuplicate();
setShowMoreMenu(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Copy className="w-4 h-4" />
</button>
)}
{onBatchDelete && (
<button
onClick={() => {
onBatchDelete();
setShowMoreMenu(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
)}
{/* Close */}
<button
onClick={onDeselectAll}
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
title="取消选择"
>
<XCircle className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}
export default BatchActionBar;

View File

@@ -1,395 +0,0 @@
/**
* ExecutionResult - Execution Result Display Component
*
* Displays the result of hand or workflow executions with
* status, output, and error information.
*
* @module components/Automation/ExecutionResult
*/
import { useState, useCallback, useMemo } from 'react';
import type { RunInfo } from '../../types/automation';
import {
CheckCircle,
XCircle,
Clock,
AlertTriangle,
ChevronDown,
ChevronUp,
Copy,
Download,
RefreshCw,
ExternalLink,
FileText,
Code,
Image,
FileSpreadsheet,
} from 'lucide-react';
import { useToast } from '../ui/Toast';
// === Status Config ===
const STATUS_CONFIG = {
completed: {
label: '完成',
icon: CheckCircle,
className: 'text-green-500',
bgClass: 'bg-green-50 dark:bg-green-900/20',
},
failed: {
label: '失败',
icon: XCircle,
className: 'text-red-500',
bgClass: 'bg-red-50 dark:bg-red-900/20',
},
running: {
label: '运行中',
icon: RefreshCw,
className: 'text-blue-500 animate-spin',
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
},
needs_approval: {
label: '待审批',
icon: AlertTriangle,
className: 'text-yellow-500',
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
},
cancelled: {
label: '已取消',
icon: XCircle,
className: 'text-gray-500',
bgClass: 'bg-gray-50 dark:bg-gray-900/20',
},
};
// === Component Props ===
interface ExecutionResultProps {
run: RunInfo;
itemType: 'hand' | 'workflow';
itemName: string;
onRerun?: () => void;
onViewDetails?: () => void;
compact?: boolean;
}
// === Helper Functions ===
function formatDuration(startedAt: string, completedAt?: string): string {
const start = new Date(startedAt).getTime();
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
const diffMs = end - start;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
function detectOutputType(output: unknown): 'text' | 'json' | 'markdown' | 'code' | 'image' | 'data' {
if (!output) return 'text';
if (typeof output === 'string') {
// Check for image URL
if (output.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i)) {
return 'image';
}
// Check for markdown
if (output.includes('#') || output.includes('**') || output.includes('```')) {
return 'markdown';
}
// Check for code
if (output.includes('function ') || output.includes('import ') || output.includes('class ')) {
return 'code';
}
// Try to parse as JSON
try {
JSON.parse(output);
return 'json';
} catch {
return 'text';
}
}
// Object/array types
if (typeof output === 'object') {
return 'json';
}
return 'text';
}
function formatOutput(output: unknown, type: string): string {
if (!output) return '无输出';
if (type === 'json') {
try {
return JSON.stringify(output, null, 2);
} catch {
return String(output);
}
}
return String(output);
}
// === Output Viewer Component ===
interface OutputViewerProps {
output: unknown;
type: string;
}
function OutputViewer({ output, type }: OutputViewerProps) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const handleCopy = useCallback(async () => {
const text = formatOutput(output, type);
await navigator.clipboard.writeText(text);
setCopied(true);
toast('已复制到剪贴板', 'success');
setTimeout(() => setCopied(false), 2000);
}, [output, type, toast]);
const handleDownload = useCallback(() => {
const text = formatOutput(output, type);
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `output-${Date.now()}.${type === 'json' ? 'json' : 'txt'}`;
a.click();
URL.revokeObjectURL(url);
}, [output, type]);
// Image preview
if (type === 'image' && typeof output === 'string') {
return (
<div className="relative">
<img
src={output}
alt="Output"
className="max-w-full rounded-lg"
/>
<div className="absolute top-2 right-2 flex gap-1">
<button
onClick={() => window.open(output, '_blank')}
className="p-1.5 bg-black/50 rounded hover:bg-black/70 text-white"
>
<ExternalLink className="w-4 h-4" />
</button>
</div>
</div>
);
}
// Text/JSON/Code output
const content = formatOutput(output, type);
return (
<div className="relative">
<pre className="p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm text-gray-100 overflow-x-auto max-h-64 overflow-y-auto">
{content}
</pre>
<div className="absolute top-2 right-2 flex gap-1">
<button
onClick={handleCopy}
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
title="复制"
>
{copied ? <CheckCircle className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={handleDownload}
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
title="下载"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
);
}
// === Main Component ===
export function ExecutionResult({
run,
itemType,
itemName,
onRerun,
onViewDetails,
compact = false,
}: ExecutionResultProps) {
const [expanded, setExpanded] = useState(!compact);
const statusConfig = STATUS_CONFIG[run.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.completed;
const StatusIcon = statusConfig.icon;
// Safely extract error message as string
const getErrorMessage = (): string | null => {
if (typeof run.error === 'string' && run.error.length > 0) {
return run.error;
}
return null;
};
const errorMessage = getErrorMessage();
const outputType = useMemo(() => detectOutputType(run.output), [run.output]);
const duration = useMemo(() => {
if (run.duration) return `${run.duration}s`;
if (run.completedAt && run.startedAt) {
return formatDuration(run.startedAt, run.completedAt);
}
return null;
}, [run.duration, run.startedAt, run.completedAt]);
// Compact mode
if (compact && !expanded) {
return (
<div
className={`flex items-center gap-3 p-3 rounded-lg ${statusConfig.bgClass} cursor-pointer`}
onClick={() => setExpanded(true)}
>
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white truncate">
{itemName}
</span>
<span className={`text-xs ${statusConfig.className}`}>
{statusConfig.label}
</span>
</div>
{duration && (
<span className="text-xs text-gray-500 dark:text-gray-400">
: {duration}
</span>
)}
</div>
<ChevronDown className="w-4 h-4 text-gray-400" />
</div>
);
}
return (
<div className={`rounded-lg border ${statusConfig.bgClass} border-gray-200 dark:border-gray-700 overflow-hidden`}>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={compact ? () => setExpanded(false) : undefined}
>
<div className="flex items-center gap-3">
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{itemName}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${statusConfig.className} ${statusConfig.bgClass}`}>
{statusConfig.label}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{itemType === 'hand' ? '自主能力' : '工作流'}
</span>
</div>
{run.runId && (
<span className="text-xs text-gray-400 dark:text-gray-500">
ID: {run.runId}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{duration && (
<span className="text-xs text-gray-500 dark:text-gray-400">
: {duration}
</span>
)}
{compact && (
<ChevronUp className="w-4 h-4 text-gray-400" />
)}
</div>
</div>
{/* Body */}
{expanded && (
<div className="px-4 pb-4 space-y-3">
{/* Error */}
{(() => {
if (!errorMessage) return null;
return (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1"></p>
<p className="text-sm text-red-600 dark:text-red-300">{errorMessage}</p>
</div>
);
})()}
{/* Output */}
{run.output !== undefined && run.output !== null && (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300"></p>
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
{outputType === 'json' && <Code className="w-3 h-3" />}
{outputType === 'markdown' && <FileText className="w-3 h-3" />}
{outputType === 'image' && <Image className="w-3 h-3" />}
{outputType === 'data' && <FileSpreadsheet className="w-3 h-3" />}
{outputType.toUpperCase()}
</span>
</div>
<OutputViewer output={run.output} type={outputType} />
</div>
)}
{/* Timestamps */}
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
: {new Date(run.startedAt).toLocaleString('zh-CN')}
</span>
{run.completedAt && (
<span>
: {new Date(run.completedAt).toLocaleString('zh-CN')}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
{onRerun && (
<button
onClick={onRerun}
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 flex items-center gap-1"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
{onViewDetails && (
<button
onClick={onViewDetails}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 flex items-center gap-1"
>
<ExternalLink className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
)}
</div>
);
}
export default ExecutionResult;

View File

@@ -1,378 +0,0 @@
/**
* 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<Frequency>(schedule?.frequency || 'daily');
const [time, setTime] = useState(schedule?.time || { hour: 9, minute: 0 });
const [daysOfWeek, setDaysOfWeek] = useState<number[]>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<Calendar className="w-5 h-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
{itemName && (
<p className="text-sm text-gray-500 dark:text-gray-400">{itemName}</p>
)}
</div>
</div>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<span className="text-xl">&times;</span>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-orange-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Frequency Selection */}
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="grid grid-cols-5 gap-2">
{[
{ value: 'once', label: '一次' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'custom', label: '自定义' },
].map(option => (
<button
key={option.value}
onClick={() => setFrequency(option.value as Frequency)}
className={`px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${
frequency === option.value
? 'bg-orange-500 text-white border-orange-500'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Time Selection */}
{frequency !== 'custom' && (
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="23"
value={time.hour}
onChange={(e) => 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"
/>
<span className="text-gray-500 dark:text-gray-400">:</span>
<input
type="number"
min="0"
max="59"
value={time.minute}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<select
value={timezone}
onChange={(e) => setTimezone(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"
>
{COMMON_TIMEZONES.map(tz => (
<option key={tz.value} value={tz.value}>{tz.label}</option>
))}
</select>
</div>
</div>
)}
{/* Weekly Days Selection */}
{frequency === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="flex items-center gap-1">
{DAY_NAMES.map((day, index) => (
<button
key={index}
onClick={() => toggleDayOfWeek(index)}
className={`w-10 h-10 rounded-full text-sm font-medium transition-colors ${
daysOfWeek.includes(index)
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{day}
</button>
))}
</div>
</div>
)}
{/* Monthly Day Selection */}
{frequency === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<select
value={dayOfMonth}
onChange={(e) => setDayOfMonth(parseInt(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"
>
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}> {day} </option>
))}
</select>
</div>
)}
{/* Custom Cron Input */}
{frequency === 'custom' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Cron
</label>
<input
type="text"
value={customCron}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Info className="w-3 h-3" />
: "0 9 * * *" 9:00
</p>
</div>
)}
{/* End Date */}
{frequency !== 'once' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
()
</label>
<input
type="date"
value={endDate}
onChange={(e) => 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"
/>
</div>
)}
{/* Preview */}
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1"></p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{preview}</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600"
>
</button>
</div>
</div>
</div>
);
}
export default ScheduleEditor;

View File

@@ -1,26 +0,0 @@
/**
* 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';
export { ApprovalQueue } from './ApprovalQueue';
export { ExecutionResult } from './ExecutionResult';
// Re-export types
export type {
AutomationItem,
AutomationStatus,
AutomationType,
CategoryType,
CategoryStats,
RunInfo,
ScheduleInfo,
} from '../../types/automation';

View File

@@ -1,132 +0,0 @@
import { useEffect } from 'react';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { Radio, RefreshCw, MessageCircle, Settings } from 'lucide-react';
const CHANNEL_ICONS: Record<string, string> = {
feishu: '飞',
qqbot: 'QQ',
wechat: '微',
};
// 可用频道类型(用于显示未配置的频道)
const AVAILABLE_CHANNEL_TYPES = [
{ type: 'feishu', name: '飞书 (Feishu)' },
{ type: 'wechat', name: '微信' },
{ type: 'qqbot', name: 'QQ 机器人' },
];
interface ChannelListProps {
onOpenSettings?: () => void;
}
export function ChannelList({ onOpenSettings }: ChannelListProps) {
const connectionState = useConnectionStore((s) => s.connectionState);
const loadPluginStatus = useAgentStore((s) => s.loadPluginStatus);
const channels = useConfigStore((s) => s.channels);
const loadChannels = useConfigStore((s) => s.loadChannels);
const connected = connectionState === 'connected';
useEffect(() => {
if (connected) {
loadPluginStatus().then(() => loadChannels());
}
}, [connected]);
const handleRefresh = () => {
loadPluginStatus().then(() => loadChannels());
};
// 去重:基于 channel id
const uniqueChannels = channels.filter((ch, index, self) =>
index === self.findIndex(c => c.id === ch.id)
);
// 获取已配置的频道类型
const configuredTypes = new Set(uniqueChannels.map(c => c.type));
// 未配置的频道类型
const unconfiguredTypes = AVAILABLE_CHANNEL_TYPES.filter(ct => !configuredTypes.has(ct.type));
if (!connected) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
<Radio className="w-8 h-8 mb-2 opacity-30" />
<p>IM </p>
<p className="mt-1"> Gateway </p>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-xs font-medium text-gray-500"></span>
<button
onClick={handleRefresh}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="刷新"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Configured channels */}
{uniqueChannels.map((ch) => (
<div
key={ch.id}
className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50"
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 ${
ch.status === 'active'
? 'bg-gradient-to-br from-blue-500 to-indigo-500'
: 'bg-gray-300'
}`}>
{CHANNEL_ICONS[ch.type] || <MessageCircle className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 truncate">{ch.label}</div>
<div className={`text-[11px] ${
ch.status === 'active' ? 'text-green-500' : ch.status === 'error' ? 'text-red-500' : 'text-gray-400'
}`}>
{ch.status === 'active' ? '已连接' : ch.status === 'error' ? ch.error || '错误' : '未配置'}
{ch.accounts !== undefined && ch.accounts > 0 && ` · ${ch.accounts} 个账号`}
</div>
</div>
</div>
))}
{/* Unconfigured channels - 只显示一次 */}
{unconfiguredTypes.map((ct) => (
<div key={ct.type} className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
{CHANNEL_ICONS[ct.type] || <MessageCircle className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600">{ct.name}</div>
<div className="text-[11px] text-gray-400"></div>
</div>
</div>
))}
{/* Help text */}
<div className="px-3 py-4 text-center">
<p className="text-[11px] text-gray-400 mb-2"> IM </p>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="inline-flex items-center gap-1 text-xs text-orange-500 hover:text-orange-600"
>
<Settings className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,286 +0,0 @@
/**
* ConnectionStatus Component
*
* Displays the current Gateway connection status with visual indicators.
* Supports automatic reconnect and manual reconnect button.
* Includes health status indicator for ZCLAW backend.
*/
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Wifi, WifiOff, Loader2, RefreshCw, Heart, HeartPulse } from 'lucide-react';
import { useConnectionStore, getClient } from '../store/connectionStore';
import {
createHealthCheckScheduler,
getHealthStatusLabel,
formatHealthCheckTime,
type HealthCheckResult,
type HealthStatus,
} from '../lib/health-check';
interface ConnectionStatusProps {
/** Show compact version (just icon and status text) */
compact?: boolean;
/** Show reconnect button when disconnected */
showReconnectButton?: boolean;
/** Additional CSS classes */
className?: string;
}
interface ReconnectInfo {
attempt: number;
delay: number;
maxAttempts: number;
}
type StatusType = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
const statusConfig: Record<StatusType, {
color: string;
bgColor: string;
label: string;
icon: typeof Wifi;
animate?: boolean;
}> = {
disconnected: {
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: '已断开',
icon: WifiOff,
},
connecting: {
color: 'text-yellow-500',
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
label: '连接中...',
icon: Loader2,
animate: true,
},
handshaking: {
color: 'text-yellow-500',
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
label: '认证中...',
icon: Loader2,
animate: true,
},
connected: {
color: 'text-green-500',
bgColor: 'bg-green-50 dark:bg-green-900/20',
label: '已连接',
icon: Wifi,
},
reconnecting: {
color: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
label: '重连中...',
icon: RefreshCw,
animate: true,
},
};
export function ConnectionStatus({
compact = false,
showReconnectButton = true,
className = '',
}: ConnectionStatusProps) {
const connectionState = useConnectionStore((s) => s.connectionState);
const connect = useConnectionStore((s) => s.connect);
const [showPrompt, setShowPrompt] = useState(false);
const [reconnectInfo, setReconnectInfo] = useState<ReconnectInfo | null>(null);
// Listen for reconnect events
useEffect(() => {
const client = getClient();
const unsubReconnecting = client.on('reconnecting', (info) => {
setReconnectInfo(info as ReconnectInfo);
});
const unsubFailed = client.on('reconnect_failed', () => {
setShowPrompt(true);
setReconnectInfo(null);
});
const unsubConnected = client.on('connected', () => {
setShowPrompt(false);
setReconnectInfo(null);
});
return () => {
unsubReconnecting();
unsubFailed();
unsubConnected();
};
}, []);
const config = statusConfig[connectionState];
const Icon = config.icon;
const isDisconnected = connectionState === 'disconnected';
const isReconnecting = connectionState === 'reconnecting';
const handleReconnect = async () => {
setShowPrompt(false);
try {
await connect();
} catch (error) {
console.error('Manual reconnect failed:', error);
}
};
// Compact version
if (compact) {
return (
<div className={`flex items-center gap-1.5 ${className}`}>
<Icon
className={`w-3.5 h-3.5 ${config.color} ${config.animate ? 'animate-spin' : ''}`}
/>
<span className={`text-xs ${config.color}`}>
{isReconnecting && reconnectInfo
? `${config.label} (${reconnectInfo.attempt}/${reconnectInfo.maxAttempts})`
: config.label}
</span>
{showPrompt && showReconnectButton && (
<button
onClick={handleReconnect}
className="text-xs text-blue-500 hover:text-blue-600 ml-1"
>
</button>
)}
</div>
);
}
// Full version
return (
<div className={`flex items-center gap-3 ${config.bgColor} rounded-lg px-3 py-2 ${className}`}>
<motion.div
initial={false}
animate={{ rotate: config.animate ? 360 : 0 }}
transition={config.animate ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
>
<Icon className={`w-5 h-5 ${config.color}`} />
</motion.div>
<div className="flex-1">
<div className={`text-sm font-medium ${config.color}`}>
{isReconnecting && reconnectInfo
? `${config.label} (${reconnectInfo.attempt}/${reconnectInfo.maxAttempts})`
: config.label}
</div>
{reconnectInfo && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{Math.round(reconnectInfo.delay / 1000)}
</div>
)}
</div>
<AnimatePresence>
{showPrompt && isDisconnected && showReconnectButton && (
<motion.button
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
onClick={handleReconnect}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-md transition-colors"
>
<RefreshCw className="w-4 h-4" />
</motion.button>
)}
</AnimatePresence>
</div>
);
}
/**
* ConnectionIndicator - Minimal connection indicator for headers
*/
export function ConnectionIndicator({ className = '' }: { className?: string }) {
const connectionState = useConnectionStore((s) => s.connectionState);
const isConnected = connectionState === 'connected';
const isReconnecting = connectionState === 'reconnecting';
return (
<span className={`text-xs flex items-center gap-1 ${className}`}>
<span
className={`w-1.5 h-1.5 rounded-full ${
isConnected
? 'bg-green-400'
: isReconnecting
? 'bg-orange-400 animate-pulse'
: 'bg-red-400'
}`}
/>
<span className={
isConnected
? 'text-green-500'
: isReconnecting
? 'text-orange-500'
: 'text-red-500'
}>
{isConnected
? 'Gateway 已连接'
: isReconnecting
? '重连中...'
: 'Gateway 未连接'}
</span>
</span>
);
}
/**
* HealthStatusIndicator - Displays ZCLAW backend health status
*/
export function HealthStatusIndicator({
className = '',
showDetails = false,
}: {
className?: string;
showDetails?: boolean;
}) {
const [healthResult, setHealthResult] = useState<HealthCheckResult | null>(null);
useEffect(() => {
// Start periodic health checks
const cleanup = createHealthCheckScheduler((result) => {
setHealthResult(result);
}, 30000); // Check every 30 seconds
return cleanup;
}, []);
if (!healthResult) {
return (
<span className={`text-xs flex items-center gap-1 ${className}`}>
<Heart className="w-3.5 h-3.5 text-gray-400" />
<span className="text-gray-400">...</span>
</span>
);
}
const statusColors: Record<HealthStatus, { dot: string; text: string; icon: typeof Heart }> = {
healthy: { dot: 'bg-green-400', text: 'text-green-500', icon: Heart },
unhealthy: { dot: 'bg-red-400', text: 'text-red-500', icon: HeartPulse },
unknown: { dot: 'bg-gray-400', text: 'text-gray-500', icon: Heart },
};
const config = statusColors[healthResult.status];
const Icon = config.icon;
return (
<span className={`text-xs flex items-center gap-1 ${className}`}>
<Icon className={`w-3.5 h-3.5 ${config.text}`} />
<span className={config.text}>
{getHealthStatusLabel(healthResult.status)}
</span>
{showDetails && healthResult.message && (
<span className="text-gray-400 ml-1" title={healthResult.message}>
({formatHealthCheckTime(healthResult.timestamp)})
</span>
)}
</span>
);
}
export default ConnectionStatus;

View File

@@ -1,270 +0,0 @@
/**
* ErrorNotification Component
*
* Displays error notifications as toast-style messages.
* Integrates with the centralized error handling system.
*/
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X,
AlertCircle,
AlertTriangle,
Bug,
WifiOff,
ShieldAlert,
Clock,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import {
getUndismissedErrors,
dismissError,
dismissAll,
type StoredError,
} from '../lib/error-handling';
import {
ErrorCategory,
ErrorSeverity,
formatErrorForToast,
} from '../lib/error-types';
interface ErrorNotificationProps {
/** Maximum number of visible notifications */
maxVisible?: number;
/** Position on screen */
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
/** Auto dismiss timeout in ms (0 = no auto dismiss) */
autoDismissMs?: number;
/** Additional CSS classes */
className?: string;
}
const categoryIcons: Record<ErrorCategory, typeof AlertCircle> = {
network: WifiOff,
auth: ShieldAlert,
permission: ShieldAlert,
validation: AlertTriangle,
config: AlertTriangle,
server: Bug,
client: AlertCircle,
timeout: Clock,
system: Bug,
};
const severityColors: Record<ErrorSeverity, {
bg: string;
border: string;
text: string;
icon: string;
}> = {
critical: {
bg: 'bg-red-50 dark:bg-red-900/20',
border: 'border-red-200 dark:border-red-800',
text: 'text-red-800 dark:text-red-200',
icon: 'text-red-500',
},
high: {
bg: 'bg-orange-50 dark:bg-orange-900/20',
border: 'border-orange-200 dark:border-orange-800',
text: 'text-orange-800 dark:text-orange-200',
icon: 'text-orange-500',
},
medium: {
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
border: 'border-yellow-200 dark:border-yellow-800',
text: 'text-yellow-800 dark:text-yellow-200',
icon: 'text-yellow-500',
},
low: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
border: 'border-blue-200 dark:border-blue-800',
text: 'text-blue-800 dark:text-blue-200',
icon: 'text-blue-500',
},
};
function ErrorItem({
error,
onDismiss,
autoDismissMs,
}: {
error: StoredError;
onDismiss: (id: string) => void;
autoDismissMs: number;
}) {
const [expanded, setExpanded] = useState(false);
const Icon = categoryIcons[error.category] || AlertCircle;
const colors = severityColors[error.severity] || severityColors.medium;
const { title, message } = formatErrorForToast(error);
// Auto dismiss
useEffect(() => {
if (autoDismissMs > 0 && error.severity !== 'critical') {
const timer = setTimeout(() => {
onDismiss(error.id);
}, autoDismissMs);
return () => clearTimeout(timer);
}
}, [autoDismissMs, error.id, error.severity, onDismiss]);
const hasDetails = error.stack || error.context;
return (
<motion.div
initial={{ opacity: 0, x: 300, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 300, scale: 0.9 }}
className={`
${colors.bg} ${colors.border} ${colors.text}
border rounded-lg shadow-lg p-4 min-w-[320px] max-w-[420px]
`}
>
<div className="flex items-start gap-3">
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${colors.icon}`} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="font-medium text-sm">{title}</h4>
<button
onClick={() => onDismiss(error.id)}
className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm mt-1 opacity-90">{message}</p>
{hasDetails && (
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs mt-2 opacity-70 hover:opacity-100"
>
{expanded ? (
<>
<ChevronUp className="w-3 h-3" />
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
</>
)}
</button>
)}
{expanded && hasDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="mt-2 p-2 bg-black/5 dark:bg-white/5 rounded text-xs font-mono overflow-auto max-h-32"
>
{error.context && (
<div className="mb-1">
<span className="opacity-70">Context: </span>
{JSON.stringify(error.context, null, 2)}
</div>
)}
{error.stack && (
<pre className="whitespace-pre-wrap opacity-70">{error.stack}</pre>
)}
</motion.div>
)}
<div className="flex items-center gap-2 mt-2 text-xs opacity-60">
<span>{error.category}</span>
<span></span>
<span>{new Date(error.timestamp).toLocaleTimeString()}</span>
</div>
</div>
</div>
</motion.div>
);
}
export function ErrorNotification({
maxVisible = 3,
position = 'top-right',
autoDismissMs = 10000,
className = '',
}: ErrorNotificationProps) {
const [errors, setErrors] = useState<StoredError[]>([]);
// Poll for new errors
useEffect(() => {
const updateErrors = () => {
setErrors(getUndismissedErrors().slice(0, maxVisible));
};
updateErrors();
const interval = setInterval(updateErrors, 1000);
return () => clearInterval(interval);
}, [maxVisible]);
const handleDismiss = useCallback((id: string) => {
dismissError(id);
setErrors(prev => prev.filter(e => e.id !== id));
}, []);
const handleDismissAll = useCallback(() => {
dismissAll();
setErrors([]);
}, []);
const positionClasses: Record<string, string> = {
'top-right': 'top-4 right-4',
'top-left': 'top-4 left-4',
'bottom-right': 'bottom-4 right-4',
'bottom-left': 'bottom-4 left-4',
};
if (errors.length === 0) return null;
return (
<div
className={`fixed ${positionClasses[position]} z-50 flex flex-col gap-2 ${className}`}
>
<AnimatePresence mode="popLayout">
{errors.map(error => (
<ErrorItem
key={error.id}
error={error}
onDismiss={handleDismiss}
autoDismissMs={autoDismissMs}
/>
))}
</AnimatePresence>
{errors.length > 1 && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={handleDismissAll}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-center py-1"
>
({errors.length})
</motion.button>
)}
</div>
);
}
/**
* ErrorNotificationProvider - Include at app root
*/
export function ErrorNotificationProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<ErrorNotification />
</>
);
}
export default ErrorNotification;

View File

@@ -1,131 +0,0 @@
/**
* HandList - 左侧导航的 Hands 列表
*
* 显示所有可用的 Hands自主能力包
* 允许用户选择一个 Hand 来查看其任务和结果。
*/
import { useEffect } from 'react';
import { useHandStore, type Hand } from '../store/handStore';
import { Zap, Loader2, RefreshCw, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
interface HandListProps {
selectedHandId?: string;
onSelectHand?: (handId: string) => void;
}
// 状态图标
function HandStatusIcon({ status }: { status: Hand['status'] }) {
switch (status) {
case 'running':
return <Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />;
case 'needs_approval':
return <AlertTriangle className="w-3.5 h-3.5 text-yellow-500" />;
case 'error':
return <XCircle className="w-3.5 h-3.5 text-red-500" />;
case 'setup_needed':
case 'unavailable':
return <AlertTriangle className="w-3.5 h-3.5 text-orange-500" />;
default:
return <CheckCircle className="w-3.5 h-3.5 text-green-500" />;
}
}
// 状态标签
const STATUS_LABELS: Record<Hand['status'], string> = {
idle: '就绪',
running: '运行中',
needs_approval: '待审批',
error: '错误',
unavailable: '不可用',
setup_needed: '需配置',
};
export function HandList({ selectedHandId, onSelectHand }: HandListProps) {
const hands = useHandStore((s) => s.hands);
const loadHands = useHandStore((s) => s.loadHands);
const isLoading = useHandStore((s) => s.isLoading);
useEffect(() => {
loadHands();
}, [loadHands]);
if (isLoading && hands.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-5 h-5 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-xs text-gray-400">...</p>
</div>
);
}
if (hands.length === 0) {
return (
<div className="p-4 text-center">
<Zap className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-xs text-gray-400 mb-1"> Hands</p>
<p className="text-xs text-gray-300"> ZCLAW </p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 头部 */}
<div className="p-3 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="text-xs font-semibold text-gray-700"></h3>
<p className="text-xs text-gray-400">{hands.length} </p>
</div>
<button
onClick={() => loadHands()}
disabled={isLoading}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Hands 列表 */}
<div className="flex-1 overflow-y-auto">
{hands.map((hand) => (
<button
key={hand.id}
onClick={() => onSelectHand?.(hand.id)}
className={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-100 transition-colors ${
selectedHandId === hand.id ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''
}`}
>
<div className="flex items-start gap-2">
<span className="text-lg flex-shrink-0">{hand.icon || '🤖'}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-gray-800 text-sm truncate">
{hand.name}
</span>
<HandStatusIcon status={hand.status} />
</div>
<p className="text-xs text-gray-400 truncate mt-0.5">
{hand.description}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">
{STATUS_LABELS[hand.status]}
</span>
{hand.toolCount !== undefined && (
<span className="text-xs text-gray-300">
{hand.toolCount}
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
</div>
);
}
export default HandList;

View File

@@ -1,326 +0,0 @@
/**
* HandTaskPanel - Hand 任务和结果面板
*
* 显示选中 Hand 的任务清单、执行历史和结果。
* 使用真实 API 数据,移除了 Mock 数据。
*/
import { useState, useEffect, useCallback } from 'react';
import { useHandStore, type Hand, type HandRun } from '../store/handStore';
import {
Zap,
Loader2,
Clock,
CheckCircle,
XCircle,
AlertCircle,
ChevronRight,
Play,
ArrowLeft,
RefreshCw,
} from 'lucide-react';
import { useToast } from './ui/Toast';
interface HandTaskPanelProps {
handId: string;
onBack?: () => void;
}
// 任务状态配置
const RUN_STATUS_CONFIG: Record<string, { label: string; className: string; icon: React.ComponentType<{ className?: string }> }> = {
pending: { label: '等待中', className: 'text-gray-500 bg-gray-100', icon: Clock },
running: { label: '运行中', className: 'text-blue-600 bg-blue-100', icon: Loader2 },
completed: { label: '已完成', className: 'text-green-600 bg-green-100', icon: CheckCircle },
failed: { label: '失败', className: 'text-red-600 bg-red-100', icon: XCircle },
cancelled: { label: '已取消', className: 'text-gray-500 bg-gray-100', icon: XCircle },
needs_approval: { label: '待审批', className: 'text-yellow-600 bg-yellow-100', icon: AlertCircle },
success: { label: '成功', className: 'text-green-600 bg-green-100', icon: CheckCircle },
error: { label: '错误', className: 'text-red-600 bg-red-100', icon: XCircle },
};
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
const hands = useHandStore((s) => s.hands);
const handRuns = useHandStore((s) => s.handRuns);
const loadHands = useHandStore((s) => s.loadHands);
const loadHandRuns = useHandStore((s) => s.loadHandRuns);
const triggerHand = useHandStore((s) => s.triggerHand);
const isLoading = useHandStore((s) => s.isLoading);
const { toast } = useToast();
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
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) {
loadHandRuns(selectedHand.id, { limit: 50 });
}
}, [selectedHand, loadHandRuns]);
// Get runs for this hand from store
const tasks: HandRun[] = selectedHand ? (handRuns[selectedHand.id] || []) : [];
// Refresh task history
const handleRefresh = useCallback(async () => {
if (!selectedHand) return;
setIsRefreshing(true);
try {
await loadHandRuns(selectedHand.id, { limit: 50 });
} finally {
setIsRefreshing(false);
}
}, [selectedHand, loadHandRuns]);
// Trigger hand execution
const handleActivate = useCallback(async () => {
if (!selectedHand) return;
// Check if hand is already running
if (selectedHand.status === 'running') {
toast(`Hand "${selectedHand.name}" 正在运行中,请等待完成`, 'warning');
return;
}
setIsActivating(true);
try {
const result = await triggerHand(selectedHand.id);
if (result) {
toast(`Hand "${selectedHand.name}" 已成功启动`, 'success');
// Refresh hands list and task history
await Promise.all([
loadHands(),
loadHandRuns(selectedHand.id, { limit: 50 }),
]);
} else {
// Check for specific error in store
const storeError = useHandStore.getState().error;
if (storeError?.includes('already active')) {
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
} else {
toast(`Hand "${selectedHand.name}" 启动失败: ${storeError || '未知错误'}`, 'error');
}
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error(`[HandTaskPanel] Activation error:`, errorMsg);
if (errorMsg.includes('already active')) {
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
} else {
toast(`Hand "${selectedHand.name}" 启动异常: ${errorMsg}`, 'error');
}
} finally {
setIsActivating(false);
}
}, [selectedHand, triggerHand, loadHands, loadHandRuns, toast]);
if (!selectedHand) {
return (
<div className="p-8 text-center">
<Zap className="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p className="text-sm text-gray-400"> Hand</p>
</div>
);
}
const runningTasks = tasks.filter(t => t.status === 'running');
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 (
<div className="h-full flex flex-col">
{/* 头部 */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-3">
{onBack && (
<button
onClick={onBack}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<span className="text-2xl">{selectedHand.icon || '🤖'}</span>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedHand.name}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedHand.description}</p>
</div>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleActivate}
disabled={selectedHand.status !== 'idle' || isActivating}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isActivating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 加载状态 */}
{isLoading && tasks.length === 0 && (
<div className="text-center py-8">
<Loader2 className="w-8 h-8 mx-auto text-gray-400 animate-spin mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">...</p>
</div>
)}
{/* 运行中的任务 */}
{runningTasks.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<h3 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
({runningTasks.length})
</h3>
<div className="space-y-2">
{runningTasks.map(task => (
<TaskCard key={task.runId} task={task} />
))}
</div>
</div>
)}
{/* 待处理任务 */}
{pendingTasks.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
({pendingTasks.length})
</h3>
<div className="space-y-2">
{pendingTasks.map(task => (
<TaskCard key={task.runId} task={task} />
))}
</div>
</div>
)}
{/* 已完成任务 */}
{completedTasks.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
({completedTasks.length})
</h3>
<div className="space-y-2">
{completedTasks.map(task => (
<TaskCard key={task.runId} task={task} expanded />
))}
</div>
</div>
)}
{/* 空状态 */}
{!isLoading && tasks.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1"></p>
<p className="text-xs text-gray-400 dark:text-gray-500">
"执行任务"
</p>
</div>
)}
</div>
</div>
);
}
// 任务卡片组件
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 (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 border border-gray-100 dark:border-gray-700">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2 min-w-0">
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${task.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
#{task.runId.slice(0, 8)}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs px-2 py-0.5 rounded ${config.className}`}>
{config.label}
</span>
<ChevronRight className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</div>
</div>
{/* 展开详情 */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span> ID</span>
<span className="font-mono">{task.runId}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{new Date(task.startedAt).toLocaleString()}</span>
</div>
{task.completedAt && (
<div className="flex justify-between">
<span></span>
<span>{new Date(task.completedAt).toLocaleString()}</span>
</div>
)}
{resultText && (
<div className="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 whitespace-pre-wrap max-h-40 overflow-auto">
{resultText}
</div>
)}
{task.error && (
<div className="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400">
{task.error}
</div>
)}
</div>
)}
</div>
);
}
export default HandTaskPanel;

View File

@@ -1,641 +0,0 @@
/**
* HandsPanel - ZCLAW Hands Management UI
*
* Displays available ZCLAW Hands (autonomous capability packages)
* with detailed status, requirements, and activation controls.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from '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 ===
type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
// === Parameter Validation Helper ===
function validateAllParameters(
parameters: HandParameter[],
values: Record<string, unknown>
): Record<string, string> {
const errors: Record<string, string> = {};
parameters.forEach(param => {
if (param.required) {
const value = values[param.name];
if (value === undefined || value === null || value === '') {
errors[param.name] = `${param.label} is required`;
}
}
});
return errors;
}
interface StatusConfig {
label: string;
className: string;
dotClass: string;
}
const STATUS_CONFIG: Record<HandStatus, StatusConfig> = {
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',
},
needs_approval: {
label: '待审批',
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
dotClass: 'bg-yellow-500',
},
error: {
label: '错误',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
dotClass: 'bg-red-500',
},
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',
},
};
function HandStatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status as HandStatus] || STATUS_CONFIG.unavailable;
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
{config.label}
</span>
);
}
// === Category Badge Component ===
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
productivity: { label: '生产力', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
data: { label: '数据', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
content: { label: '内容', className: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' },
communication: { label: '通信', className: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' },
};
function CategoryBadge({ category }: { category?: string }) {
if (!category) return null;
const config = CATEGORY_CONFIG[category] || { label: category, className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
return (
<span className={`px-2 py-0.5 rounded text-xs ${config.className}`}>
{config.label}
</span>
);
}
// === Requirement Item Component ===
function RequirementItem({ requirement }: { requirement: HandRequirement }) {
return (
<div className={`flex items-start gap-2 text-sm py-1 ${requirement.met ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
<span className="flex-shrink-0 mt-0.5">
{requirement.met ? (
<CheckCircle className="w-4 h-4" />
) : (
<XCircle className="w-4 h-4" />
)}
</span>
<div className="flex-1 min-w-0">
<span className="break-words">{requirement.description}</span>
{requirement.details && (
<span className="text-gray-400 dark:text-gray-500 text-xs ml-1">({requirement.details})</span>
)}
</div>
</div>
);
}
// === Hand Details Modal Component ===
interface HandDetailsModalProps {
hand: Hand;
isOpen: boolean;
onClose: () => void;
onActivate: (params?: Record<string, unknown>) => void;
isActivating: boolean;
}
function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: HandDetailsModalProps) {
// Get Hand parameters from definitions
const handDefinition = HAND_DEFINITIONS.find(h => h.id === hand.id);
const parameters: HandParameter[] = handDefinition?.parameters || [];
// Form state for parameters
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
const [showParamsForm, setShowParamsForm] = useState(false);
// Initialize default values
useEffect(() => {
if (parameters.length > 0) {
const defaults: Record<string, unknown> = {};
parameters.forEach(p => {
if (p.defaultValue !== undefined) {
defaults[p.name] = p.defaultValue;
}
});
setParamValues(defaults);
}
}, [parameters]);
// Reset form when modal opens/closes
useEffect(() => {
if (isOpen) {
setShowParamsForm(false);
setParamErrors({});
}
}, [isOpen]);
const handleActivateClick = useCallback(() => {
if (parameters.length > 0 && !showParamsForm) {
// Show params form first
setShowParamsForm(true);
return;
}
// Validate parameters if showing form
if (showParamsForm) {
const errors = validateAllParameters(parameters, paramValues);
setParamErrors(errors);
if (Object.keys(errors).length > 0) {
return;
}
// Pass parameters to onActivate
onActivate(paramValues);
} else {
onActivate();
}
}, [parameters, showParamsForm, paramValues, onActivate]);
if (!isOpen) return null;
const canActivate = hand.status === 'idle' || hand.status === 'setup_needed';
const hasUnmetRequirements = hand.requirements?.some(r => !r.met);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<span className="text-2xl">{hand.icon || '🤖'}</span>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{hand.name}</h2>
<HandStatusBadge status={hand.status} />
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<span className="text-xl">&times;</span>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400">{hand.description}</p>
{/* Agent Config */}
{(hand.provider || hand.model) && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
{hand.provider && (
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<p className="font-medium text-gray-900 dark:text-white">{hand.provider}</p>
</div>
)}
{hand.model && (
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<p className="font-medium text-gray-900 dark:text-white">{hand.model}</p>
</div>
)}
</div>
</div>
)}
{/* Requirements */}
{hand.requirements && hand.requirements.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
</h3>
<div className="space-y-1">
{hand.requirements.map((req, idx) => (
<RequirementItem key={idx} requirement={req} />
))}
</div>
</div>
)}
{/* Tools */}
{hand.tools && hand.tools.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
({hand.tools.length})
</h3>
<div className="flex flex-wrap gap-1.5">
{hand.tools.map((tool, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 font-mono"
>
{tool}
</span>
))}
</div>
</div>
)}
{/* Parameters Form (shown when activating) */}
{showParamsForm && parameters.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
</h3>
<HandParamsForm
parameters={parameters}
values={paramValues}
onChange={setParamValues}
errors={paramErrors}
disabled={isActivating}
presetKey={`hand-${hand.id}`}
/>
</div>
)}
{/* Dashboard Metrics */}
{hand.metrics && hand.metrics.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
({hand.metrics.length})
</h3>
<div className="grid grid-cols-3 gap-2">
{hand.metrics.map((metric, idx) => (
<div
key={idx}
className="bg-white dark:bg-gray-800 rounded p-2 text-center border border-gray-200 dark:border-gray-700"
>
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">{metric}</div>
<div className="text-lg font-semibold text-gray-400 dark:text-gray-500">-</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={showParamsForm ? () => setShowParamsForm(false) : onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
{showParamsForm ? '返回' : '关闭'}
</button>
<button
onClick={handleActivateClick}
disabled={!canActivate || hasUnmetRequirements || isActivating}
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isActivating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : hasUnmetRequirements ? (
<>
<Settings className="w-4 h-4" />
</>
) : showParamsForm ? (
<>
<Play className="w-4 h-4" />
</>
) : (
<>
<Zap className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Hand Card Component ===
interface HandCardProps {
hand: Hand;
onDetails: (hand: Hand) => void;
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
isActivating: boolean;
}
function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps) {
const canActivate = hand.status === 'idle';
const hasUnmetRequirements = hand.requirements_met === false;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl flex-shrink-0">{hand.icon || '🤖'}</span>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{hand.name}</h3>
</div>
<HandStatusBadge status={hand.status} />
</div>
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{hand.description}</p>
{/* Requirements Summary (if any unmet) */}
{hasUnmetRequirements && (
<div className="mb-3 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border border-orange-200 dark:border-orange-800">
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400 text-xs font-medium">
<AlertTriangle className="w-3.5 h-3.5" />
<span></span>
</div>
</div>
)}
{/* Meta Info */}
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mb-3">
{hand.toolCount !== undefined && (
<span>{hand.toolCount} </span>
)}
{hand.metricCount !== undefined && (
<span>{hand.metricCount} </span>
)}
{hand.category && (
<CategoryBadge category={hand.category} />
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => onDetails(hand)}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 flex items-center gap-1"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onActivate(hand)}
disabled={!canActivate || hasUnmetRequirements || isActivating}
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
{isActivating ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : hand.status === 'running' ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : (
<>
<Zap className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
</div>
);
}
// === Main HandsPanel Component ===
export function HandsPanel() {
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();
}, [loadHands]);
const handleDetails = useCallback(async (hand: Hand) => {
// Load full details before showing modal
const details = await getHandDetails(hand.id);
setSelectedHand(details || hand);
setShowModal(true);
}, [getHandDetails]);
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
setActivatingHandId(hand.id);
try {
const result = await triggerHand(hand.id, params);
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, toast, storeError]);
const handleCloseModal = useCallback(() => {
setShowModal(false);
setSelectedHand(null);
}, []);
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
if (!selectedHand) return;
setShowModal(false);
await handleActivate(selectedHand, params);
}, [selectedHand, handleActivate]);
if (isLoading && hands.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400"> Hands ...</p>
</div>
);
}
if (hands.length === 0) {
return (
<div className="p-4 text-center">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<Zap className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3"> Hands</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
ZCLAW
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Hands
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<button
onClick={() => loadHands()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* 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>
{/* 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
key={hand.id}
hand={hand}
/>
) : (
<HandCard
key={hand.id}
hand={hand}
onDetails={handleDetails}
onActivate={handleActivate}
isActivating={activatingHandId === hand.id}
/>
);
})}
</div>
{/* Details Modal */}
{selectedHand && (
<HandDetailsModal
hand={selectedHand}
isOpen={showModal}
onClose={handleCloseModal}
onActivate={handleModalActivate}
isActivating={activatingHandId === selectedHand.id}
/>
)}
</>
)}
</div>
);
}
export default HandsPanel;

View File

@@ -1,534 +0,0 @@
/**
* PipelineResultPreview - Pipeline 执行结果预览组件
*
* 展示 Pipeline 执行完成后的结果,支持多种预览模式:
* - JSON 数据预览
* - Markdown 渲染
* - 文件下载列表
* - 课堂预览器(特定 Pipeline
*/
import { useState } from 'react';
import {
FileText,
Download,
ExternalLink,
Copy,
Check,
Code,
File,
Presentation,
FileSpreadsheet,
X,
} from 'lucide-react';
import { PipelineRunResponse } from '../lib/pipeline-client';
import { useToast } from './ui/Toast';
import DOMPurify from 'dompurify';
import { ClassroomPreviewer, type ClassroomData } from './ClassroomPreviewer';
import { useClassroomStore } from '../store/classroomStore';
import { adaptToClassroom } from '../lib/classroom-adapter';
// === Types ===
interface PipelineResultPreviewProps {
result: PipelineRunResponse;
pipelineId: string;
onClose?: () => void;
}
type PreviewMode = 'auto' | 'json' | 'markdown' | 'classroom' | 'files';
// === Utility Functions ===
function getFileIcon(filename: string): React.ReactNode {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'pptx':
case 'ppt':
return <Presentation className="w-5 h-5 text-orange-500" />;
case 'xlsx':
case 'xls':
return <FileSpreadsheet className="w-5 h-5 text-green-500" />;
case 'pdf':
return <FileText className="w-5 h-5 text-red-500" />;
case 'html':
return <Code className="w-5 h-5 text-blue-500" />;
case 'md':
case 'markdown':
return <FileText className="w-5 h-5 text-gray-500" />;
default:
return <File className="w-5 h-5 text-gray-400" />;
}
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// === Sub-Components ===
interface FileDownloadCardProps {
file: {
name: string;
url: string;
size?: number;
};
}
function FileDownloadCard({ file }: FileDownloadCardProps) {
const handleDownload = () => {
// Create download link
const link = document.createElement('a');
link.href = file.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
{getFileIcon(file.name)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{file.name}
</p>
{file.size && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(file.size)}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => window.open(file.url, '_blank')}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
title="在新窗口打开"
>
<ExternalLink className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
);
}
interface JsonPreviewProps {
data: unknown;
}
function JsonPreview({ data }: JsonPreviewProps) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const jsonString = JSON.stringify(data, null, 2);
const handleCopy = async () => {
await navigator.clipboard.writeText(jsonString);
setCopied(true);
toast('已复制到剪贴板', 'success');
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative">
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
title="复制"
>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-sm max-h-96">
{jsonString}
</pre>
</div>
);
}
interface MarkdownPreviewProps {
content: string;
}
function MarkdownPreview({ content }: MarkdownPreviewProps) {
// Simple markdown rendering with XSS protection
const renderMarkdown = (md: string): string => {
// First, escape HTML entities to prevent XSS
const escaped = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return escaped
// Headers
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>')
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mt-4 mb-2">$1</h1>')
// Bold
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Lists
.replace(/^- (.*$)/gim, '<li class="ml-4">$1</li>')
// Paragraphs
.replace(/\n\n/g, '</p><p class="my-2">')
// Line breaks
.replace(/\n/g, '<br>');
};
return (
<div
className="prose dark:prose-invert max-w-none p-4 bg-white dark:bg-gray-800 rounded-lg"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(renderMarkdown(content)) }}
/>
);
}
// === Main Component ===
export function PipelineResultPreview({
result,
pipelineId,
onClose,
}: PipelineResultPreviewProps) {
const [mode, setMode] = useState<PreviewMode>('auto');
const { toast } = useToast();
// Determine the best preview mode
const outputs = result.outputs as Record<string, unknown> | undefined;
const exportFiles = (outputs?.export_files as Array<{ name: string; url: string; size?: number }>) || [];
// Check if this is a classroom pipeline
const isClassroom = pipelineId === 'classroom-generator' || pipelineId.includes('classroom');
// Auto-detect preview mode
const autoMode: PreviewMode = isClassroom ? 'classroom' :
exportFiles.length > 0 ? 'files' :
typeof outputs === 'object' ? 'json' : 'json';
const activeMode = mode === 'auto' ? autoMode : mode;
// Handle classroom export
const handleClassroomExport = (format: 'pptx' | 'html' | 'pdf', data: ClassroomData) => {
toast(`正在导出 ${format.toUpperCase()} 格式...`, 'info');
// Create downloadable content based on format
setTimeout(() => {
try {
if (format === 'html') {
// Generate HTML content
const htmlContent = generateClassroomHTML(data);
downloadFile(htmlContent, `${data.title}.html`, 'text/html');
toast('HTML 导出成功', 'success');
} else if (format === 'pptx') {
// For PPTX, we would need a library like pptxgenjs
// For now, export as JSON that can be converted
const pptxData = JSON.stringify(data, null, 2);
downloadFile(pptxData, `${data.title}.slides.json`, 'application/json');
toast('幻灯片数据已导出JSON格式', 'success');
} else if (format === 'pdf') {
// For PDF, we would need a library like jspdf
// For now, export as printable HTML
const htmlContent = generatePrintableHTML(data);
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(htmlContent);
printWindow.document.close();
printWindow.print();
toast('已打开打印预览', 'success');
}
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '导出失败';
toast(`导出失败: ${errorMsg}`, 'error');
}
}, 500);
};
// Render based on mode
const renderContent = () => {
switch (activeMode) {
case 'json':
return <JsonPreview data={outputs} />;
case 'markdown': {
const mdContent = (outputs?.summary || outputs?.report || JSON.stringify(outputs, null, 2)) as string;
return <MarkdownPreview content={mdContent} />;
}
case 'classroom': {
// Convert outputs to ClassroomData format
const classroomData: ClassroomData | null = outputs ? {
id: result.pipelineId || 'classroom',
title: (outputs.title as string) || '课堂内容',
subject: (outputs.subject as string) || '通用',
difficulty: (outputs.difficulty as '初级' | '中级' | '高级') || '中级',
duration: (outputs.duration as number) || 30,
scenes: Array.isArray(outputs.scenes) ? (outputs.scenes as ClassroomData['scenes']) : [],
outline: (outputs.outline as ClassroomData['outline']) || { sections: [] },
createdAt: new Date().toISOString(),
} : null;
if (classroomData && classroomData.scenes.length > 0) {
return (
<div className="-m-4">
<ClassroomPreviewer
data={classroomData}
onExport={(format) => {
// Handle export
handleClassroomExport(format, classroomData);
}}
onOpenFullPlayer={() => {
const classroom = adaptToClassroom(classroomData);
useClassroomStore.getState().setActiveClassroom(classroom);
useClassroomStore.getState().openClassroom();
}}
/>
</div>
);
}
return (
<div className="text-center py-8 text-gray-500">
<Presentation className="w-12 h-12 mx-auto mb-3 text-gray-400" />
<p></p>
<p className="text-sm mt-2"></p>
</div>
);
}
default:
return <JsonPreview data={outputs} />;
}
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Pipeline
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{result.pipelineId} · {result.status === 'completed' ? '成功' : result.status}
</p>
</div>
{onClose && (
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
<X className="w-5 h-5 text-gray-500" />
</button>
)}
</div>
{/* Mode Tabs */}
<div className="flex items-center gap-2 p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<button
onClick={() => setMode('auto')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'auto'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
</button>
<button
onClick={() => setMode('json')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'json'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
JSON
</button>
<button
onClick={() => setMode('markdown')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'markdown'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
Markdown
</button>
{isClassroom && (
<button
onClick={() => setMode('classroom')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'classroom'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
</button>
)}
</div>
{/* Content */}
<div className="p-4 overflow-auto max-h-96">
{renderContent()}
</div>
{/* Export Files */}
{exportFiles.length > 0 && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
({exportFiles.length})
</h3>
<div className="space-y-2">
{exportFiles.map((file, index) => (
<FileDownloadCard key={index} file={file} />
))}
</div>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<span className="text-xs text-gray-500 dark:text-gray-400">
: {new Date(result.startedAt).toLocaleString()}
</span>
{onClose && (
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md"
>
</button>
)}
</div>
</div>
);
}
export default PipelineResultPreview;
// === Helper Functions ===
function downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function generateClassroomHTML(data: ClassroomData): string {
const scenesHTML = data.scenes.map((scene, index) => `
<section class="slide ${index === 0 ? 'active' : ''}" data-index="${index}">
<div class="slide-content ${scene.type}">
<h2>${scene.content.heading || scene.title}</h2>
${scene.content.bullets ? `
<ul>
${scene.content.bullets.map(b => `<li>${b}</li>`).join('')}
</ul>
` : ''}
${scene.narration ? `<p class="narration">${scene.narration}</p>` : ''}
</div>
</section>
`).join('');
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${data.title}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #6366f1);
min-height: 100vh;
color: white;
}
.presentation {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
padding: 2rem 0;
border-bottom: 1px solid rgba(255,255,255,0.2);
margin-bottom: 2rem;
}
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
.meta { opacity: 0.8; font-size: 0.9rem; }
.slide {
background: rgba(255,255,255,0.1);
border-radius: 1rem;
padding: 2rem;
margin-bottom: 1.5rem;
backdrop-filter: blur(10px);
}
.slide h2 { font-size: 1.8rem; margin-bottom: 1rem; }
.slide ul { list-style: none; padding-left: 1rem; }
.slide li { margin-bottom: 0.75rem; font-size: 1.1rem; }
.slide li::before { content: '•'; color: #60a5fa; margin-right: 0.5rem; }
.narration {
background: rgba(0,0,0,0.3);
padding: 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-style: italic;
opacity: 0.9;
}
.title .slide-content {
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 300px;
}
.quiz { background: rgba(34, 197, 94, 0.2); }
.summary { background: rgba(168, 85, 247, 0.2); }
footer {
text-align: center;
padding: 2rem 0;
border-top: 1px solid rgba(255,255,255,0.2);
margin-top: 2rem;
opacity: 0.6;
}
</style>
</head>
<body>
<div class="presentation">
<header>
<h1>${data.title}</h1>
<p class="meta">${data.subject} · ${data.difficulty} · ${data.duration} 分钟</p>
</header>
<main>
${scenesHTML}
</main>
<footer>
<p>由 ZCLAW 课堂生成器创建</p>
</footer>
</div>
</body>
</html>`;
}
function generatePrintableHTML(data: ClassroomData): string {
return generateClassroomHTML(data);
}

View File

@@ -1,567 +0,0 @@
/**
* PipelinesPanel - Pipeline Discovery and Execution UI
*
* Displays available Pipelines (DSL-based workflows) with
* category filtering, search, and execution capabilities.
*
* Pipelines orchestrate Skills and Hands to accomplish complex tasks.
*/
import { useState, useEffect } from 'react';
import {
Play,
RefreshCw,
Search,
Loader2,
XCircle,
Package,
Filter,
X,
} from 'lucide-react';
import { PipelineResultPreview } from './PipelineResultPreview';
import {
PipelineClient,
PipelineInfo,
PipelineRunResponse,
usePipelines,
validateInputs,
getDefaultForType,
formatInputType,
} from '../lib/pipeline-client';
import { useToast } from './ui/Toast';
import { saasClient } from '../lib/saas-client';
// === Category Badge Component ===
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
education: { label: '教育', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
marketing: { label: '营销', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
legal: { label: '法律', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
productivity: { label: '生产力', className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
research: { label: '研究', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
sales: { label: '销售', className: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400' },
hr: { label: '人力', className: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' },
finance: { label: '财务', className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
default: { label: '其他', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400' },
};
function CategoryBadge({ category }: { category: string }) {
const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.default;
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${config.className}`}>
{config.label}
</span>
);
}
// === Pipeline Card Component ===
interface PipelineCardProps {
pipeline: PipelineInfo;
onRun: (pipeline: PipelineInfo) => void;
}
function PipelineCard({ pipeline, onRun }: PipelineCardProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<span className="text-2xl">{pipeline.icon}</span>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">
{pipeline.displayName}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{pipeline.id} · v{pipeline.version}
</p>
</div>
</div>
<CategoryBadge category={pipeline.category} />
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
{pipeline.description}
</p>
{pipeline.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{pipeline.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs text-gray-600 dark:text-gray-300"
>
{tag}
</span>
))}
{pipeline.tags.length > 3 && (
<span className="px-1.5 py-0.5 text-xs text-gray-400">
+{pipeline.tags.length - 3}
</span>
)}
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<span className="text-xs text-gray-400">
{pipeline.inputs.length}
</span>
<button
onClick={() => onRun(pipeline)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
>
<Play className="w-4 h-4" />
</button>
</div>
</div>
);
}
// === Pipeline Run Modal ===
interface RunModalProps {
pipeline: PipelineInfo;
onClose: () => void;
onComplete: (result: PipelineRunResponse) => void;
}
function RunModal({ pipeline, onClose, onComplete }: RunModalProps) {
const [values, setValues] = useState<Record<string, unknown>>(() => {
const defaults: Record<string, unknown> = {};
pipeline.inputs.forEach((input) => {
defaults[input.name] = input.default ?? getDefaultForType(input.inputType);
});
return defaults;
});
const [errors, setErrors] = useState<string[]>([]);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<PipelineRunResponse | null>(null);
const handleInputChange = (name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
setErrors([]);
};
const handleRun = async () => {
// Validate inputs
const validation = validateInputs(pipeline.inputs, values);
if (!validation.valid) {
setErrors(validation.errors);
return;
}
setRunning(true);
setProgress(null);
try {
const result = await PipelineClient.runAndWait(
{ pipelineId: pipeline.id, inputs: values },
(p) => setProgress(p)
);
if (result.status === 'completed') {
onComplete(result);
} else if (result.error) {
setErrors([result.error]);
}
} catch (err) {
setErrors([err instanceof Error ? err.message : String(err)]);
} finally {
setRunning(false);
}
};
const renderInput = (input: typeof pipeline.inputs[0]) => {
const value = values[input.name];
switch (input.inputType) {
case 'string':
case 'text':
return input.inputType === 'text' ? (
<textarea
value={(value as string) || ''}
onChange={(e) => handleInputChange(input.name, e.target.value)}
placeholder={input.placeholder}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
) : (
<input
type="text"
value={(value as string) || ''}
onChange={(e) => handleInputChange(input.name, e.target.value)}
placeholder={input.placeholder}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
case 'number':
return (
<input
type="number"
value={(value as number) ?? ''}
onChange={(e) => handleInputChange(input.name, e.target.valueAsNumber || 0)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
case 'boolean':
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={(value as boolean) || false}
onChange={(e) => handleInputChange(input.name, e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-600 dark:text-gray-300"></span>
</label>
);
case 'select':
return (
<select
value={(value as string) || ''}
onChange={(e) => handleInputChange(input.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">...</option>
{input.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
case 'multi-select':
return (
<div className="space-y-2">
{input.options.map((opt) => (
<label key={opt} className="flex items-center gap-2">
<input
type="checkbox"
checked={((value as string[]) || []).includes(opt)}
onChange={(e) => {
const current = (value as string[]) || [];
const updated = e.target.checked
? [...current, opt]
: current.filter((v) => v !== opt);
handleInputChange(input.name, updated);
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-600 dark:text-gray-300">{opt}</span>
</label>
))}
</div>
);
default:
return (
<input
type="text"
value={(value as string) || ''}
onChange={(e) => handleInputChange(input.name, e.target.value)}
placeholder={input.placeholder}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
{/* 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-3">
<span className="text-2xl">{pipeline.icon}</span>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{pipeline.displayName}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{pipeline.description}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Form */}
<div className="p-4 space-y-4">
{pipeline.inputs.map((input) => (
<div key={input.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{input.label}
{input.required && <span className="text-red-500 ml-1">*</span>}
<span className="text-xs text-gray-400 ml-2">
({formatInputType(input.inputType)})
</span>
</label>
{renderInput(input)}
</div>
))}
{errors.length > 0 && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
{errors.map((error, i) => (
<p key={i} className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
))}
</div>
)}
{/* Progress */}
{running && progress && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<div className="flex items-center gap-2 mb-2">
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{progress.message || '运行中...'}
</span>
</div>
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
disabled={running}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50"
>
</button>
<button
onClick={handleRun}
disabled={running}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md disabled:opacity-50"
>
{running ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Main Pipelines Panel ===
export function PipelinesPanel() {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedPipeline, setSelectedPipeline] = useState<PipelineInfo | null>(null);
const [runResult, setRunResult] = useState<{ result: PipelineRunResponse; pipeline: PipelineInfo } | null>(null);
const { toast } = useToast();
// Subscribe to pipeline-complete push events (for background completion)
useEffect(() => {
let unlisten: (() => void) | undefined;
PipelineClient.onComplete((event) => {
// Only show notification if we're not already tracking this run
// (the polling path handles in-flight runs via handleRunComplete)
if (selectedPipeline?.id === event.pipelineId) return;
if (event.status === 'completed') {
toast(`Pipeline "${event.pipelineId}" 后台执行完成`, 'success');
} else if (event.status === 'failed') {
toast(`Pipeline "${event.pipelineId}" 后台执行失败: ${event.error ?? ''}`, 'error');
}
}).then((fn) => { unlisten = fn; });
return () => { unlisten?.(); };
}, [selectedPipeline, toast]);
// Fetch all pipelines without filtering
const { pipelines, loading, error, refresh } = usePipelines({});
// Get unique categories from ALL pipelines (not filtered)
const categories = Array.from(
new Set(pipelines.map((p) => p.category).filter(Boolean))
);
// Filter pipelines by selected category and search
const filteredPipelines = pipelines.filter((p) => {
// Category filter
if (selectedCategory && p.category !== selectedCategory) {
return false;
}
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
p.displayName.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.tags.some((t) => t.toLowerCase().includes(query))
);
}
return true;
});
const handleRunPipeline = (pipeline: PipelineInfo) => {
setSelectedPipeline(pipeline);
};
const handleRunComplete = (result: PipelineRunResponse) => {
setSelectedPipeline(null);
if (result.status === 'completed') {
toast('Pipeline 执行完成', 'success');
setRunResult({ result, pipeline: selectedPipeline! });
// Report pipeline execution to billing (fire-and-forget)
try {
if (saasClient.isAuthenticated()) {
saasClient.reportUsageFireAndForget('pipeline_runs');
}
} catch { /* billing reporting must never block */ }
} else {
toast(`Pipeline 执行失败: ${result.error}`, 'error');
}
};
return (
<div className="h-full flex flex-col">
{/* 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">
<Package className="w-5 h-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Pipelines
</h2>
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded-full text-xs text-gray-600 dark:text-gray-300">
{pipelines.length}
</span>
</div>
<button
onClick={refresh}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Filters */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索 Pipelines..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
{/* Category filters */}
{categories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-gray-400" />
<button
onClick={() => setSelectedCategory(null)}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedCategory === null
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
</button>
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedCategory === cat
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{CATEGORY_CONFIG[cat]?.label || cat}
</button>
))}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : error ? (
<div className="text-center py-8 text-red-500">
<XCircle className="w-8 h-8 mx-auto mb-2" />
<p>{error}</p>
</div>
) : filteredPipelines.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Package className="w-8 h-8 mx-auto mb-2" />
<p> Pipeline</p>
{searchQuery && <p className="text-sm mt-1"></p>}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredPipelines.map((pipeline) => (
<PipelineCard
key={pipeline.id}
pipeline={pipeline}
onRun={handleRunPipeline}
/>
))}
</div>
)}
</div>
{/* Run Modal */}
{selectedPipeline && (
<RunModal
pipeline={selectedPipeline}
onClose={() => setSelectedPipeline(null)}
onComplete={handleRunComplete}
/>
)}
{/* Result Preview */}
{runResult && (
<PipelineResultPreview
result={runResult.result}
pipelineId={runResult.pipeline.id}
onClose={() => setRunResult(null)}
/>
)}
</div>
);
}
export default PipelinesPanel;

View File

@@ -1,173 +0,0 @@
/**
* SubscriptionPanel — 当前订阅详情面板
*
* 展示当前计划、试用/到期状态、用量配额和操作入口。
*/
import { useEffect } from 'react';
import { useSaaSStore } from '../../store/saasStore';
import {
CheckCircle,
Clock,
AlertTriangle,
Loader2,
CreditCard,
Crown,
} from 'lucide-react';
const PLAN_LABELS: Record<string, string> = {
free: '免费版',
pro: '专业版',
team: '团队版',
};
const PLAN_COLORS: Record<string, string> = {
free: '#8c8c8c',
pro: '#863bff',
team: '#47bfff',
};
function UsageBar({ label, current, max }: { label: string; current: number; max: number | null }) {
const pct = max ? Math.min((current / max) * 100, 100) : 0;
const displayMax = max ? max.toLocaleString() : '∞';
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500';
return (
<div className="mb-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{label}</span>
<span>{current.toLocaleString()} / {displayMax}</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${Math.max(pct, 1)}%` }}
/>
</div>
</div>
);
}
export function SubscriptionPanel() {
const subscription = useSaaSStore((s) => s.subscription);
const billingLoading = useSaaSStore((s) => s.billingLoading);
const fetchBillingOverview = useSaaSStore((s) => s.fetchBillingOverview);
useEffect(() => {
fetchBillingOverview().catch(() => {});
}, [fetchBillingOverview]);
if (billingLoading && !subscription) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
</div>
);
}
const plan = subscription?.plan;
const sub = subscription?.subscription;
const usage = subscription?.usage;
const planName = plan?.name || 'free';
const color = PLAN_COLORS[planName] || '#666';
const label = PLAN_LABELS[planName] || planName;
// Trial / expiry status
const isTrialing = sub?.status === 'trialing';
const isActive = sub?.status === 'active';
const trialEnd = sub?.trial_end ? new Date(sub.trial_end) : null;
const periodEnd = sub?.current_period_end ? new Date(sub.current_period_end) : null;
const now = Date.now();
const daysLeft = trialEnd
? Math.max(0, Math.ceil((trialEnd.getTime() - now) / (1000 * 60 * 60 * 24)))
: periodEnd
? Math.max(0, Math.ceil((periodEnd.getTime() - now) / (1000 * 60 * 60 * 24)))
: null;
const showExpiryWarning = daysLeft !== null && daysLeft <= 3;
return (
<div className="space-y-5">
{/* Plan badge */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-white"
style={{ background: color }}
>
<Crown className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
{isTrialing && (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
<Clock className="w-3 h-3" />
</span>
)}
{isActive && (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
<CheckCircle className="w-3 h-3" />
</span>
)}
</div>
</div>
<CreditCard className="w-5 h-5 text-gray-400" />
</div>
{/* Expiry / trial warning */}
{showExpiryWarning && (
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm mb-3 ${
isTrialing
? 'bg-amber-50 text-amber-700 border border-amber-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>
{isTrialing
? `试用还剩 ${daysLeft} 天,请尽快升级以保留 Pro 功能`
: `订阅将在 ${daysLeft} 天后到期`}
</span>
</div>
)}
{daysLeft !== null && !showExpiryWarning && (
<p className="text-xs text-gray-400">
{isTrialing ? `试用剩余 ${daysLeft}` : `订阅到期: ${periodEnd?.toLocaleDateString()}`}
</p>
)}
</div>
{/* Usage */}
{usage && (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-900 mb-4"></h3>
<UsageBar label="中转请求" current={usage.relay_requests} max={usage.max_relay_requests} />
<UsageBar label="Hand 执行" current={usage.hand_executions} max={usage.max_hand_executions} />
<UsageBar label="Pipeline 运行" current={usage.pipeline_runs} max={usage.max_pipeline_runs} />
{sub && (
<p className="mt-2 text-xs text-gray-400">
: {new Date(sub.current_period_start).toLocaleDateString()} {new Date(sub.current_period_end).toLocaleDateString()}
</p>
)}
</div>
)}
{/* Free plan upgrade prompt */}
{planName === 'free' && (
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-2"></h3>
<p className="text-xs text-gray-600 mb-3">
9 Hands Pipeline
</p>
<p className="text-xs text-purple-600">
</p>
</div>
)}
</div>
);
}

View File

@@ -1,990 +0,0 @@
/**
* SchedulerPanel - ZCLAW Scheduler UI
*
* Displays scheduled jobs, event triggers, workflows, and run history.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useHandStore } from '../store/handStore';
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { WorkflowEditor } from './WorkflowEditor';
import { WorkflowHistory } from './WorkflowHistory';
import { TriggersPanel } from './TriggersPanel';
import {
Clock,
Zap,
History,
Plus,
RefreshCw,
Loader2,
Calendar,
X,
AlertCircle,
CheckCircle,
GitBranch,
Play,
} from 'lucide-react';
// === Tab Types ===
type TabType = 'scheduled' | 'triggers' | 'workflows' | '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({
active,
onClick,
icon: Icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
active
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
);
}
// === Empty State Component ===
function EmptyState({
icon: Icon,
title,
description,
actionLabel,
onAction,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<div className="text-center py-8">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<Icon className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{title}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4 max-w-sm mx-auto">
{description}
</p>
{actionLabel && onAction && (
<button
onClick={onAction}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
{actionLabel}
</button>
)}
</div>
);
}
// === Create Job Modal Component ===
interface CreateJobModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
function CreateJobModal({ isOpen, onClose, onSuccess }: CreateJobModalProps) {
// Store state - use domain stores
const hands = useHandStore((s) => s.hands);
const workflows = useWorkflowStore((s) => s.workflows);
const clones = useAgentStore((s) => s.clones);
const createScheduledTask = useConfigStore((s) => s.createScheduledTask);
const loadHands = useHandStore((s) => s.loadHands);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const loadClones = useAgentStore((s) => s.loadClones);
const [formData, setFormData] = useState<JobFormData>(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, string> = {};
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 = <K extends keyof JobFormData>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Task Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.name}
</p>
)}
</div>
{/* Schedule Type */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
{[
{ value: 'cron', label: 'Cron 表达式' },
{ value: 'interval', label: '固定间隔' },
{ value: 'once', label: '执行一次' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => updateField('scheduleType', option.value as ScheduleType)}
className={`flex-1 px-3 py-2 text-sm rounded-lg border transition-colors ${
formData.scheduleType === option.value
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-500'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Cron Expression */}
{formData.scheduleType === 'cron' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cron <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.cronExpression}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.cronExpression}
</p>
)}
<p className="mt-1 text-xs text-gray-400">
格式: (例如: 0 9 * * * 9:00)
</p>
</div>
)}
{/* Interval Settings */}
{formData.scheduleType === 'interval' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="number"
min="1"
value={formData.intervalValue}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.intervalValue}
</p>
)}
</div>
<div className="w-32">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<select
value={formData.intervalUnit}
onChange={(e) => updateField('intervalUnit', e.target.value as 'minutes' | 'hours' | 'days')}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="minutes"></option>
<option value="hours"></option>
<option value="days"></option>
</select>
</div>
</div>
)}
{/* Run Once Settings */}
{formData.scheduleType === 'once' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.runOnceDate}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.runOnceDate}
</p>
)}
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="time"
value={formData.runOnceTime}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.runOnceTime}
</p>
)}
</div>
</div>
)}
{/* Target Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<div className="flex gap-2">
{[
{ value: 'hand', label: 'Hand' },
{ value: 'workflow', label: 'Workflow' },
{ value: 'agent', label: 'Agent' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
updateField('targetType', option.value as TargetType);
updateField('targetId', ''); // Reset target when type changes
}}
className={`flex-1 px-3 py-2 text-sm rounded-lg border transition-colors ${
formData.targetType === option.value
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-500'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Target Selection Dropdown */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<select
value={formData.targetId}
onChange={(e) => updateField('targetId', 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.targetId ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
>
<option value="">-- --</option>
{getAvailableTargets().map((target) => (
<option key={target.id} value={target.id}>
{target.name || target.id}
</option>
))}
</select>
{errors.targetId && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.targetId}
</p>
)}
{getAvailableTargets().length === 0 && (
<p className="mt-1 text-xs text-gray-400">
{formData.targetType === 'hand' ? 'Hands' : formData.targetType === 'workflow' ? 'Workflows' : 'Agents'}
</p>
)}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="可选的任务描述..."
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 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 resize-none"
/>
</div>
{/* Enabled Toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={formData.enabled}
onChange={(e) => updateField('enabled', e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="enabled" className="text-sm text-gray-700 dark:text-gray-300">
</label>
</div>
{/* Status Messages */}
{submitStatus === 'success' && (
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-green-700 dark:text-green-400">
<CheckCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">!</span>
</div>
)}
{submitStatus === 'error' && (
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-red-700 dark:text-red-400">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">{errorMessage}</span>
</div>
)}
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
</button>
<button
type="submit"
form="job-form"
onClick={handleSubmit}
disabled={isSubmitting || submitStatus === 'success'}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Plus className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Main SchedulerPanel Component ===
export function SchedulerPanel() {
// Store state - use domain stores
const scheduledTasks = useConfigStore((s) => s.scheduledTasks);
const loadScheduledTasks = useConfigStore((s) => s.loadScheduledTasks);
const workflows = useWorkflowStore((s) => s.workflows);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
const executeWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
const handLoading = useHandStore((s) => s.isLoading);
const workflowLoading = useWorkflowStore((s) => s.isLoading);
const configLoading = useConfigStore((s) => s.isLoading);
const isLoading = handLoading || workflowLoading || configLoading;
const [activeTab, setActiveTab] = useState<TabType>('scheduled');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isWorkflowEditorOpen, setIsWorkflowEditorOpen] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | undefined>(undefined);
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [isSavingWorkflow, setIsSavingWorkflow] = useState(false);
useEffect(() => {
loadScheduledTasks();
loadWorkflows();
}, [loadScheduledTasks, loadWorkflows]);
const handleCreateJob = useCallback(() => {
setIsCreateModalOpen(true);
}, []);
const handleCreateSuccess = useCallback(() => {
loadScheduledTasks();
}, [loadScheduledTasks]);
// Workflow handlers
const handleCreateWorkflow = useCallback(() => {
setEditingWorkflow(undefined);
setIsWorkflowEditorOpen(true);
}, []);
const handleEditWorkflow = useCallback((workflow: Workflow) => {
setEditingWorkflow(workflow);
setIsWorkflowEditorOpen(true);
}, []);
const handleViewWorkflowHistory = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);
}, []);
const handleSaveWorkflow = useCallback(async (data: {
name: string;
description?: string;
steps: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}) => {
setIsSavingWorkflow(true);
try {
if (editingWorkflow) {
// Update existing workflow
await updateWorkflow(editingWorkflow.id, data);
} else {
// Create new workflow
await createWorkflow(data);
}
setIsWorkflowEditorOpen(false);
setEditingWorkflow(undefined);
await loadWorkflows();
} catch (error) {
console.error('Failed to save workflow:', error);
throw error;
} finally {
setIsSavingWorkflow(false);
}
}, [editingWorkflow, createWorkflow, updateWorkflow, loadWorkflows]);
const handleExecuteWorkflow = useCallback(async (workflowId: string) => {
try {
await executeWorkflow(workflowId);
await loadWorkflows();
} catch (error) {
console.error('Failed to execute workflow:', error);
}
}, [executeWorkflow, loadWorkflows]);
if (isLoading && scheduledTasks.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
...
</p>
</div>
);
}
return (
<>
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
</p>
</div>
<button
onClick={() => loadScheduledTasks()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Tab Navigation */}
<div className="flex items-center justify-between">
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<TabButton
active={activeTab === 'scheduled'}
onClick={() => setActiveTab('scheduled')}
icon={Clock}
label="定时任务"
/>
<TabButton
active={activeTab === 'triggers'}
onClick={() => setActiveTab('triggers')}
icon={Zap}
label="事件触发器"
/>
<TabButton
active={activeTab === 'workflows'}
onClick={() => setActiveTab('workflows')}
icon={GitBranch}
label="工作流"
/>
<TabButton
active={activeTab === 'history'}
onClick={() => setActiveTab('history')}
icon={History}
label="运行历史"
/>
</div>
{activeTab === 'scheduled' && (
<button
onClick={handleCreateJob}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
)}
{activeTab === 'workflows' && (
<button
onClick={handleCreateWorkflow}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
{/* Tab Content */}
{activeTab === 'scheduled' && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
{scheduledTasks.length === 0 ? (
<EmptyState
icon={Calendar}
title="暂无定时任务"
description="创建一个定时任务来定期运行代理。"
actionLabel="创建定时任务"
onAction={handleCreateJob}
/>
) : (
<div className="space-y-2">
{scheduledTasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{task.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{task.schedule}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded text-xs ${
task.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: task.status === 'paused'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{task.status === 'active' ? '运行中' : task.status === 'paused' ? '已暂停' : task.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'triggers' && (
<TriggersPanel />
)}
{/* Workflows Tab */}
{activeTab === 'workflows' && (
selectedWorkflow ? (
<WorkflowHistory
workflow={selectedWorkflow}
onBack={() => setSelectedWorkflow(null)}
/>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
{workflows.length === 0 ? (
<EmptyState
icon={GitBranch}
title="暂无工作流"
description="工作流可以将多个 Hand 组合成自动化流程,实现复杂的任务编排。"
actionLabel="创建工作流"
onAction={handleCreateWorkflow}
/>
) : (
<div className="space-y-2">
{workflows.map((workflow) => (
<div
key={workflow.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<GitBranch className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{workflow.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{workflow.description || '无描述'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{workflow.steps && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{workflow.steps}
</span>
)}
<button
onClick={() => handleExecuteWorkflow(workflow.id)}
className="p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded"
title="执行"
>
<Play className="w-4 h-4" />
</button>
<button
onClick={() => handleEditWorkflow(workflow)}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="编辑"
>
<GitBranch className="w-4 h-4" />
</button>
<button
onClick={() => handleViewWorkflowHistory(workflow)}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="历史"
>
<History className="w-4 h-4" />
</button>
</div>
</div>
))}
<button
onClick={handleCreateWorkflow}
className="w-full flex items-center justify-center gap-2 p-3 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-500 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 dark:hover:border-blue-400 dark:hover:text-blue-400 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
)}
</div>
)
)}
{activeTab === 'history' && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<EmptyState
icon={History}
title="暂无运行历史"
description="当定时任务或事件触发器执行时,运行记录将显示在这里,包括状态和日志。"
/>
</div>
)}
</div>
{/* Create Job Modal */}
<CreateJobModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
{/* Workflow Editor Modal */}
<WorkflowEditor
workflow={editingWorkflow}
isOpen={isWorkflowEditorOpen}
onClose={() => {
setIsWorkflowEditorOpen(false);
setEditingWorkflow(undefined);
}}
onSave={handleSaveWorkflow}
isSaving={isSavingWorkflow}
/>
</>
);
}
export default SchedulerPanel;

View File

@@ -1,54 +0,0 @@
/**
* SimpleTopBar - Minimal top bar for simple UI mode
*
* Shows only the ZCLAW logo and a mode-toggle button.
* Designed for the streamlined "simple" experience.
*/
import { LayoutGrid } from 'lucide-react';
import { motion } from 'framer-motion';
// === Types ===
interface SimpleTopBarProps {
onToggleMode: () => void;
}
// === Component ===
export function SimpleTopBar({ onToggleMode }: SimpleTopBarProps) {
return (
<header className="h-10 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 flex items-center px-4 flex-shrink-0 select-none">
{/* Logo */}
<div className="flex items-center gap-1.5">
<span className="font-bold text-lg bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
ZCLAW
</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Mode toggle button */}
<motion.button
type="button"
onClick={onToggleMode}
className="
flex items-center gap-1.5 px-2.5 py-1 rounded-md
text-xs text-gray-500 dark:text-gray-400
hover:text-gray-900 dark:hover:text-gray-100
hover:bg-gray-100 dark:hover:bg-gray-800
transition-colors duration-150
"
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
title="切换到专业模式"
>
<LayoutGrid className="w-3.5 h-3.5" />
<span></span>
</motion.button>
</header>
);
}
export default SimpleTopBar;

View File

@@ -1,211 +0,0 @@
/**
* * SkillCard - 技能卡片组件
*
* * 展示单个技能的基本信息,包括名称、描述、能力和安装状态
*/
import { motion } from 'framer-motion';
import {
Package,
Check,
Star,
MoreHorizontal,
Clock,
} from 'lucide-react';
import type { Skill } from '../../types/skill-market';
import { useState } from 'react';
// === 类型定义 ===
interface SkillCardProps {
/** 技能数据 */
skill: Skill;
/** 是否选中 */
isSelected?: boolean;
/** 点击回调 */
onClick?: () => void;
/** 安装/卸载回调 */
onToggleInstall?: () => void;
/** 显示更多操作 */
onShowMore?: () => void;
}
// === 分类配置 ===
const CATEGORY_CONFIG: Record<string, { color: string; bgColor: string }> = {
development: { color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/30' },
security: { color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/30' },
analytics: { color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30' },
content: { color: 'text-pink-600 dark:text-pink-400', bgColor: 'bg-pink-100 dark:bg-pink-900/30' },
ops: { color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-900/30' },
management: { color: 'text-cyan-600 dark:text-cyan-400', bgColor: 'bg-cyan-100 dark:bg-cyan-900/30' },
testing: { color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-100 dark:bg-emerald-900/30' },
business: { color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-100 dark:bg-amber-900/30' },
marketing: { color: 'text-rose-600 dark:text-rose-400', bgColor: 'bg-rose-100 dark:bg-rose-900/30' },
};
// === 分类名称映射 ===
const CATEGORY_NAMES: Record<string, string> = {
development: '开发',
security: '安全',
analytics: '分析',
content: '内容',
ops: '运维',
management: '管理',
testing: '测试',
business: '商务',
marketing: '营销',
};
/**
* SkillCard - 技能卡片组件
*/
export function SkillCard({
skill,
isSelected = false,
onClick,
onToggleInstall,
onShowMore,
}: SkillCardProps) {
const [isHovered, setIsHovered] = useState(false);
const categoryConfig = CATEGORY_CONFIG[skill.category] || {
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-100 dark:bg-gray-800/30',
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
onClick={onClick}
className={`
relative p-4 rounded-lg border cursor-pointer transition-all duration-200
${isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600'
}
`}
>
{/* 顶部:图标和名称 */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${categoryConfig.bgColor}`}
>
<Package className={`w-5 h-5 ${categoryConfig.color}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{skill.name}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{skill.author || '官方'}
</p>
</div>
</div>
{/* 安装按钮 */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => {
e.stopPropagation();
onToggleInstall?.();
}}
className={`
px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
${skill.installed
? 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-gray-700 dark:bg-gray-600 text-white hover:bg-gray-800 dark:hover:bg-gray-500'
}
`}
>
{skill.installed ? (
<span className="flex items-center gap-1">
<Check className="w-3 h-3" />
</span>
) : (
<span className="flex items-center gap-1">
<Package className="w-3 h-3" />
</span>
)}
</motion.button>
</div>
{/* 描述 */}
<p className="text-xs text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
{skill.description}
</p>
{/* 标签和能力 */}
<div className="flex flex-wrap gap-1 mb-3">
{skill.capabilities.slice(0, 3).map((cap) => (
<span
key={cap}
className="px-2 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
>
{cap}
</span>
))}
{skill.capabilities.length > 3 && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
+{skill.capabilities.length - 3}
</span>
)}
</div>
{/* 底部:分类、评分和统计 */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<span
className={`px-2 py-0.5 rounded text-xs ${categoryConfig.bgColor} ${categoryConfig.color}`}
>
{CATEGORY_NAMES[skill.category] || skill.category}
</span>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
{skill.rating !== undefined && (
<span className="flex items-center gap-1">
<Star className="w-3 h-3 text-yellow-500 fill-current" />
{skill.rating.toFixed(1)}
</span>
)}
{skill.reviewCount !== undefined && skill.reviewCount > 0 && (
<span>{skill.reviewCount} </span>
)}
{skill.installedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(skill.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
{/* 悬停时显示更多按钮 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isHovered ? 1 : 0 }}
className="absolute top-2 right-2"
>
<button
onClick={(e) => {
e.stopPropagation();
onShowMore?.();
}}
className="p-1.5 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="更多操作"
>
<MoreHorizontal className="w-4 h-4" />
</button>
</motion.div>
</motion.div>
);
}
export default SkillCard;

View File

@@ -1,92 +0,0 @@
/**
* Node Palette Component
*
* Draggable palette of available node types.
*/
import { DragEvent } from 'react';
import type { NodePaletteItem, NodeCategory } from '../../lib/workflow-builder/types';
interface NodePaletteProps {
categories: Record<NodeCategory, NodePaletteItem[]>;
onDragStart: (type: string) => void;
onDragEnd: () => void;
}
const categoryLabels: Record<NodeCategory, { label: string; color: string }> = {
input: { label: 'Input', color: 'emerald' },
ai: { label: 'AI & Skills', color: 'violet' },
action: { label: 'Actions', color: 'amber' },
control: { label: 'Control Flow', color: 'orange' },
output: { label: 'Output', color: 'blue' },
};
export function NodePalette({ categories, onDragStart, onDragEnd }: NodePaletteProps) {
const handleDragStart = (event: DragEvent, type: string) => {
event.dataTransfer.setData('application/reactflow', type);
event.dataTransfer.effectAllowed = 'move';
onDragStart(type);
};
const handleDragEnd = () => {
onDragEnd();
};
return (
<div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-800">Nodes</h2>
<p className="text-sm text-gray-500">Drag nodes to canvas</p>
</div>
<div className="p-2">
{(Object.keys(categories) as NodeCategory[]).map((category) => {
const items = categories[category];
if (items.length === 0) return null;
const { label, color } = categoryLabels[category];
return (
<div key={category} className="mb-4">
<h3
className={`text-sm font-medium text-${color}-700 mb-2 px-2`}
>
{label}
</h3>
<div className="space-y-1">
{items.map((item) => (
<div
key={item.type}
draggable
onDragStart={(e) => handleDragStart(e, item.type)}
onDragEnd={handleDragEnd}
className={`
flex items-center gap-3 px-3 py-2 rounded-lg
bg-gray-50 hover:bg-gray-100 cursor-grab
border border-transparent hover:border-gray-200
transition-all duration-150
active:cursor-grabbing
`}
>
<span className="text-lg">{item.icon}</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-700 text-sm">
{item.label}
</div>
<div className="text-xs text-gray-500 truncate">
{item.description}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
export default NodePalette;

View File

@@ -1,301 +0,0 @@
/**
* Property Panel Component
*
* Panel for editing node properties.
*/
import { useState, useEffect } from 'react';
import type { WorkflowNodeData } from '../../lib/workflow-builder/types';
interface PropertyPanelProps {
nodeId: string;
nodeData: WorkflowNodeData | undefined;
onUpdate: (data: Partial<WorkflowNodeData>) => void;
onDelete: () => void;
onClose: () => void;
}
export function PropertyPanel({
nodeData,
onUpdate,
onDelete,
onClose,
}: PropertyPanelProps) {
const [localData, setLocalData] = useState<Partial<WorkflowNodeData>>({});
useEffect(() => {
if (nodeData) {
setLocalData(nodeData);
}
}, [nodeData]);
if (!nodeData) return null;
const handleChange = (field: string, value: unknown) => {
const updated = { ...localData, [field]: value };
setLocalData(updated);
onUpdate({ [field]: value } as Partial<WorkflowNodeData>);
};
return (
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h2 className="font-semibold text-gray-800">Properties</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Common Fields */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Label
</label>
<input
type="text"
value={localData.label || ''}
onChange={(e) => handleChange('label', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Type-specific Fields */}
{renderTypeSpecificFields(nodeData.type, localData, handleChange)}
{/* Delete Button */}
<div className="pt-4 border-t border-gray-200">
<button
onClick={onDelete}
className="w-full px-4 py-2 text-red-600 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100"
>
Delete Node
</button>
</div>
</div>
</div>
);
}
function renderTypeSpecificFields(
type: string,
data: Partial<WorkflowNodeData>,
onChange: (field: string, value: unknown) => void
) {
// Type-safe property accessors for union-typed node data
const d = data as Record<string, unknown>;
const str = (key: string): string => (d[key] as string) || '';
const num = (key: string): number | string => (d[key] as number) ?? '';
const bool = (key: string): boolean => (d[key] as boolean) || false;
const arr = (key: string): string[] => (d[key] as string[]) || [];
const obj = (key: string): Record<string, unknown> => (d[key] as Record<string, unknown>) || {};
switch (type) {
case 'input':
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Variable Name
</label>
<input
type="text"
value={str('variableName')}
onChange={(e) => onChange('variableName', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Default Value
</label>
<textarea
value={str('defaultValue')}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange('defaultValue', parsed);
} catch {
onChange('defaultValue', e.target.value);
}
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
rows={3}
placeholder="JSON or string value"
/>
</div>
</>
);
case 'llm':
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Template
</label>
<textarea
value={str('template')}
onChange={(e) => onChange('template', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
rows={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Model Override
</label>
<input
type="text"
value={str('model')}
onChange={(e) => onChange('model', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="e.g., gpt-4"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Temperature
</label>
<input
type="number"
min="0"
max="2"
step="0.1"
value={num('temperature')}
onChange={(e) => onChange('temperature', parseFloat(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={bool('jsonMode')}
onChange={(e) => onChange('jsonMode', e.target.checked)}
className="w-4 h-4 text-blue-600 rounded"
/>
<label className="text-sm text-gray-700">JSON Mode</label>
</div>
</>
);
case 'skill':
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Skill ID
</label>
<input
type="text"
value={str('skillId')}
onChange={(e) => onChange('skillId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Input Mappings (JSON)
</label>
<textarea
value={JSON.stringify(obj('inputMappings'), null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange('inputMappings', parsed);
} catch {
// Invalid JSON, ignore
}
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
rows={4}
/>
</div>
</>
);
case 'hand':
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Hand ID
</label>
<input
type="text"
value={str('handId')}
onChange={(e) => onChange('handId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Action
</label>
<input
type="text"
value={str('action')}
onChange={(e) => onChange('action', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</>
);
case 'export':
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Formats
</label>
<div className="space-y-2">
{['json', 'markdown', 'html', 'pptx', 'pdf'].map((format) => (
<label key={format} className="flex items-center gap-2">
<input
type="checkbox"
checked={arr('formats').includes(format)}
onChange={(e) => {
const formats = arr('formats');
if (e.target.checked) {
onChange('formats', [...formats, format]);
} else {
onChange('formats', formats.filter((f) => f !== format));
}
}}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700 capitalize">{format}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Output Directory
</label>
<input
type="text"
value={str('outputDir')}
onChange={(e) => onChange('outputDir', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
placeholder="./output"
/>
</div>
</>
);
default:
return (
<div className="text-sm text-gray-500 italic">
No additional properties for this node type.
</div>
);
}
}
export default PropertyPanel;

View File

@@ -1,324 +0,0 @@
/**
* Workflow Builder Component
*
* Visual workflow editor using React Flow for creating and editing
* Pipeline DSL configurations.
*/
import { useCallback, useRef, useEffect } from 'react';
import {
ReactFlow,
Controls,
Background,
MiniMap,
BackgroundVariant,
Connection,
addEdge,
useNodesState,
useEdgesState,
Node,
NodeChange,
EdgeChange,
Edge,
NodeTypes,
ReactFlowProvider,
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useWorkflowBuilderStore, paletteCategories } from '../../store/workflowBuilderStore';
import type { WorkflowNodeData, WorkflowNodeType } from '../../lib/workflow-builder/types';
// Import custom node components
import { InputNode } from './nodes/InputNode';
import { LlmNode } from './nodes/LlmNode';
import { SkillNode } from './nodes/SkillNode';
import { HandNode } from './nodes/HandNode';
import { ConditionNode } from './nodes/ConditionNode';
import { ParallelNode } from './nodes/ParallelNode';
import { ExportNode } from './nodes/ExportNode';
import { HttpNode } from './nodes/HttpNode';
import { OrchestrationNode } from './nodes/OrchestrationNode';
import { NodePalette } from './NodePalette';
import { PropertyPanel } from './PropertyPanel';
import { WorkflowToolbar } from './WorkflowToolbar';
// =============================================================================
// Node Types Configuration
// =============================================================================
const nodeTypes: NodeTypes = {
input: InputNode,
llm: LlmNode,
skill: SkillNode,
hand: HandNode,
condition: ConditionNode,
parallel: ParallelNode,
export: ExportNode,
http: HttpNode,
orchestration: OrchestrationNode,
};
// =============================================================================
// Main Component
// =============================================================================
export function WorkflowBuilderInternal() {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = useReactFlow();
const {
canvas,
isDirty,
selectedNodeId,
validation,
addNode,
updateNode,
deleteNode,
addEdge: addStoreEdge,
selectNode,
saveWorkflow,
validate,
setDragging,
} = useWorkflowBuilderStore();
// Local state for React Flow
const [nodes, setNodes, onNodesChange] = useNodesState<Node<WorkflowNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
// Sync canvas state with React Flow
useEffect(() => {
if (canvas) {
setNodes(canvas.nodes.map(n => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data as WorkflowNodeData,
})));
setEdges(canvas.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: e.type || 'default',
animated: true,
})));
} else {
setNodes([]);
setEdges([]);
}
}, [canvas?.id]);
// Handle node changes (position, selection)
const handleNodesChange = useCallback(
(changes: NodeChange<Node<WorkflowNodeData>>[]) => {
onNodesChange(changes);
// Sync position changes back to store
for (const change of changes) {
if (change.type === 'position' && change.position) {
const node = nodes.find(n => n.id === change.id);
if (node) {
// Position updates are handled by React Flow internally
}
}
if (change.type === 'select') {
selectNode(change.selected ? change.id : null);
}
}
},
[onNodesChange, nodes, selectNode]
);
// Handle edge changes
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
onEdgesChange(changes);
},
[onEdgesChange]
);
// Handle new connections
const onConnect = useCallback(
(connection: Connection) => {
if (connection.source && connection.target) {
addStoreEdge(connection.source, connection.target);
setEdges((eds) =>
addEdge(
{
...connection,
type: 'default',
animated: true,
},
eds
)
);
}
},
[addStoreEdge, setEdges]
);
// Handle node click
const onNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
selectNode(node.id);
},
[selectNode]
);
// Handle pane click (deselect)
const onPaneClick = useCallback(() => {
selectNode(null);
}, [selectNode]);
// Handle drag over for palette items
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Handle drop from palette
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow') as WorkflowNodeType;
if (!type) return;
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
addNode(type, position);
},
[screenToFlowPosition, addNode]
);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Delete selected node
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedNodeId) {
deleteNode(selectedNodeId);
}
// Save workflow
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
saveWorkflow();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedNodeId, deleteNode, saveWorkflow]);
if (!canvas) {
return (
<div className="flex items-center justify-center h-full bg-gray-50">
<div className="text-center">
<p className="text-gray-500 mb-4">No workflow loaded</p>
<button
onClick={() => useWorkflowBuilderStore.getState().createNewWorkflow('New Workflow')}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Create New Workflow
</button>
</div>
</div>
);
}
return (
<div className="flex h-full">
{/* Node Palette */}
<NodePalette
categories={paletteCategories}
onDragStart={() => {
setDragging(true);
}}
onDragEnd={() => {
setDragging(false);
}}
/>
{/* Canvas */}
<div className="flex-1 flex flex-col">
<WorkflowToolbar
workflowName={canvas.name}
isDirty={isDirty}
validation={validation}
onSave={saveWorkflow}
onValidate={validate}
/>
<div ref={reactFlowWrapper} className="flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
fitView
snapToGrid
snapGrid={[15, 15]}
defaultEdgeOptions={{
animated: true,
type: 'smoothstep',
}}
>
<Controls />
<MiniMap
nodeColor={(node) => {
switch (node.type) {
case 'input':
return '#10b981';
case 'llm':
return '#8b5cf6';
case 'skill':
return '#f59e0b';
case 'hand':
return '#ef4444';
case 'export':
return '#3b82f6';
default:
return '#6b7280';
}
}}
maskColor="rgba(0, 0, 0, 0.1)"
/>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
</ReactFlow>
</div>
</div>
{/* Property Panel */}
{selectedNodeId && (
<PropertyPanel
nodeId={selectedNodeId}
nodeData={nodes.find(n => n.id === selectedNodeId)?.data as WorkflowNodeData}
onUpdate={(data) => updateNode(selectedNodeId, data)}
onDelete={() => deleteNode(selectedNodeId)}
onClose={() => selectNode(null)}
/>
)}
</div>
);
}
// Export with provider
export function WorkflowBuilder() {
return (
<ReactFlowProvider>
<WorkflowBuilderInternal />
</ReactFlowProvider>
);
}
export default WorkflowBuilder;

View File

@@ -1,166 +0,0 @@
/**
* Workflow Toolbar Component
*
* Toolbar with actions for the workflow builder.
*/
import { useState } from 'react';
import type { ValidationResult } from '../../lib/workflow-builder/types';
import { canvasToYaml } from '../../lib/workflow-builder/yaml-converter';
import { useWorkflowBuilderStore } from '../../store/workflowBuilderStore';
interface WorkflowToolbarProps {
workflowName: string;
isDirty: boolean;
validation: ValidationResult | null;
onSave: () => void;
onValidate: () => ValidationResult;
}
export function WorkflowToolbar({
workflowName,
isDirty,
validation,
onSave,
onValidate,
}: WorkflowToolbarProps) {
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [yamlPreview, setYamlPreview] = useState('');
const canvas = useWorkflowBuilderStore(state => state.canvas);
const handlePreviewYaml = () => {
if (canvas) {
const yaml = canvasToYaml(canvas);
setYamlPreview(yaml);
setIsPreviewOpen(true);
}
};
const handleCopyYaml = async () => {
try {
await navigator.clipboard.writeText(yamlPreview);
alert('YAML copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleDownloadYaml = () => {
const blob = new Blob([yamlPreview], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${workflowName.replace(/\s+/g, '-').toLowerCase()}.yaml`;
a.click();
URL.revokeObjectURL(url);
};
return (
<>
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-200">
{/* Left: Workflow Name */}
<div className="flex items-center gap-3">
<h1 className="font-semibold text-gray-800">{workflowName}</h1>
{isDirty && (
<span className="text-sm text-amber-600 flex items-center gap-1">
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
Unsaved
</span>
)}
</div>
{/* Center: Validation Status */}
{validation && (
<div className="flex items-center gap-2">
{validation.valid ? (
<span className="text-sm text-green-600 flex items-center gap-1">
Valid
</span>
) : (
<span className="text-sm text-red-600 flex items-center gap-1">
{validation.errors.length} error(s)
</span>
)}
{validation.warnings.length > 0 && (
<span className="text-sm text-amber-600">
{validation.warnings.length} warning(s)
</span>
)}
</div>
)}
{/* Right: Actions */}
<div className="flex items-center gap-2">
<button
onClick={onValidate}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
>
Validate
</button>
<button
onClick={handlePreviewYaml}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
>
Preview YAML
</button>
<button
onClick={onSave}
disabled={!isDirty}
className={`
px-4 py-1.5 text-sm rounded-lg font-medium
${isDirty
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}
`}
>
Save
</button>
</div>
</div>
{/* YAML Preview Modal */}
{isPreviewOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-xl w-[800px] max-h-[80vh] overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h2 className="font-semibold text-gray-800">Pipeline YAML</h2>
<div className="flex items-center gap-2">
<button
onClick={handleCopyYaml}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
>
Copy
</button>
<button
onClick={handleDownloadYaml}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
>
Download
</button>
<button
onClick={() => setIsPreviewOpen(false)}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600"
>
</button>
</div>
</div>
{/* YAML Content */}
<div className="p-4 overflow-y-auto max-h-[60vh]">
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap">
{yamlPreview}
</pre>
</div>
</div>
</div>
)}
</>
);
}
export default WorkflowToolbar;

View File

@@ -1,21 +0,0 @@
/**
* Workflow Builder Components
*
* Export all workflow builder components.
*/
export { WorkflowBuilder, WorkflowBuilderInternal } from './WorkflowBuilder';
export { NodePalette } from './NodePalette';
export { PropertyPanel } from './PropertyPanel';
export { WorkflowToolbar } from './WorkflowToolbar';
// Node components
export { InputNode } from './nodes/InputNode';
export { LlmNode } from './nodes/LlmNode';
export { SkillNode } from './nodes/SkillNode';
export { HandNode } from './nodes/HandNode';
export { ConditionNode } from './nodes/ConditionNode';
export { ParallelNode } from './nodes/ParallelNode';
export { ExportNode } from './nodes/ExportNode';
export { HttpNode } from './nodes/HttpNode';
export { OrchestrationNode } from './nodes/OrchestrationNode';

View File

@@ -1,81 +0,0 @@
/**
* Condition Node Component
*
* Node for conditional branching.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { ConditionNodeData } from '../../../lib/workflow-builder/types';
type ConditionNodeType = Node<ConditionNodeData>;
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeType>) => {
const branchCount = data.branches.length + (data.hasDefault ? 1 : 0);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-orange-50 border-orange-300
${selected ? 'border-orange-500 shadow-lg shadow-orange-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-orange-400 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🔀</span>
<span className="font-medium text-orange-800">{data.label}</span>
</div>
{/* Condition Preview */}
<div className="text-sm text-orange-600 bg-orange-100 rounded px-2 py-1 font-mono mb-2">
{data.condition || 'No condition'}
</div>
{/* Branches */}
<div className="space-y-1">
{data.branches.map((branch: { label?: string; when: string }, index: number) => (
<div key={index} className="flex items-center justify-between">
<div className="relative">
{/* Branch Output Handle */}
<Handle
type="source"
position={Position.Right}
id={`branch-${index}`}
style={{ top: `${((index + 1) / (branchCount + 1)) * 100}%` }}
className="w-3 h-3 bg-orange-400 border-2 border-white"
/>
</div>
<span className="text-xs text-orange-500 truncate max-w-[120px]">
{branch.label || branch.when}
</span>
</div>
))}
{data.hasDefault && (
<div className="flex items-center justify-between">
<Handle
type="source"
position={Position.Right}
id="default"
style={{ top: '100%' }}
className="w-3 h-3 bg-gray-400 border-2 border-white"
/>
<span className="text-xs text-gray-500">Default</span>
</div>
)}
</div>
</div>
);
});
ConditionNode.displayName = 'ConditionNode';
export default ConditionNode;

View File

@@ -1,72 +0,0 @@
/**
* Export Node Component
*
* Node for exporting workflow results to various formats.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { ExportNodeData } from '../../../lib/workflow-builder/types';
export const ExportNode = memo(({ data, selected }: NodeProps<Node<ExportNodeData>>) => {
const formatLabels: Record<string, string> = {
pptx: 'PowerPoint',
html: 'HTML',
pdf: 'PDF',
markdown: 'Markdown',
json: 'JSON',
};
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-blue-50 border-blue-300
${selected ? 'border-blue-500 shadow-lg shadow-blue-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-blue-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-blue-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📤</span>
<span className="font-medium text-blue-800">{data.label}</span>
</div>
{/* Formats */}
<div className="flex flex-wrap gap-1">
{data.formats.map((format) => (
<span
key={format}
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded"
>
{formatLabels[format] || format}
</span>
))}
</div>
{/* Output Directory */}
{data.outputDir && (
<div className="text-xs text-blue-500 mt-2 truncate">
📁 {data.outputDir}
</div>
)}
</div>
);
});
ExportNode.displayName = 'ExportNode';
export default ExportNode;

View File

@@ -1,76 +0,0 @@
/**
* Hand Node Component
*
* Node for executing hand actions.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { HandNodeData } from '../../../lib/workflow-builder/types';
type HandNodeType = Node<HandNodeData>;
export const HandNode = memo(({ data, selected }: NodeProps<HandNodeType>) => {
const hasHand = Boolean(data.handId);
const hasAction = Boolean(data.action);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-rose-50 border-rose-300
${selected ? 'border-rose-500 shadow-lg shadow-rose-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-rose-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-rose-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<span className="font-medium text-rose-800">{data.label}</span>
</div>
{/* Hand Info */}
<div className="space-y-1">
<div className={`text-sm ${hasHand ? 'text-rose-600' : 'text-rose-400 italic'}`}>
{hasHand ? (
<span className="font-mono bg-rose-100 px-1.5 py-0.5 rounded">
{data.handName || data.handId}
</span>
) : (
'No hand selected'
)}
</div>
{hasAction && (
<div className="text-xs text-rose-500">
Action: <span className="font-mono">{data.action}</span>
</div>
)}
</div>
{/* Params Count */}
{Object.keys(data.params).length > 0 && (
<div className="text-xs text-rose-500 mt-1">
{Object.keys(data.params).length} param(s)
</div>
)}
</div>
);
});
HandNode.displayName = 'HandNode';
export default HandNode;

View File

@@ -1,81 +0,0 @@
/**
* HTTP Node Component
*
* Node for making HTTP requests.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { HttpNodeData } from '../../../lib/workflow-builder/types';
const methodColors: Record<string, string> = {
GET: 'bg-green-100 text-green-700',
POST: 'bg-blue-100 text-blue-700',
PUT: 'bg-yellow-100 text-yellow-700',
DELETE: 'bg-red-100 text-red-700',
PATCH: 'bg-purple-100 text-purple-700',
};
export const HttpNode = memo(({ data, selected }: NodeProps<Node<HttpNodeData>>) => {
const hasUrl = Boolean(data.url);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-slate-50 border-slate-300
${selected ? 'border-slate-500 shadow-lg shadow-slate-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-slate-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-slate-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🌐</span>
<span className="font-medium text-slate-800">{data.label}</span>
</div>
{/* Method Badge */}
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs font-bold px-2 py-0.5 rounded ${methodColors[data.method]}`}>
{data.method}
</span>
</div>
{/* URL */}
<div className={`text-sm font-mono bg-slate-100 rounded px-2 py-1 truncate ${hasUrl ? 'text-slate-600' : 'text-slate-400 italic'}`}>
{hasUrl ? data.url : 'No URL specified'}
</div>
{/* Headers Count */}
{Object.keys(data.headers).length > 0 && (
<div className="text-xs text-slate-500 mt-2">
{Object.keys(data.headers).length} header(s)
</div>
)}
{/* Body Indicator */}
{data.body && (
<div className="text-xs text-slate-500 mt-1">
Has body content
</div>
)}
</div>
);
});
HttpNode.displayName = 'HttpNode';
export default HttpNode;

View File

@@ -1,54 +0,0 @@
/**
* Input Node Component
*
* Node for defining workflow input variables.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { InputNodeData } from '../../../lib/workflow-builder/types';
export const InputNode = memo(({ data, selected }: NodeProps<Node<InputNodeData>>) => {
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-emerald-50 border-emerald-300
${selected ? 'border-emerald-500 shadow-lg shadow-emerald-200' : ''}
`}
>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-emerald-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📥</span>
<span className="font-medium text-emerald-800">{data.label}</span>
</div>
{/* Variable Name */}
<div className="text-sm text-emerald-600">
<span className="font-mono bg-emerald-100 px-1.5 py-0.5 rounded">
{data.variableName}
</span>
</div>
{/* Default Value Indicator */}
{data.defaultValue !== undefined && (
<div className="text-xs text-emerald-500 mt-1">
default: {typeof data.defaultValue === 'string'
? `"${data.defaultValue}"`
: JSON.stringify(data.defaultValue)}
</div>
)}
</div>
);
});
InputNode.displayName = 'InputNode';
export default InputNode;

View File

@@ -1,70 +0,0 @@
/**
* LLM Node Component
*
* Node for LLM generation actions.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { LlmNodeData } from '../../../lib/workflow-builder/types';
export const LlmNode = memo(({ data, selected }: NodeProps<Node<LlmNodeData>>) => {
const templatePreview = data.template.length > 50
? data.template.slice(0, 50) + '...'
: data.template || 'No template';
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-violet-50 border-violet-300
${selected ? 'border-violet-500 shadow-lg shadow-violet-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-violet-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-violet-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🤖</span>
<span className="font-medium text-violet-800">{data.label}</span>
{data.jsonMode && (
<span className="text-xs bg-violet-200 text-violet-700 px-1.5 py-0.5 rounded">
JSON
</span>
)}
</div>
{/* Template Preview */}
<div className="text-sm text-violet-600 bg-violet-100 rounded px-2 py-1 font-mono">
{data.isTemplateFile ? '📄 ' : ''}
{templatePreview}
</div>
{/* Model Info */}
{(data.model || data.temperature !== undefined) && (
<div className="flex gap-2 mt-2 text-xs text-violet-500">
{data.model && <span>Model: {data.model}</span>}
{data.temperature !== undefined && (
<span>Temp: {data.temperature}</span>
)}
</div>
)}
</div>
);
});
LlmNode.displayName = 'LlmNode';
export default LlmNode;

View File

@@ -1,81 +0,0 @@
/**
* Orchestration Node Component
*
* Node for executing skill orchestration graphs (DAGs).
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { OrchestrationNodeData } from '../../../lib/workflow-builder/types';
export const OrchestrationNode = memo(({ data, selected }: NodeProps<Node<OrchestrationNodeData>>) => {
const hasGraphId = Boolean(data.graphId);
const hasGraph = Boolean(data.graph);
const inputCount = Object.keys(data.inputMappings).length;
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-gradient-to-br from-indigo-50 to-purple-50
border-indigo-300
${selected ? 'border-indigo-500 shadow-lg shadow-indigo-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-indigo-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-indigo-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🔀</span>
<span className="font-medium text-indigo-800">{data.label}</span>
</div>
{/* Graph Reference */}
<div className={`text-sm mb-2 ${hasGraphId || hasGraph ? 'text-indigo-600' : 'text-indigo-400 italic'}`}>
{hasGraphId ? (
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
<span className="text-xs">📋</span>
<span className="font-mono text-xs">{data.graphId}</span>
</div>
) : hasGraph ? (
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
<span className="text-xs">📊</span>
<span className="text-xs">Inline graph</span>
</div>
) : (
'No graph configured'
)}
</div>
{/* Input Mappings */}
{inputCount > 0 && (
<div className="text-xs text-indigo-500 mt-2">
{inputCount} input mapping(s)
</div>
)}
{/* Description */}
{data.description && (
<div className="text-xs text-indigo-400 mt-2 line-clamp-2">
{data.description}
</div>
)}
</div>
);
});
OrchestrationNode.displayName = 'OrchestrationNode';
export default OrchestrationNode;

View File

@@ -1,55 +0,0 @@
/**
* Parallel Node Component
*
* Node for parallel execution of steps.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { ParallelNodeData } from '../../../lib/workflow-builder/types';
export const ParallelNode = memo(({ data, selected }: NodeProps<Node<ParallelNodeData>>) => {
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-cyan-50 border-cyan-300
${selected ? 'border-cyan-500 shadow-lg shadow-cyan-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-cyan-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-cyan-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<span className="font-medium text-cyan-800">{data.label}</span>
</div>
{/* Each Expression */}
<div className="text-sm text-cyan-600 bg-cyan-100 rounded px-2 py-1 font-mono">
each: {data.each || '${inputs.items}'}
</div>
{/* Max Workers */}
<div className="text-xs text-cyan-500 mt-2">
Max workers: {data.maxWorkers}
</div>
</div>
);
});
ParallelNode.displayName = 'ParallelNode';
export default ParallelNode;

View File

@@ -1,65 +0,0 @@
/**
* Skill Node Component
*
* Node for executing skills.
*/
import { memo } from 'react';
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
import type { SkillNodeData } from '../../../lib/workflow-builder/types';
export const SkillNode = memo(({ data, selected }: NodeProps<Node<SkillNodeData>>) => {
const hasSkill = Boolean(data.skillId);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-amber-50 border-amber-300
${selected ? 'border-amber-500 shadow-lg shadow-amber-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-amber-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-amber-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<span className="font-medium text-amber-800">{data.label}</span>
</div>
{/* Skill ID */}
<div className={`text-sm ${hasSkill ? 'text-amber-600' : 'text-amber-400 italic'}`}>
{hasSkill ? (
<span className="font-mono bg-amber-100 px-1.5 py-0.5 rounded">
{data.skillName || data.skillId}
</span>
) : (
'No skill selected'
)}
</div>
{/* Input Mappings Count */}
{Object.keys(data.inputMappings).length > 0 && (
<div className="text-xs text-amber-500 mt-1">
{Object.keys(data.inputMappings).length} input mapping(s)
</div>
)}
</div>
);
});
SkillNode.displayName = 'SkillNode';
export default SkillNode;

View File

@@ -1,479 +0,0 @@
/**
* WorkflowEditor - ZCLAW Workflow Editor Component
*
* Allows creating and editing multi-step workflows that chain
* multiple Hands together for complex task automation.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useHandStore, type Hand } from '../store/handStore';
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
import {
X,
Plus,
Trash2,
GripVertical,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Save,
Loader2,
AlertCircle,
GitBranch,
} from 'lucide-react';
import { safeJsonParse } from '../lib/json-utils';
// === Types ===
interface WorkflowStep {
id: string;
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}
interface WorkflowEditorProps {
workflow?: Workflow; // If provided, edit mode; otherwise create mode
isOpen: boolean;
onClose: () => void;
onSave: (data: {
name: string;
description?: string;
steps: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}) => Promise<void>;
isSaving: boolean;
}
// === Step Editor Component ===
interface StepEditorProps {
step: WorkflowStep;
hands: Hand[];
index: number;
onUpdate: (step: WorkflowStep) => void;
onRemove: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
}
function StepEditor({ step, hands, index, onUpdate, onRemove, onMoveUp, onMoveDown }: StepEditorProps) {
const [isExpanded, setIsExpanded] = useState(true);
const selectedHand = hands.find(h => h.name === step.handName);
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700">
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab" />
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-xs font-medium text-blue-600 dark:text-blue-400">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<select
value={step.handName}
onChange={(e) => onUpdate({ ...step, handName: e.target.value })}
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""> Hand...</option>
{hands.map(hand => (
<option key={hand.id} value={hand.id}>
{hand.name} - {hand.description}
</option>
))}
</select>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{onMoveUp && (
<button
onClick={onMoveUp}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
title="上移"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
{onMoveDown && (
<button
onClick={onMoveDown}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
title="下移"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
<button
onClick={onRemove}
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Step Name */}
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
()
</label>
<input
type="text"
value={step.name || ''}
onChange={(e) => onUpdate({ ...step, name: e.target.value || undefined })}
placeholder={`${step.handName || '步骤'} ${index + 1}`}
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Condition */}
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
()
</label>
<input
type="text"
value={step.condition || ''}
onChange={(e) => onUpdate({ ...step, condition: e.target.value || undefined })}
placeholder="例如: previous_result.success == true"
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
使
</p>
</div>
{/* Parameters */}
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
(JSON )
</label>
<textarea
value={step.params ? JSON.stringify(step.params, null, 2) : ''}
onChange={(e) => {
const text = e.target.value.trim();
if (text) {
const result = safeJsonParse<Record<string, unknown>>(text);
if (result.success) {
onUpdate({ ...step, params: result.data });
}
// If parse fails, keep current params
} else {
onUpdate({ ...step, params: undefined });
}
}}
placeholder='{"key": "value"}'
rows={3}
className="w-full px-2 py-1.5 text-sm font-mono border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Hand Info */}
{selectedHand && (
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md text-xs text-blue-700 dark:text-blue-400">
<div className="font-medium mb-1">{selectedHand.description}</div>
{selectedHand.toolCount && (
<div>: {selectedHand.toolCount}</div>
)}
</div>
)}
</div>
)}
</div>
);
}
// === Main WorkflowEditor Component ===
export function WorkflowEditor({ workflow, isOpen, onClose, onSave, isSaving }: WorkflowEditorProps) {
const hands = useHandStore((s) => s.hands);
const loadHands = useHandStore((s) => s.loadHands);
const getWorkflowDetail = useWorkflowStore((s) => s.getWorkflowDetail);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [steps, setSteps] = useState<WorkflowStep[]>([]);
const [error, setError] = useState<string | null>(null);
const isEditMode = !!workflow;
// Load hands on mount
useEffect(() => {
loadHands();
}, [loadHands]);
// Initialize form when workflow changes (edit mode)
useEffect(() => {
if (workflow) {
setName(workflow.name);
setDescription(workflow.description || '');
// Load full workflow details including steps
getWorkflowDetail(workflow.id)
.then((detail: { steps: Array<{ handName: string; name?: string; params?: Record<string, unknown>; condition?: string }> } | undefined) => {
if (detail && Array.isArray(detail.steps)) {
const editorSteps: WorkflowStep[] = detail.steps.map((step: { handName: string; name?: string; params?: Record<string, unknown>; condition?: string }, index: number) => ({
id: `step-${workflow.id}-${index}`,
handName: step.handName || '',
name: step.name,
params: step.params,
condition: step.condition,
}));
setSteps(editorSteps);
} else {
setSteps([]);
}
})
.catch(() => setSteps([]));
} else {
setName('');
setDescription('');
setSteps([]);
}
setError(null);
}, [workflow, getWorkflowDetail]);
// Add new step
const handleAddStep = useCallback(() => {
const newStep: WorkflowStep = {
id: `step-${Date.now()}`,
handName: '',
};
setSteps(prev => [...prev, newStep]);
}, []);
// Update step
const handleUpdateStep = useCallback((index: number, updatedStep: WorkflowStep) => {
setSteps(prev => prev.map((s, i) => i === index ? updatedStep : s));
}, []);
// Remove step
const handleRemoveStep = useCallback((index: number) => {
setSteps(prev => prev.filter((_, i) => i !== index));
}, []);
// Move step up
const handleMoveStepUp = useCallback((index: number) => {
if (index === 0) return;
setSteps(prev => {
const newSteps = [...prev];
[newSteps[index - 1], newSteps[index]] = [newSteps[index], newSteps[index - 1]];
return newSteps;
});
}, []);
// Move step down
const handleMoveStepDown = useCallback((index: number) => {
if (index === steps.length - 1) return;
setSteps(prev => {
const newSteps = [...prev];
[newSteps[index], newSteps[index + 1]] = [newSteps[index + 1], newSteps[index]];
return newSteps;
});
}, [steps.length]);
// Validate and save
const handleSave = async () => {
setError(null);
// Validation
if (!name.trim()) {
setError('请输入工作流名称');
return;
}
if (steps.length === 0) {
setError('请至少添加一个步骤');
return;
}
const invalidSteps = steps.filter(s => !s.handName);
if (invalidSteps.length > 0) {
setError('所有步骤都必须选择一个 Hand');
return;
}
try {
await onSave({
name: name.trim(),
description: description.trim() || undefined,
steps: steps.map(s => ({
handName: s.handName,
name: s.name,
params: s.params,
condition: s.condition,
})),
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<GitBranch className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditMode ? '编辑工作流' : '新建工作流'}
</h2>
{workflow && (
<p className="text-sm text-gray-500 dark:text-gray-400">{workflow.name}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Error */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Basic Info */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
*
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="我的工作流"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="描述这个工作流的用途..."
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
({steps.length})
</label>
<button
onClick={handleAddStep}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/50"
>
<Plus className="w-3 h-3" />
</button>
</div>
{steps.length === 0 ? (
<div className="p-8 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg">
<GitBranch className="w-8 h-8 mx-auto text-gray-300 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
</p>
<button
onClick={handleAddStep}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
>
<Plus className="w-4 h-4" />
</button>
</div>
) : (
<div className="space-y-2">
{steps.map((step, index) => (
<StepEditor
key={step.id}
step={step}
hands={hands}
index={index}
onUpdate={(s) => handleUpdateStep(index, s)}
onRemove={() => handleRemoveStep(index)}
onMoveUp={index > 0 ? () => handleMoveStepUp(index) : undefined}
onMoveDown={index < steps.length - 1 ? () => handleMoveStepDown(index) : undefined}
/>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Save className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
export default WorkflowEditor;

View File

@@ -1,281 +0,0 @@
/**
* WorkflowHistory - ZCLAW Workflow Execution History Component
*
* Displays the execution history of a specific workflow,
* showing run details, status, and results.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useWorkflowStore, type Workflow, type WorkflowRun } from '../store/workflowStore';
import {
ArrowLeft,
Clock,
CheckCircle,
XCircle,
AlertCircle,
Loader2,
ChevronRight,
RefreshCw,
History,
} from 'lucide-react';
interface WorkflowHistoryProps {
workflow: Workflow;
isOpen?: boolean;
onClose?: () => void;
onBack?: () => void;
}
// Status configuration
const STATUS_CONFIG: Record<string, { label: string; className: string; icon: React.ComponentType<{ className?: string }> }> = {
pending: { label: '等待中', className: 'text-gray-500 bg-gray-100', icon: Clock },
running: { label: '运行中', className: 'text-blue-600 bg-blue-100', icon: Loader2 },
completed: { label: '已完成', className: 'text-green-600 bg-green-100', icon: CheckCircle },
success: { label: '成功', className: 'text-green-600 bg-green-100', icon: CheckCircle },
failed: { label: '失败', className: 'text-red-600 bg-red-100', icon: XCircle },
error: { label: '错误', className: 'text-red-600 bg-red-100', icon: XCircle },
cancelled: { label: '已取消', className: 'text-gray-500 bg-gray-100', icon: XCircle },
paused: { label: '已暂停', className: 'text-yellow-600 bg-yellow-100', icon: AlertCircle },
};
// Run Card Component
interface RunCardProps {
run: WorkflowRun;
index: number;
}
function RunCard({ run, index }: RunCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const config = STATUS_CONFIG[run.status] || STATUS_CONFIG.pending;
const StatusIcon = config.icon;
// Format result for display
const resultText = run.result
? (typeof run.result === 'string' ? run.result : JSON.stringify(run.result, null, 2))
: undefined;
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-100 dark:border-gray-700">
<div
className="flex items-center justify-between p-3 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-3 min-w-0">
<span className="flex-shrink-0 w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center text-xs font-medium text-gray-600 dark:text-gray-400">
{index + 1}
</span>
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${run.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
#{run.runId.slice(0, 8)}
</span>
{run.step && (
<span className="text-xs text-gray-500 dark:text-gray-400 truncate">
: {run.step}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs px-2 py-0.5 rounded ${config.className}`}>
{config.label}
</span>
<ChevronRight className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="px-3 pb-3 pt-0 border-t border-gray-200 dark:border-gray-700 space-y-2">
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<div className="flex justify-between">
<span> ID</span>
<span className="font-mono">{run.runId}</span>
</div>
</div>
{resultText && (
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 text-xs whitespace-pre-wrap max-h-60 overflow-auto">
<div className="font-medium mb-1">:</div>
{resultText}
</div>
)}
{run.status === 'failed' && !resultText && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 text-xs">
</div>
)}
</div>
)}
</div>
);
}
export function WorkflowHistory({ workflow, onBack }: WorkflowHistoryProps) {
const loadWorkflowRuns = useWorkflowStore((s) => s.loadWorkflowRuns);
const cancelWorkflow = useWorkflowStore((s) => s.cancelWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [cancellingRunId, setCancellingRunId] = useState<string | null>(null);
// Load workflow runs
const loadRuns = useCallback(async () => {
try {
const result = await loadWorkflowRuns(workflow.id, { limit: 50 });
setRuns(result || []);
} catch {
setRuns([]);
}
}, [workflow.id, loadWorkflowRuns]);
useEffect(() => {
loadRuns();
}, [loadRuns]);
// Refresh runs
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
await loadRuns();
} finally {
setIsRefreshing(false);
}
}, [loadRuns]);
// Cancel running workflow
const handleCancel = useCallback(async (runId: string) => {
if (!confirm('确定要取消这个正在运行的工作流吗?')) return;
setCancellingRunId(runId);
try {
await cancelWorkflow(workflow.id, runId);
await loadRuns();
} catch (error) {
console.error('Failed to cancel workflow:', error);
alert(`取消失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setCancellingRunId(null);
}
}, [workflow.id, cancelWorkflow, loadRuns]);
// Categorize runs
const runningRuns = runs.filter(r => r.status === 'running');
const completedRuns = runs.filter(r => ['completed', 'success', 'failed', 'error', 'cancelled'].includes(r.status));
const pendingRuns = runs.filter(r => ['pending', 'paused'].includes(r.status));
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<History className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{workflow.name}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
({runs.length} )
</p>
</div>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Loading State */}
{isLoading && runs.length === 0 && (
<div className="text-center py-8">
<Loader2 className="w-8 h-8 mx-auto text-gray-400 animate-spin mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">...</p>
</div>
)}
{/* Running Runs */}
{runningRuns.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<h3 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
({runningRuns.length})
</h3>
<div className="space-y-2">
{runningRuns.map((run, index) => (
<div key={run.runId} className="flex items-center justify-between">
<RunCard run={run} index={index} />
<button
onClick={() => handleCancel(run.runId)}
disabled={cancellingRunId === run.runId}
className="ml-2 px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
>
{cancellingRunId === run.runId ? '取消中...' : '取消'}
</button>
</div>
))}
</div>
</div>
)}
{/* Pending Runs */}
{pendingRuns.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
({pendingRuns.length})
</h3>
<div className="space-y-2">
{pendingRuns.map((run, index) => (
<RunCard key={run.runId} run={run} index={index} />
))}
</div>
</div>
)}
{/* Completed Runs */}
{completedRuns.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
({completedRuns.length})
</h3>
<div className="space-y-2">
{completedRuns.map((run, index) => (
<RunCard key={run.runId} run={run} index={index} />
))}
</div>
</div>
)}
{/* Empty State */}
{!isLoading && runs.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<History className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1"></p>
<p className="text-xs text-gray-400 dark:text-gray-500">
</p>
</div>
)}
</div>
</div>
);
}
export default WorkflowHistory;

View File

@@ -1,510 +0,0 @@
/**
* WorkflowList - ZCLAW Workflow Management UI
*
* Displays available ZCLAW Workflows and allows executing them.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
import { WorkflowEditor } from './WorkflowEditor';
import { WorkflowHistory } from './WorkflowHistory';
import { WorkflowBuilder } from './WorkflowBuilder';
import {
Play,
Edit,
Trash2,
History,
Plus,
List,
GitBranch,
RefreshCw,
Loader2,
X,
} from 'lucide-react';
import { safeJsonParse } from '../lib/json-utils';
// === View Toggle Types ===
type ViewMode = 'list' | 'visual';
// === Workflow Execute Modal ===
interface ExecuteModalProps {
workflow: Workflow;
isOpen: boolean;
onClose: () => void;
onExecute: (id: string, input?: Record<string, unknown>) => Promise<void>;
isExecuting: boolean;
}
function ExecuteModal({ workflow, isOpen, onClose, onExecute, isExecuting }: ExecuteModalProps) {
const [input, setInput] = useState('');
const handleExecute = async () => {
let parsedInput: Record<string, unknown> | undefined;
if (input.trim()) {
const result = safeJsonParse<Record<string, unknown>>(input);
if (!result.success) {
alert('Input format error, please use valid JSON format.');
return;
}
parsedInput = result.data;
}
await onExecute(workflow.id, parsedInput);
setInput('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
{/* 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-3">
<div className="w-8 h-8 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<Play className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{workflow.name}</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="p-4">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
(JSON ):
</div>
<textarea
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder='{"key": "value"}'
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={handleExecute}
disabled={isExecuting}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isExecuting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Workflow Table Row ===
interface WorkflowRowProps {
workflow: Workflow;
onExecute: (workflow: Workflow) => void;
onEdit: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
onHistory: (workflow: Workflow) => void;
isExecuting: boolean;
isDeleting: boolean;
}
function WorkflowRow({ workflow, onExecute, onEdit, onDelete, onHistory, isExecuting, isDeleting }: WorkflowRowProps) {
// Format created date if available
const createdDate = workflow.createdAt
? new Date(workflow.createdAt).toLocaleDateString('zh-CN')
: new Date().toLocaleDateString('zh-CN');
return (
<tr className="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<GitBranch className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-white truncate">
{workflow.name}
</div>
{workflow.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{workflow.description}
</div>
)}
</div>
</div>
</td>
{/* Steps */}
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded-full text-sm font-medium text-gray-700 dark:text-gray-300">
{workflow.steps}
</span>
</td>
{/* Created */}
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{createdDate}
</td>
{/* Actions */}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onExecute(workflow)}
disabled={isExecuting}
className="p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title="Run"
>
{isExecuting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
<button
onClick={() => onEdit(workflow)}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
title="Edit"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => onHistory(workflow)}
className="p-1.5 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
title="History"
>
<History className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(workflow)}
disabled={isDeleting}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title="删除"
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</td>
</tr>
);
}
// === Main WorkflowList Component ===
export function WorkflowList() {
const workflows = useWorkflowStore((s) => s.workflows);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
const deleteWorkflow = useWorkflowStore((s) => s.deleteWorkflow);
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [deletingWorkflowId, setDeletingWorkflowId] = useState<string | null>(null);
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [showExecuteModal, setShowExecuteModal] = useState(false);
const [showEditor, setShowEditor] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadWorkflows();
}, [loadWorkflows]);
const handleExecute = useCallback(async (id: string, input?: Record<string, unknown>) => {
setExecutingWorkflowId(id);
try {
await triggerWorkflow(id, input);
} finally {
setExecutingWorkflowId(null);
}
}, [triggerWorkflow]);
const handleExecuteClick = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);
setShowExecuteModal(true);
}, []);
const handleEdit = useCallback((workflow: Workflow) => {
setEditingWorkflow(workflow);
setShowEditor(true);
}, []);
const handleDelete = useCallback(async (workflow: Workflow) => {
if (confirm(`确定要删除 "${workflow.name}" 吗?此操作不可撤销。`)) {
setDeletingWorkflowId(workflow.id);
try {
await deleteWorkflow(workflow.id);
// The store will update the workflows list automatically
} catch (error) {
console.error('Failed to delete workflow:', error);
alert(`删除工作流失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setDeletingWorkflowId(null);
}
}
}, [deleteWorkflow]);
const handleHistory = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);
setShowHistory(true);
}, []);
const handleNewWorkflow = useCallback(() => {
setEditingWorkflow(null);
setShowEditor(true);
}, []);
const handleSaveWorkflow = useCallback(async (data: {
name: string;
description?: string;
steps: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}) => {
setIsSaving(true);
try {
if (editingWorkflow) {
await updateWorkflow(editingWorkflow.id, data);
} else {
await createWorkflow(data);
}
await loadWorkflows();
} finally {
setIsSaving(false);
}
}, [editingWorkflow, createWorkflow, updateWorkflow, loadWorkflows]);
const handleCloseEditor = useCallback(() => {
setShowEditor(false);
setEditingWorkflow(null);
}, []);
const handleCloseModal = useCallback(() => {
setShowExecuteModal(false);
setSelectedWorkflow(null);
}, []);
// Loading state
if (isLoading && workflows.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">...</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
</p>
</div>
<button
onClick={() => loadWorkflows()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Toolbar */}
<div className="flex items-center justify-between">
{/* View Toggle */}
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<List className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setViewMode('visual')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'visual'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<GitBranch className="w-3.5 h-3.5" />
</button>
</div>
{/* New Workflow Button */}
<button
onClick={handleNewWorkflow}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Content */}
{viewMode === 'list' ? (
workflows.length === 0 ? (
// Empty State
<div className="p-8 text-center">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<GitBranch className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4">
</p>
<button
onClick={handleNewWorkflow}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
) : (
// Table View
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{workflows.map((workflow) => (
<WorkflowRow
key={workflow.id}
workflow={workflow}
onExecute={handleExecuteClick}
onEdit={handleEdit}
onDelete={handleDelete}
onHistory={handleHistory}
isExecuting={executingWorkflowId === workflow.id}
isDeleting={deletingWorkflowId === workflow.id}
/>
))}
</tbody>
</table>
</div>
)
) : (
// Visual Builder View
<WorkflowBuilder />
)}
{/* Execute Modal */}
{selectedWorkflow && (
<ExecuteModal
workflow={selectedWorkflow}
isOpen={showExecuteModal}
onClose={handleCloseModal}
onExecute={handleExecute}
isExecuting={executingWorkflowId === selectedWorkflow.id}
/>
)}
{/* Workflow Editor */}
<WorkflowEditor
workflow={editingWorkflow || undefined}
isOpen={showEditor}
onClose={handleCloseEditor}
onSave={handleSaveWorkflow}
isSaving={isSaving}
/>
{/* Workflow History */}
{selectedWorkflow && (
<WorkflowHistory
workflow={selectedWorkflow}
isOpen={showHistory}
onClose={() => {
setShowHistory(false);
setSelectedWorkflow(null);
}}
/>
)}
</div>
);
}
export default WorkflowList;

View File

@@ -1,400 +0,0 @@
/**
* IntentInput - 智能输入组件
*
* 提供自然语言触发 Pipeline 的入口:
* - 支持关键词/模式快速匹配
* - 显示匹配建议
* - 参数收集(对话式/表单式)
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import {
Send,
Sparkles,
Loader2,
ChevronRight,
X,
MessageSquare,
FileText,
Zap,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
// === Types ===
/** 路由结果 */
interface RouteResult {
type: 'matched' | 'ambiguous' | 'no_match' | 'need_more_info';
pipeline_id?: string;
display_name?: string;
mode?: 'conversation' | 'form' | 'hybrid' | 'auto';
params?: Record<string, unknown>;
confidence?: number;
missing_params?: MissingParam[];
candidates?: PipelineCandidate[];
suggestions?: PipelineCandidate[];
prompt?: string;
}
/** 缺失参数 */
interface MissingParam {
name: string;
label?: string;
param_type: string;
required: boolean;
default?: unknown;
}
/** Pipeline 候选 */
interface PipelineCandidate {
id: string;
display_name?: string;
description?: string;
icon?: string;
category?: string;
match_reason?: string;
}
/** 组件 Props */
export interface IntentInputProps {
/** 匹配成功回调 */
onMatch?: (pipelineId: string, params: Record<string, unknown>, mode: string) => void;
/** 取消回调 */
onCancel?: () => void;
/** 占位符文本 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
}
// === IntentInput Component ===
export function IntentInput({
onMatch,
onCancel,
placeholder = '输入你想做的事情,如"帮我做一个Python入门课程"...',
disabled = false,
className = '',
}: IntentInputProps) {
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<RouteResult | null>(null);
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
const inputRef = useRef<HTMLTextAreaElement>(null);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Handle route request
const handleRoute = useCallback(async () => {
if (!input.trim() || loading) return;
setLoading(true);
setResult(null);
try {
const routeResult = await invoke<RouteResult>('route_intent', {
userInput: input.trim(),
});
setResult(routeResult);
// Initialize param values from extracted params
if (routeResult.params) {
setParamValues(routeResult.params);
}
// If high confidence and no missing params, auto-execute
if (
routeResult.type === 'matched' &&
routeResult.confidence &&
routeResult.confidence >= 0.9 &&
(!routeResult.missing_params || routeResult.missing_params.length === 0)
) {
handleExecute(routeResult.pipeline_id!, routeResult.params || {}, routeResult.mode!);
}
} catch (error) {
console.error('Route error:', error);
setResult({
type: 'no_match',
suggestions: [],
});
} finally {
setLoading(false);
}
}, [input, loading]);
// Handle execute
const handleExecute = useCallback(
(pipelineId: string, params: Record<string, unknown>, mode: string) => {
onMatch?.(pipelineId, params, mode);
// Reset state
setInput('');
setResult(null);
setParamValues({});
},
[onMatch]
);
// Handle param change
const handleParamChange = useCallback((name: string, value: unknown) => {
setParamValues((prev) => ({ ...prev, [name]: value }));
}, []);
// Handle key press
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (result?.type === 'matched') {
handleExecute(result.pipeline_id!, paramValues, result.mode!);
} else {
handleRoute();
}
} else if (e.key === 'Escape') {
onCancel?.();
}
},
[result, paramValues, handleRoute, handleExecute, onCancel]
);
// Render input area
const renderInput = () => (
<div className="relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={placeholder}
disabled={disabled || loading}
rows={2}
className={`w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-xl resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white disabled:opacity-50 ${className}`}
/>
<button
onClick={result?.type === 'matched' ? undefined : handleRoute}
disabled={!input.trim() || disabled || loading}
className="absolute right-3 bottom-3 p-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
);
// Render matched result
const renderMatched = () => {
if (!result || result.type !== 'matched') return null;
const { pipeline_id, display_name, mode, missing_params, confidence } = result;
return (
<div className="mt-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-blue-600" />
<span className="font-medium text-blue-700 dark:text-blue-300">
{display_name || pipeline_id}
</span>
{confidence && (
<span className="text-xs text-blue-500 dark:text-blue-400">
({Math.round(confidence * 100)}% )
</span>
)}
</div>
<button
onClick={() => setResult(null)}
className="p-1 hover:bg-blue-100 dark:hover:bg-blue-800 rounded"
>
<X className="w-4 h-4 text-blue-500" />
</button>
</div>
{/* Mode indicator */}
<div className="flex items-center gap-2 mb-3 text-sm">
<span className="text-gray-500 dark:text-gray-400">:</span>
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">
{mode === 'conversation' && <MessageSquare className="w-3 h-3" />}
{mode === 'form' && <FileText className="w-3 h-3" />}
{mode === 'hybrid' && <Zap className="w-3 h-3" />}
{mode === 'conversation' && '对话式'}
{mode === 'form' && '表单式'}
{mode === 'hybrid' && '混合式'}
{mode === 'auto' && '自动'}
</span>
</div>
{/* Missing params form */}
{missing_params && missing_params.length > 0 && (
<div className="space-y-3 mb-4">
{missing_params.map((param) => (
<div key={param.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{param.label || param.name}
{param.required && <span className="text-red-500 ml-1">*</span>}
</label>
{renderParamInput(param)}
</div>
))}
</div>
)}
{/* Execute button */}
<button
onClick={() => handleExecute(pipeline_id!, paramValues, mode!)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
<Zap className="w-4 h-4" />
<ChevronRight className="w-4 h-4" />
</button>
</div>
);
};
// Render param input
const renderParamInput = (param: MissingParam) => {
const value = paramValues[param.name] ?? param.default ?? '';
switch (param.param_type) {
case 'text':
return (
<textarea
value={(value as string) || ''}
onChange={(e) => handleParamChange(param.name, e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
case 'number':
return (
<input
type="number"
value={(value as number) ?? ''}
onChange={(e) => handleParamChange(param.name, e.target.valueAsNumber)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
case 'boolean':
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={(value as boolean) || false}
onChange={(e) => handleParamChange(param.name, e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-600 dark:text-gray-300"></span>
</label>
);
default:
return (
<input
type="text"
value={(value as string) || ''}
onChange={(e) => handleParamChange(param.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
}
};
// Render suggestions
const renderSuggestions = () => {
if (!result || result.type !== 'no_match') return null;
const { suggestions } = result;
return (
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Pipeline:
</p>
{suggestions && suggestions.length > 0 ? (
<div className="space-y-2">
{suggestions.map((candidate) => (
<button
key={candidate.id}
onClick={() => {
setInput('');
handleExecute(candidate.id, {}, 'form');
}}
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 transition-colors text-left"
>
<div>
<span className="font-medium text-gray-900 dark:text-white">
{candidate.display_name || candidate.id}
</span>
{candidate.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{candidate.description}
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
)}
</div>
);
};
// Render ambiguous results
const renderAmbiguous = () => {
if (!result || result.type !== 'ambiguous') return null;
const { candidates } = result;
return (
<div className="mt-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800">
<p className="text-sm text-amber-700 dark:text-amber-300 mb-3">
Pipeline:
</p>
<div className="space-y-2">
{candidates?.map((candidate) => (
<button
key={candidate.id}
onClick={() => handleExecute(candidate.id, paramValues, 'form')}
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-300 dark:hover:border-amber-600 transition-colors text-left"
>
<div>
<span className="font-medium text-gray-900 dark:text-white">
{candidate.display_name || candidate.id}
</span>
{candidate.match_reason && (
<p className="text-sm text-amber-600 dark:text-amber-400 mt-0.5">
{candidate.match_reason}
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-amber-500" />
</button>
))}
</div>
</div>
);
};
return (
<div className="intent-input">
{renderInput()}
{renderMatched()}
{renderSuggestions()}
{renderAmbiguous()}
</div>
);
}
export default IntentInput;

View File

@@ -1,345 +0,0 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
AlertTriangle,
Wifi,
Shield,
Clock,
Settings,
AlertCircle,
ChevronDown,
ChevronUp,
Copy,
CheckCircle,
ExternalLink,
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
import {
AppError,
ErrorCategory,
classifyError,
formatErrorForClipboard,
getErrorIcon as getIconByCategory,
getErrorColor as getColorByCategory,
} from '../../lib/error-types';
import { reportError } from '../../lib/error-handling';
// === Props ===
export interface ErrorAlertProps {
error: AppError | string | Error | null;
onDismiss?: () => void;
onRetry?: () => void;
showTechnicalDetails?: boolean;
className?: string;
compact?: boolean;
}
interface ErrorAlertState {
showDetails: boolean;
copied: boolean;
}
// === Category Configuration ===
const CATEGORY_CONFIG: Record<ErrorCategory, {
icon: typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle;
color: string;
bgColor: string;
label: string;
}> = {
network: {
icon: Wifi,
color: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
label: 'Network',
},
auth: {
icon: Shield,
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'Authentication',
},
permission: {
icon: Shield,
color: 'text-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
label: 'Permission',
},
validation: {
icon: AlertCircle,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
label: 'Validation',
},
timeout: {
icon: Clock,
color: 'text-amber-500',
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
label: 'Timeout',
},
server: {
icon: AlertTriangle,
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'Server',
},
client: {
icon: AlertCircle,
color: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
label: 'Client',
},
config: {
icon: Settings,
color: 'text-gray-500',
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
label: 'Configuration',
},
system: {
icon: AlertTriangle,
color: 'text-red-600',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'System',
},
};
/**
* Get icon component for error category
*/
export function getIconByCategory(category: ErrorCategory): typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
return CATEGORY_CONFIG[category]?.icon ?? AlertCircle;
}
/**
* Get color class for error category
*/
export function getColorByCategory(category: ErrorCategory): string {
return CATEGORY_CONFIG[category]?.color ?? 'text-gray-500';
}
/**
* ErrorAlert Component
*
* Displays detailed error information with recovery suggestions,
* technical details, and action buttons.
*/
export function ErrorAlert({
error: errorProp,
onDismiss,
onRetry,
showTechnicalDetails = true,
className,
compact = false,
}: ErrorAlertProps) {
const [state, setState] = useState<ErrorAlertState>({
showDetails: false,
copied: false,
});
// Normalize error input
const appError = typeof errorProp === 'string'
? classifyError(new Error(errorProp))
: errorProp instanceof Error
? classifyError(errorProp)
: errorProp;
const {
category,
title,
message,
technicalDetails,
recoverable,
recoverySteps,
timestamp,
} = appError;
const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.system!;
const IconComponent = config.icon;
const handleCopyDetails = useCallback(async () => {
const text = formatErrorForClipboard(appError);
try {
await navigator.clipboard.writeText(text);
setState({ copied: true });
setTimeout(() => setState({ copied: false }), 2000);
} catch (err) {
console.error('Failed to copy error details:', err);
}
}, [appError]);
const handleReport = useCallback(() => {
reportError(appError.originalError || appError, {
errorId: appError.id,
category: appError.category,
title: appError.title,
message: appError.message,
timestamp: appError.timestamp.toISOString(),
});
}, [appError]);
const toggleDetails = useCallback(() => {
setState((prev) => ({ showDetails: !prev.showDetails }));
}, []);
const handleRetry = useCallback(() => {
onRetry?.();
}, [onRetry]);
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
'rounded-lg border overflow-hidden',
config.bgColor,
'border-gray-200 dark:border-gray-700',
className
)}
>
{/* Header */}
<div className="flex items-start gap-3 p-3 bg-white/50 dark:bg-gray-800/50">
<div className={cn('p-2 rounded-lg', config.bgColor)}>
<IconComponent className={cn('w-5 h-5', config.color)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn('text-xs font-medium', config.color)}>
{config.label}
</span>
<span className="text-xs text-gray-400">
{timestamp.toLocaleTimeString()}
</span>
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mt-1">
{title}
</h4>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
aria-label="Dismiss"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Body */}
<div className="px-3 pb-2">
<p className={cn(
'text-gray-700 dark:text-gray-300',
compact ? 'text-sm line-clamp-2' : 'text-sm'
)}>
{message}
</p>
{/* Recovery Steps */}
{recoverySteps.length > 0 && !compact && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Recovery Suggestions
</p>
<ul className="space-y-1">
{recoverySteps.slice(0, 3).map((step, index) => (
<li key={index} className="text-xs text-gray-600 dark:text-gray-400 flex items-start gap-2">
<span className="text-gray-400">-</span>
{step.description}
{step.action && step.label && (
<button
onClick={step.action}
className="text-blue-500 hover:text-blue-600 ml-1"
>
{step.label}
</button>
)}
</li>
))}
</ul>
</div>
)}
{/* Technical Details Toggle */}
{showTechnicalDetails && technicalDetails && !compact && (
<div className="mt-2">
<button
onClick={toggleDetails}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
{state.showDetails ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
Technical Details
</button>
<AnimatePresence>
{state.showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<pre className="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap break-all">
{technicalDetails}
</pre>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between gap-2 p-3 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white/30 dark:bg-gray-800/30">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCopyDetails}
className="text-xs"
>
{state.copied ? (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReport}
className="text-xs"
>
<ExternalLink className="w-3 h-3 mr-1" />
Report
</Button>
</div>
{recoverable && onRetry && (
<Button
variant="primary"
size="sm"
onClick={handleRetry}
className="text-xs"
>
Try Again
</Button>
)}
</div>
</motion.div>
);
}