Major restructuring: - Split monolithic gatewayStore into 5 focused stores: - connectionStore: WebSocket connection and gateway lifecycle - configStore: quickConfig, workspaceInfo, MCP services - agentStore: clones, usage stats, agent management - handStore: hands, approvals, triggers, hand runs - workflowStore: workflows, workflow runs, execution - Update all components to use new stores with selector pattern - Remove
520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
/**
|
||
* WorkflowList - OpenFang Workflow Management UI
|
||
*
|
||
* Displays available OpenFang Workflows and allows executing them.
|
||
*
|
||
* Design based on OpenFang 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 {
|
||
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 (placeholder)
|
||
<div className="p-8 text-center bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 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-2">
|
||
可视化工作流编辑器
|
||
</p>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||
拖拽式工作流编辑器即将推出!
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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;
|