feat: implement createWorkflowNodeFromSpec function for node creation and add tests for it
This commit is contained in:
@@ -5,8 +5,10 @@ import "@xyflow/react/dist/style.css"
|
|||||||
import {
|
import {
|
||||||
addEdge,
|
addEdge,
|
||||||
Background,
|
Background,
|
||||||
|
ConnectionMode,
|
||||||
Controls,
|
Controls,
|
||||||
Handle,
|
Handle,
|
||||||
|
MarkerType,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
Position,
|
Position,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
type Edge,
|
type Edge,
|
||||||
type Node,
|
type Node,
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
|
type ReactFlowInstance,
|
||||||
} from "@xyflow/react"
|
} from "@xyflow/react"
|
||||||
import { AlertCircleIcon, CheckCircle2Icon, PlusIcon } from "lucide-react"
|
import { AlertCircleIcon, CheckCircle2Icon, PlusIcon } from "lucide-react"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
@@ -35,6 +38,7 @@ import {
|
|||||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
import {
|
import {
|
||||||
applyAutoInputMappings,
|
applyAutoInputMappings,
|
||||||
|
createWorkflowNodeFromSpec,
|
||||||
fromApiDefinition,
|
fromApiDefinition,
|
||||||
getAvailableVariables,
|
getAvailableVariables,
|
||||||
getNodeSpec,
|
getNodeSpec,
|
||||||
@@ -43,10 +47,11 @@ import {
|
|||||||
validateWorkflowDraft,
|
validateWorkflowDraft,
|
||||||
type WorkflowEditorEdge,
|
type WorkflowEditorEdge,
|
||||||
type WorkflowEditorNode,
|
type WorkflowEditorNode,
|
||||||
type WorkflowNodeSpec,
|
|
||||||
} from "./workflow-utils"
|
} from "./workflow-utils"
|
||||||
import { NodeConfigPanel } from "./node-config-panel"
|
import { NodeConfigPanel } from "./node-config-panel"
|
||||||
|
|
||||||
|
const workflowDragType = "application/agent-desk-workflow-node"
|
||||||
|
|
||||||
type WorkflowNodeData = Record<string, unknown> & {
|
type WorkflowNodeData = Record<string, unknown> & {
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
name?: string
|
name?: string
|
||||||
@@ -67,6 +72,22 @@ const nodeTypes = {
|
|||||||
workflowNode: WorkflowCanvasNode,
|
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[] {
|
function toFlowNodes(definition: AIWorkflowDefinition): WorkflowFlowNode[] {
|
||||||
return fromApiDefinition(definition).nodes.map((node) => ({
|
return fromApiDefinition(definition).nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
@@ -128,6 +149,7 @@ export function WorkflowEditor({
|
|||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<WorkflowFlowEdge>(
|
const [edges, setEdges, onEdgesChange] = useEdgesState<WorkflowFlowEdge>(
|
||||||
toFlowEdges(definition)
|
toFlowEdges(definition)
|
||||||
)
|
)
|
||||||
|
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<WorkflowFlowNode, WorkflowFlowEdge> | null>(null)
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
const selectedNode = useMemo(
|
const selectedNode = useMemo(
|
||||||
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
|
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||||
@@ -157,80 +179,92 @@ export function WorkflowEditor({
|
|||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
let newEdge: WorkflowFlowEdge | null = null
|
if (!connection.source || !connection.target) {
|
||||||
setEdges((current) => {
|
return
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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]
|
[edges, nodeSpecs, setEdges, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const addNode = (spec: AIWorkflowNodeSpec) => {
|
const addNode = (spec: AIWorkflowNodeSpec) => {
|
||||||
setNodes((current) => {
|
setNodes((current) => {
|
||||||
let nextIndex = current.length + 1
|
const node = createWorkflowNodeFromSpec(
|
||||||
let id = `${spec.type}_${nextIndex}`
|
spec,
|
||||||
while (current.some((node) => node.id === id)) {
|
current,
|
||||||
nextIndex += 1
|
{ x: 120 + current.length * 28, y: 100 + current.length * 24 }
|
||||||
id = `${spec.type}_${nextIndex}`
|
) as WorkflowFlowNode
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
id,
|
...node,
|
||||||
type: "workflowNode",
|
|
||||||
position: { x: 120 + current.length * 28, y: 100 + current.length * 24 },
|
|
||||||
data: {
|
data: {
|
||||||
nodeType: spec.type,
|
...node.data,
|
||||||
name: spec.title,
|
|
||||||
label: spec.title,
|
|
||||||
config: {},
|
|
||||||
inputs: spec.defaultInputs ?? {},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
|
||||||
setNodes((current) =>
|
setNodes((current) =>
|
||||||
current.map((node) =>
|
current.map((node) =>
|
||||||
@@ -257,8 +291,10 @@ export function WorkflowEditor({
|
|||||||
<button
|
<button
|
||||||
key={spec.type}
|
key={spec.type}
|
||||||
type="button"
|
type="button"
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => onNodeDragStart(event, spec)}
|
||||||
onClick={() => addNode(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" />
|
<PlusIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
@@ -283,17 +319,30 @@ export function WorkflowEditor({
|
|||||||
nodes={renderedNodes}
|
nodes={renderedNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
defaultEdgeOptions={defaultEdgeOptions}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
connectionRadius={34}
|
||||||
|
connectOnClick
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onInit={setFlowInstance}
|
||||||
|
onDragOver={onCanvasDragOver}
|
||||||
|
onDrop={onCanvasDrop}
|
||||||
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
||||||
fitView
|
fitView
|
||||||
|
fitViewOptions={fitViewOptions}
|
||||||
|
minZoom={0.45}
|
||||||
|
maxZoom={1.35}
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls />
|
<Controls />
|
||||||
<MiniMap pannable zoomable />
|
<MiniMap pannable zoomable />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
<WorkflowValidationBadge errors={validation.errors} valid={validation.valid} />
|
<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>
|
</section>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
@@ -307,7 +356,7 @@ export function WorkflowEditor({
|
|||||||
/>
|
/>
|
||||||
{!validation.valid ? (
|
{!validation.valid ? (
|
||||||
<div className="border-t p-4">
|
<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">
|
<ul className="space-y-1 text-xs text-destructive">
|
||||||
{validation.errors.map((error) => (
|
{validation.errors.map((error) => (
|
||||||
<li key={error}>{error}</li>
|
<li key={error}>{error}</li>
|
||||||
@@ -321,7 +370,7 @@ export function WorkflowEditor({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)}
|
onClick={() => onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)}
|
||||||
>
|
>
|
||||||
Sync definition
|
同步当前流程
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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(
|
function enrichNodesForRender(
|
||||||
nodes: WorkflowFlowNode[],
|
nodes: WorkflowFlowNode[],
|
||||||
nodeSpecs: AIWorkflowNodeSpec[]
|
nodeSpecs: AIWorkflowNodeSpec[]
|
||||||
@@ -360,13 +419,17 @@ function WorkflowCanvasNode({ data, selected }: NodeProps<WorkflowFlowNode>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
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" : "",
|
selected ? "ring-2 ring-ring" : "",
|
||||||
hasIssue ? "border-destructive/70" : "border-border",
|
hasIssue ? "border-destructive/70" : "border-border",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<Handle type="target" position={Position.Left} />
|
<Handle
|
||||||
<div className="flex items-start gap-2 border-b px-3 py-2">
|
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 ? (
|
{hasIssue ? (
|
||||||
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
|
<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 className="mt-0.5 truncate text-xs text-muted-foreground">{data.title}</div>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between text-muted-foreground">
|
||||||
<span>输入 {data.inputCount ?? 0}</span>
|
<span>输入 {data.inputCount ?? 0}</span>
|
||||||
<span>输出 {data.outputCount ?? 0}</span>
|
<span>输出 {data.outputCount ?? 0}</span>
|
||||||
@@ -392,7 +455,11 @@ function WorkflowCanvasNode({ data, selected }: NodeProps<WorkflowFlowNode>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -407,7 +474,7 @@ function WorkflowValidationBadge({
|
|||||||
return (
|
return (
|
||||||
<div className="absolute left-3 top-3 flex gap-2">
|
<div className="absolute left-3 top-3 flex gap-2">
|
||||||
{valid ? (
|
{valid ? (
|
||||||
<Badge variant="default">Valid draft</Badge>
|
<Badge variant="default">流程可发布</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
@@ -419,7 +486,7 @@ function WorkflowValidationBadge({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Badge variant="destructive" className="cursor-pointer">
|
<Badge variant="destructive" className="cursor-pointer">
|
||||||
{errors.length} issues
|
{errors.length} 个待处理
|
||||||
</Badge>
|
</Badge>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent side="bottom" align="start" className="w-80">
|
<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", () => {
|
describe("getAvailableVariables", () => {
|
||||||
it("exposes start outputs to retrieve node", async () => {
|
it("exposes start outputs to retrieve node", async () => {
|
||||||
const { getAvailableVariables } = await loadModule()
|
const { getAvailableVariables } = await loadModule()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type WorkflowEditorNode = {
|
|||||||
data?: {
|
data?: {
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
label?: string
|
||||||
config?: Record<string, unknown>
|
config?: Record<string, unknown>
|
||||||
inputs?: Record<string, WorkflowVariableSelector>
|
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(
|
function findPreferredOutput(
|
||||||
inputName: string,
|
inputName: string,
|
||||||
inputType: WorkflowVariableType,
|
inputType: WorkflowVariableType,
|
||||||
|
|||||||
Reference in New Issue
Block a user