From 7029ab47b7fe752cc9ebdc1c4c802aa2da0287ba Mon Sep 17 00:00:00 2001 From: mlogclub Date: Mon, 22 Jun 2026 18:53:46 +0800 Subject: [PATCH] feat: implement createWorkflowNodeFromSpec function for node creation and add tests for it --- .../_components/workflow-editor.tsx | 203 ++++++++++++------ .../_components/workflow-utils.test.mjs | 35 +++ .../_components/workflow-utils.ts | 31 +++ 3 files changed, 201 insertions(+), 68 deletions(-) diff --git a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx index 861af2a..ce7d460 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx +++ b/web/app/dashboard/ai-workflows/_components/workflow-editor.tsx @@ -5,8 +5,10 @@ import "@xyflow/react/dist/style.css" import { addEdge, Background, + ConnectionMode, Controls, Handle, + MarkerType, MiniMap, Position, ReactFlow, @@ -16,6 +18,7 @@ import { type Edge, type Node, type NodeProps, + type ReactFlowInstance, } from "@xyflow/react" import { AlertCircleIcon, CheckCircle2Icon, PlusIcon } from "lucide-react" import { useCallback, useEffect, useMemo, useState } from "react" @@ -35,6 +38,7 @@ import { import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin" import { applyAutoInputMappings, + createWorkflowNodeFromSpec, fromApiDefinition, getAvailableVariables, getNodeSpec, @@ -43,10 +47,11 @@ import { validateWorkflowDraft, type WorkflowEditorEdge, type WorkflowEditorNode, - type WorkflowNodeSpec, } from "./workflow-utils" import { NodeConfigPanel } from "./node-config-panel" +const workflowDragType = "application/agent-desk-workflow-node" + type WorkflowNodeData = Record & { nodeType?: string name?: string @@ -67,6 +72,22 @@ const nodeTypes = { workflowNode: WorkflowCanvasNode, } +const fitViewOptions = { + padding: 0.16, + minZoom: 0.72, + maxZoom: 1, +} + +const defaultEdgeOptions = { + type: "smoothstep", + markerEnd: { + type: MarkerType.ArrowClosed, + }, + style: { + strokeWidth: 1.6, + }, +} + function toFlowNodes(definition: AIWorkflowDefinition): WorkflowFlowNode[] { return fromApiDefinition(definition).nodes.map((node) => ({ id: node.id, @@ -128,6 +149,7 @@ export function WorkflowEditor({ const [edges, setEdges, onEdgesChange] = useEdgesState( toFlowEdges(definition) ) + const [flowInstance, setFlowInstance] = useState | null>(null) const [selectedNodeId, setSelectedNodeId] = useState(null) const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) ?? null, @@ -157,80 +179,92 @@ export function WorkflowEditor({ const onConnect = useCallback( (connection: Connection) => { - let newEdge: WorkflowFlowEdge | null = null - setEdges((current) => { - let nextIndex = current.length + 1 - let id = `edge_${connection.source}_${connection.target}_${nextIndex}` - while (current.some((edge) => edge.id === id)) { - nextIndex += 1 - id = `edge_${connection.source}_${connection.target}_${nextIndex}` - } - newEdge = { - ...connection, - id, - } as WorkflowFlowEdge - return addEdge( - { - ...connection, - id, - }, - current - ) - }) - if (connection.source && connection.target) { - setNodes((currentNodes) => { - const currentDraft = toDraft(currentNodes, newEdge ? [...edges, newEdge] : edges) - const nextDraft = applyAutoInputMappings( - currentDraft, - connection.source!, - connection.target!, - nodeSpecs - ) - return currentNodes.map((node) => { - const nextNode = nextDraft.nodes.find((item) => item.id === node.id) - if (!nextNode) { - return node - } - return { - ...node, - data: { - ...node.data, - inputs: nextNode.data?.inputs ?? node.data.inputs, - }, - } - }) - }) + if (!connection.source || !connection.target) { + return } + const edge = { + ...connection, + id: uniqueEdgeId(edges, connection.source, connection.target), + } as WorkflowFlowEdge + setEdges((current) => addEdge(edge, current)) + setNodes((currentNodes) => { + const currentDraft = toDraft(currentNodes, [...edges, edge]) + const nextDraft = applyAutoInputMappings( + currentDraft, + connection.source!, + connection.target!, + nodeSpecs + ) + return currentNodes.map((node) => { + const nextNode = nextDraft.nodes.find((item) => item.id === node.id) + if (!nextNode) { + return node + } + return { + ...node, + data: { + ...node.data, + inputs: nextNode.data?.inputs ?? node.data.inputs, + }, + } + }) + }) }, [edges, nodeSpecs, setEdges, setNodes] ) const addNode = (spec: AIWorkflowNodeSpec) => { setNodes((current) => { - let nextIndex = current.length + 1 - let id = `${spec.type}_${nextIndex}` - while (current.some((node) => node.id === id)) { - nextIndex += 1 - id = `${spec.type}_${nextIndex}` - } + const node = createWorkflowNodeFromSpec( + spec, + current, + { x: 120 + current.length * 28, y: 100 + current.length * 24 } + ) as WorkflowFlowNode return [ ...current, { - id, - type: "workflowNode", - position: { x: 120 + current.length * 28, y: 100 + current.length * 24 }, + ...node, data: { - nodeType: spec.type, - name: spec.title, - label: spec.title, - config: {}, - inputs: spec.defaultInputs ?? {}, + ...node.data, }, }, ] }) } + const onNodeDragStart = (event: React.DragEvent, spec: AIWorkflowNodeSpec) => { + event.dataTransfer.setData(workflowDragType, spec.type) + event.dataTransfer.effectAllowed = "copy" + } + + const onCanvasDragOver = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes(workflowDragType)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = "copy" + } + + const onCanvasDrop = (event: React.DragEvent) => { + event.preventDefault() + const nodeType = event.dataTransfer.getData(workflowDragType) + if (!nodeType || !flowInstance) { + return + } + const spec = nodeSpecs.find((item) => item.type === nodeType) + if (!spec) { + return + } + const position = flowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }) + setNodes((current) => [ + ...current, + createWorkflowNodeFromSpec(spec, current, position) as WorkflowFlowNode, + ]) + } + const updateNodeData = (nodeId: string, data: WorkflowNodeData) => { setNodes((current) => current.map((node) => @@ -257,8 +291,10 @@ export function WorkflowEditor({ @@ -330,6 +379,16 @@ export function WorkflowEditor({ ) } +function uniqueEdgeId(edges: WorkflowFlowEdge[], source: string, target: string) { + let nextIndex = edges.length + 1 + let id = `edge_${source}_${target}_${nextIndex}` + while (edges.some((edge) => edge.id === id)) { + nextIndex += 1 + id = `edge_${source}_${target}_${nextIndex}` + } + return id +} + function enrichNodesForRender( nodes: WorkflowFlowNode[], nodeSpecs: AIWorkflowNodeSpec[] @@ -360,13 +419,17 @@ function WorkflowCanvasNode({ data, selected }: NodeProps) { return (
- -
+ +
{hasIssue ? ( ) : ( @@ -377,7 +440,7 @@ function WorkflowCanvasNode({ data, selected }: NodeProps) {
{data.title}
-
+
输入 {data.inputCount ?? 0} 输出 {data.outputCount ?? 0} @@ -392,7 +455,11 @@ function WorkflowCanvasNode({ data, selected }: NodeProps) {
)}
- +
) } @@ -407,7 +474,7 @@ function WorkflowValidationBadge({ return (
{valid ? ( - Valid draft + 流程可发布 ) : ( - {errors.length} issues + {errors.length} 个待处理 diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs b/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs index cd72a45..45df580 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs +++ b/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs @@ -144,6 +144,41 @@ describe("applyAutoInputMappings", () => { }) }) +describe("createWorkflowNodeFromSpec", () => { + it("creates node at dropped canvas position with unique id", async () => { + const { createWorkflowNodeFromSpec } = await loadModule() + + const node = createWorkflowNodeFromSpec( + { + type: "llm_reply", + title: "AI 回复", + defaultInputs: { + userMessage: { nodeId: "start_1", field: "userMessage" }, + }, + }, + [ + { id: "llm_reply_1", type: "workflowNode", position: { x: 0, y: 0 }, data: {} }, + ], + { x: 120, y: 240 } + ) + + assert.deepEqual(plain(node), { + id: "llm_reply_2", + type: "workflowNode", + position: { x: 120, y: 240 }, + data: { + nodeType: "llm_reply", + name: "AI 回复", + label: "AI 回复", + config: {}, + inputs: { + userMessage: { nodeId: "start_1", field: "userMessage" }, + }, + }, + }) + }) +}) + describe("getAvailableVariables", () => { it("exposes start outputs to retrieve node", async () => { const { getAvailableVariables } = await loadModule() diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts index 453654d..e4062d7 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts +++ b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts @@ -10,6 +10,7 @@ export type WorkflowEditorNode = { data?: { nodeType?: string name?: string + label?: string config?: Record inputs?: Record } @@ -273,6 +274,36 @@ export function applyAutoInputMappings( } } +export function createWorkflowNodeFromSpec( + spec: WorkflowNodeSpec, + existingNodes: Pick[], + position: WorkflowNodePosition +): WorkflowEditorNode { + const id = uniqueNodeId(existingNodes, spec.type) + return { + id, + type: "workflowNode", + position, + data: { + nodeType: spec.type, + name: spec.title ?? spec.type, + label: spec.title ?? spec.type, + config: {}, + inputs: spec.defaultInputs ?? {}, + }, + } +} + +function uniqueNodeId(existingNodes: Pick[], nodeType: string) { + let nextIndex = existingNodes.length + 1 + let id = `${nodeType}_${nextIndex}` + while (existingNodes.some((node) => node.id === id)) { + nextIndex += 1 + id = `${nodeType}_${nextIndex}` + } + return id +} + function findPreferredOutput( inputName: string, inputType: WorkflowVariableType,