diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx index e5738ee..37e506b 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx +++ b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx @@ -5,17 +5,21 @@ import "@xyflow/react/dist/style.css" import { addEdge, Background, + BaseEdge, ConnectionMode, + EdgeLabelRenderer, Controls, + getBezierPath, Handle, MarkerType, - MiniMap, Position, ReactFlow, useEdgesState, useNodesState, type Connection, + type ConnectionLineComponentProps, type Edge, + type EdgeProps, type Node, type NodeProps, type ReactFlowInstance, @@ -64,6 +68,8 @@ type WorkflowNodeData = Record & { name?: string config?: Record inputs?: Record + nodeSpecs?: AIWorkflowNodeSpec[] + onAddAfter?: (sourceNodeId: string, spec: AIWorkflowNodeSpec) => void label?: string title?: string description?: string @@ -89,6 +95,10 @@ const nodeTypes = { workflowNode: WorkflowCanvasNode, } +const edgeTypes = { + workflowEdge: WorkflowCanvasEdge, +} + const fitViewOptions = { padding: 0.16, minZoom: 0.72, @@ -96,7 +106,7 @@ const fitViewOptions = { } const defaultEdgeOptions = { - type: "smoothstep", + type: "workflowEdge", markerEnd: { type: MarkerType.ArrowClosed, }, @@ -123,6 +133,7 @@ function toFlowNodes(definition: AIWorkflowDefinition): WorkflowFlowNode[] { function toFlowEdges(definition: AIWorkflowDefinition): WorkflowFlowEdge[] { return (definition.edges ?? []).map((edge) => ({ id: edge.id, + type: "workflowEdge", source: edge.source, target: edge.target, label: edge.condition ? "条件" : undefined, @@ -196,10 +207,6 @@ export function WorkflowEditor({ () => validateWorkflowDraft(draft, nodeSpecs), [draft, nodeSpecs] ) - const renderedNodes = useMemo( - () => enrichNodesForRender(nodes, nodeSpecs), - [nodes, nodeSpecs] - ) const propertyPanelNodeSpec = useMemo( () => getNodeSpec(nodeSpecs, propertyPanelNode?.data.nodeType ?? ""), [nodeSpecs, propertyPanelNode] @@ -269,6 +276,7 @@ export function WorkflowEditor({ const edge = { ...connection, id: uniqueEdgeId(edges, connection.source, connection.target), + type: "workflowEdge", } as WorkflowFlowEdge setEdges((current) => addEdge(edge, current)) setNodes((currentNodes) => { @@ -316,6 +324,48 @@ export function WorkflowEditor({ }) } + const addNodeAfter = useCallback( + (sourceNodeId: string, spec: AIWorkflowNodeSpec) => { + setNodes((current) => { + const sourceNode = current.find((node) => node.id === sourceNodeId) + const nextPosition = sourceNode + ? { x: sourceNode.position.x + 280, y: sourceNode.position.y } + : { x: 160 + current.length * 32, y: 120 + current.length * 24 } + const nextNode = createWorkflowNodeFromSpec( + spec, + current, + nextPosition + ) as WorkflowFlowNode + + setEdges((currentEdges) => [ + ...currentEdges, + { + id: uniqueEdgeId(currentEdges, sourceNodeId, nextNode.id), + source: sourceNodeId, + target: nextNode.id, + type: "workflowEdge", + }, + ]) + + return [...current, nextNode] + }) + }, + [setEdges, setNodes] + ) + + const renderedNodes = useMemo( + () => + enrichNodesForRender(nodes, nodeSpecs).map((node) => ({ + ...node, + data: { + ...node.data, + nodeSpecs, + onAddAfter: addNodeAfter, + }, + })), + [addNodeAfter, nodes, nodeSpecs] + ) + const dropNodeOnCanvas = useCallback( (spec: AIWorkflowNodeSpec, x: number, y: number) => { if (!flowInstance || !canvasRef.current) { @@ -549,7 +599,9 @@ export function WorkflowEditor({ nodes={renderedNodes} edges={edges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} defaultEdgeOptions={defaultEdgeOptions} + connectionLineComponent={WorkflowConnectionLine} connectionMode={ConnectionMode.Loose} connectionRadius={34} connectOnClick @@ -576,9 +628,16 @@ export function WorkflowEditor({ minZoom={0.45} maxZoom={1.35} > - - - + + {propertyPanelNode || propertyPanelEdge ? ( @@ -852,34 +911,193 @@ function normalizeConditionRight(value: string) { return trimmed } -function WorkflowCanvasNode({ data, selected }: NodeProps) { +function WorkflowConnectionLine({ fromX, fromY, toX, toY }: ConnectionLineComponentProps) { + const [edgePath] = getBezierPath({ + sourceX: fromX, + sourceY: fromY, + sourcePosition: Position.Right, + targetX: toX, + targetY: toY, + targetPosition: Position.Left, + curvature: 0.18, + }) + + return ( + + + + + ) +} + +function WorkflowCanvasEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + selected, + data, + markerEnd, +}: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature: 0.18, + }) + const condition = (data as WorkflowEditorEdge["data"] | undefined)?.condition + + return ( + <> + + {condition ? ( + +
+ 条件 +
+
+ ) : null} + + ) +} + +function WorkflowNodeHandle({ + type, + position, + className, +}: { + type: "source" | "target" + position: Position + className?: string +}) { + return ( + + + + ) +} + +function WorkflowAddAfterButton({ + nodeId, + visible, + className, + nodeSpecs, + onAddAfter, +}: { + nodeId: string + visible: boolean + className?: string + nodeSpecs?: AIWorkflowNodeSpec[] + onAddAfter?: (sourceNodeId: string, spec: AIWorkflowNodeSpec) => void +}) { + if (!nodeSpecs?.length || !onAddAfter) { + return null + } + return ( + + + + + } + /> + +
添加下游节点
+
+ {nodeSpecs.map((spec) => ( + + ))} +
+
+
+ ) +} + +function WorkflowCanvasNode({ id, data, selected }: NodeProps) { + const [hovered, setHovered] = useState(false) const missingInputs = data.missingInputs ?? [] const hasIssue = missingInputs.length > 0 const isConditionNode = data.nodeType === "condition" + const nodeSpecs = data.nodeSpecs as AIWorkflowNodeSpec[] | undefined + const onAddAfter = data.onAddAfter as + | ((sourceNodeId: string, spec: AIWorkflowNodeSpec) => void) + | undefined + const showHandles = selected || hovered const handleClassName = cn( - "!size-4 !rounded-full !border-0 !bg-primary !text-primary-foreground !shadow-md", + "!size-4 !rounded-full !border-0 !bg-primary !text-primary-foreground !shadow-lg", "flex items-center justify-center opacity-0 transition-all duration-150", - selected - ? "pointer-events-auto opacity-100" - : "pointer-events-none group-hover/node:pointer-events-auto group-hover/node:opacity-100" + showHandles ? "pointer-events-auto opacity-100" : "pointer-events-none" ) if (isConditionNode) { return ( -
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + >
- - - + />
{hasIssue ? ( @@ -889,64 +1107,81 @@ function WorkflowCanvasNode({ data, selected }: NodeProps) {
{data.name ?? data.title}
分支
- - - + /> +
) } return (
setHovered(true)} + onMouseLeave={() => setHovered(false)} > - - - -
- {hasIssue ? ( - - ) : ( - - )} + /> +
+
+ {hasIssue ? ( + + ) : ( + + )} +
{data.name ?? data.title}
{data.title}
-
-
- 输入 {data.inputCount ?? 0} - 输出 {data.outputCount ?? 0} +
+
+ 输入 {data.inputCount ?? 0} + 输出 {data.outputCount ?? 0}
{hasIssue ? ( -
+
缺少输入:{missingInputs.join("、")}
) : ( -
+
配置完整
)}
- - - + /> +
) }