Files
zclaw_openfang/desktop/src/components/WorkflowBuilder/WorkflowBuilder.tsx
iven bf6d81f9c6
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor: 清理未使用代码并添加未来功能标记
style: 统一代码格式和注释风格

docs: 更新多个功能文档的完整度和状态

feat(runtime): 添加路径验证工具支持

fix(pipeline): 改进条件判断和变量解析逻辑

test(types): 为ID类型添加全面测试用例

chore: 更新依赖项和Cargo.lock文件

perf(mcp): 优化MCP协议传输和错误处理
2026-03-25 21:55:12 +08:00

325 lines
8.6 KiB
TypeScript

/**
* 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;