feat: implement createWorkflowNodeFromSpec function for node creation and add tests for it

This commit is contained in:
mlogclub
2026-06-22 18:53:46 +08:00
parent d1f39ae57e
commit 7029ab47b7
3 changed files with 201 additions and 68 deletions
@@ -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<string, unknown> & {
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<WorkflowFlowEdge>(
toFlowEdges(definition)
)
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<WorkflowFlowNode, WorkflowFlowEdge> | null>(null)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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<HTMLButtonElement>, 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({
<button
key={spec.type}
type="button"
draggable
onDragStart={(event) => onNodeDragStart(event, spec)}
onClick={() => addNode(spec)}
className="flex w-full items-start gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted"
className="flex w-full cursor-grab items-start gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted active:cursor-grabbing"
>
<PlusIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
@@ -283,17 +319,30 @@ export function WorkflowEditor({
nodes={renderedNodes}
edges={edges}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
connectionMode={ConnectionMode.Loose}
connectionRadius={34}
connectOnClick
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={setFlowInstance}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
fitView
fitViewOptions={fitViewOptions}
minZoom={0.45}
maxZoom={1.35}
>
<Background />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
<WorkflowValidationBadge errors={validation.errors} valid={validation.valid} />
<div className="pointer-events-none absolute bottom-3 left-3 rounded-md border bg-background/95 px-3 py-2 text-xs text-muted-foreground shadow-sm">
线
</div>
</section>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -307,7 +356,7 @@ export function WorkflowEditor({
/>
{!validation.valid ? (
<div className="border-t p-4">
<div className="mb-2 text-sm font-medium">Local validation</div>
<div className="mb-2 text-sm font-medium"></div>
<ul className="space-y-1 text-xs text-destructive">
{validation.errors.map((error) => (
<li key={error}>{error}</li>
@@ -321,7 +370,7 @@ export function WorkflowEditor({
className="w-full"
onClick={() => onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)}
>
Sync definition
</Button>
</div>
</aside>
@@ -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<WorkflowFlowNode>) {
return (
<div
className={[
"min-w-56 rounded-md border bg-background shadow-sm",
"group/node w-44 rounded-md border bg-background shadow-sm",
selected ? "ring-2 ring-ring" : "",
hasIssue ? "border-destructive/70" : "border-border",
].join(" ")}
>
<Handle type="target" position={Position.Left} />
<div className="flex items-start gap-2 border-b px-3 py-2">
<Handle
type="target"
position={Position.Left}
className="!size-2 !border !border-background !bg-muted-foreground/70 transition-colors group-hover/node:!bg-primary"
/>
<div className="flex items-start gap-2 border-b px-2.5 py-2">
{hasIssue ? (
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
) : (
@@ -377,7 +440,7 @@ function WorkflowCanvasNode({ data, selected }: NodeProps<WorkflowFlowNode>) {
<div className="mt-0.5 truncate text-xs text-muted-foreground">{data.title}</div>
</div>
</div>
<div className="space-y-2 px-3 py-2 text-xs">
<div className="space-y-1.5 px-2.5 py-2 text-xs">
<div className="flex justify-between text-muted-foreground">
<span> {data.inputCount ?? 0}</span>
<span> {data.outputCount ?? 0}</span>
@@ -392,7 +455,11 @@ function WorkflowCanvasNode({ data, selected }: NodeProps<WorkflowFlowNode>) {
</div>
)}
</div>
<Handle type="source" position={Position.Right} />
<Handle
type="source"
position={Position.Right}
className="!size-2 !border !border-background !bg-muted-foreground/70 transition-colors group-hover/node:!bg-primary"
/>
</div>
)
}
@@ -407,7 +474,7 @@ function WorkflowValidationBadge({
return (
<div className="absolute left-3 top-3 flex gap-2">
{valid ? (
<Badge variant="default">Valid draft</Badge>
<Badge variant="default"></Badge>
) : (
<Popover>
<PopoverTrigger
@@ -419,7 +486,7 @@ function WorkflowValidationBadge({
}
>
<Badge variant="destructive" className="cursor-pointer">
{errors.length} issues
{errors.length}
</Badge>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-80">
@@ -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()
@@ -10,6 +10,7 @@ export type WorkflowEditorNode = {
data?: {
nodeType?: string
name?: string
label?: string
config?: Record<string, unknown>
inputs?: Record<string, WorkflowVariableSelector>
}
@@ -273,6 +274,36 @@ export function applyAutoInputMappings(
}
}
export function createWorkflowNodeFromSpec(
spec: WorkflowNodeSpec,
existingNodes: Pick<WorkflowEditorNode, "id">[],
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<WorkflowEditorNode, "id">[], 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,