Răsfoiți Sursa

feat(webui): draw ports a node derives from its config, and named inputs

fszontagh 1 lună în urmă
părinte
comite
6e2f5e7417

+ 8 - 0
webui/src/api/workflows.ts

@@ -36,6 +36,13 @@ export interface NodeOutput {
   color?: string
 }
 
+export interface DynamicOutputs {
+  from: string
+  namePrefix: string
+  labelFrom?: string
+  color?: string
+}
+
 export interface NodeDefinition {
   id: string
   name: string
@@ -49,6 +56,7 @@ export interface NodeDefinition {
   outputSchema: object
   inputs: { name: string; displayName: string; type: string; required: boolean }[]
   outputs: NodeOutput[]
+  dynamicOutputs?: DynamicOutputs
 }
 
 // Transform backend workflow data to frontend format

+ 52 - 4
webui/src/components/workflow/WorkflowNode.tsx

@@ -1,6 +1,6 @@
 import { Handle, Position, NodeProps } from 'reactflow'
 import { Play, CheckCircle, XCircle, Loader2 } from 'lucide-react'
-import { NodeOutput } from '../../api/workflows'
+import { NodeOutput, DynamicOutputs } from '../../api/workflows'
 
 export interface NodeExecutionState {
   status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'disabled'
@@ -26,11 +26,36 @@ function calculateHandlePosition(index: number, total: number): number {
   return spacing * (index + 1)
 }
 
+// Ports for a node whose output count comes from its own config. Names are
+// positional so an edge survives someone editing a rule's label or value.
+export function resolveOutputs(
+  staticOutputs: NodeOutput[] | undefined,
+  dynamic: DynamicOutputs | undefined,
+  config: Record<string, any> | undefined
+): NodeOutput[] {
+  const fixed = staticOutputs?.length ? staticOutputs : []
+  if (!dynamic) {
+    return fixed.length ? fixed : [{ name: 'main', displayName: 'Output', type: 'any' }]
+  }
+  const rules = Array.isArray(config?.[dynamic.from]) ? config![dynamic.from] : []
+  const derived: NodeOutput[] = rules.map((rule: any, idx: number) => ({
+    name: `${dynamic.namePrefix}${idx}`,
+    displayName: (dynamic.labelFrom && rule?.[dynamic.labelFrom]) || `${dynamic.namePrefix} ${idx + 1}`,
+    type: 'any',
+    color: dynamic.color,
+  }))
+  const all = [...derived, ...fixed]
+  return all.length ? all : [{ name: 'main', displayName: 'Output', type: 'any' }]
+}
+
 export function WorkflowNode({ data, selected }: NodeProps) {
   const isTrigger = data.isTrigger
   const disabled = data.disabled === true
   const execState = data.executionState as NodeExecutionState | undefined
-  const outputs: NodeOutput[] = data.outputs || [{ name: 'main', displayName: 'Output', type: 'any' }]
+  const outputs: NodeOutput[] = resolveOutputs(data.outputs, data.dynamicOutputs, data.config)
+  const inputs: NodeOutput[] = data.inputs?.length
+    ? data.inputs
+    : [{ name: 'data', displayName: 'Data', type: 'any' }]
 
   // Get active branch from execution output (for IF conditions, loops, etc.)
   const activeBranch = execState?.output?._activeBranch as string | undefined
@@ -93,13 +118,36 @@ export function WorkflowNode({ data, selected }: NodeProps) {
       }`}
       style={getStatusStyle()}
     >
-      {/* Input handle at top (not for triggers) */}
-      {!isTrigger && (
+      {/* Input handles at top (not for triggers). A node that names more than
+          one input gets a handle each, spread the way outputs are. */}
+      {!isTrigger && inputs.map((input, idx) => (
         <Handle
+          key={input.name}
           type="target"
           position={Position.Top}
+          id={inputs.length > 1 ? input.name : undefined}
           className="!w-3 !h-3 !bg-gray-400 dark:!bg-gray-500 !border-2 !border-white dark:!border-slate-800"
+          style={inputs.length > 1 ? { left: `${calculateHandlePosition(idx, inputs.length)}%` } : undefined}
         />
+      ))}
+
+      {/* Input labels, only when there is more than one to tell apart */}
+      {!isTrigger && inputs.length > 1 && (
+        <div className="absolute -top-5 left-0 w-full pointer-events-none">
+          {inputs.map((input, idx) => (
+            <div
+              key={input.name}
+              className="text-[10px] text-center whitespace-nowrap text-gray-500 dark:text-gray-400"
+              style={{
+                position: 'absolute',
+                left: `${calculateHandlePosition(idx, inputs.length)}%`,
+                transform: 'translateX(-50%)',
+              }}
+            >
+              {input.displayName}
+            </div>
+          ))}
+        </div>
       )}
 
       {/* Node content */}