@foresthubai/workflow-builder 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +661 -661
  2. package/NOTICE +16 -16
  3. package/README.md +110 -93
  4. package/dist/components/ui/command.d.ts +2 -2
  5. package/dist/components/ui/input.d.ts +1 -1
  6. package/dist/components/ui/resizable.d.ts +1 -1
  7. package/dist/components/ui/textarea.d.ts +1 -1
  8. package/dist/graph/BaseNode.js +10 -10
  9. package/dist/graph/reactFlowRegistry.d.ts.map +1 -1
  10. package/dist/lib/utils.d.ts +3 -0
  11. package/dist/lib/utils.d.ts.map +1 -0
  12. package/dist/lib/utils.js +6 -0
  13. package/dist/lib/utils.js.map +1 -0
  14. package/dist/toolbars/CanvasTabsToolbar.d.ts +11 -0
  15. package/dist/toolbars/CanvasTabsToolbar.d.ts.map +1 -0
  16. package/dist/toolbars/CanvasTabsToolbar.js +101 -0
  17. package/dist/toolbars/CanvasTabsToolbar.js.map +1 -0
  18. package/package.json +2 -2
  19. package/src/BuilderLayout.tsx +345 -345
  20. package/src/Canvas.tsx +261 -261
  21. package/src/CanvasEditor.tsx +142 -142
  22. package/src/CanvasTabsToolbar.tsx +176 -176
  23. package/src/RightConfigPanel.tsx +266 -266
  24. package/src/WorkflowBuilder.tsx +412 -412
  25. package/src/cn.ts +6 -6
  26. package/src/components/ui/add-button.tsx +39 -39
  27. package/src/components/ui/alert-dialog.tsx +141 -141
  28. package/src/components/ui/alert.tsx +59 -59
  29. package/src/components/ui/badge.tsx +36 -36
  30. package/src/components/ui/button.tsx +85 -85
  31. package/src/components/ui/card.tsx +79 -79
  32. package/src/components/ui/checkbox.tsx +28 -28
  33. package/src/components/ui/collapsible.tsx +9 -9
  34. package/src/components/ui/command.tsx +153 -153
  35. package/src/components/ui/delete-button.tsx +23 -23
  36. package/src/components/ui/dialog.tsx +125 -125
  37. package/src/components/ui/dropdown-menu.tsx +198 -198
  38. package/src/components/ui/input.tsx +55 -55
  39. package/src/components/ui/label.tsx +24 -24
  40. package/src/components/ui/readonly-banner.tsx +15 -15
  41. package/src/components/ui/resizable.tsx +43 -43
  42. package/src/components/ui/scroll-area.tsx +102 -102
  43. package/src/components/ui/select.tsx +160 -160
  44. package/src/components/ui/separator.tsx +29 -29
  45. package/src/components/ui/switch.tsx +27 -27
  46. package/src/components/ui/textarea.tsx +51 -51
  47. package/src/components/ui/toast.tsx +127 -127
  48. package/src/components/ui/toaster.tsx +33 -33
  49. package/src/components/ui/toggle-group.tsx +59 -59
  50. package/src/components/ui/toggle.tsx +43 -43
  51. package/src/components/ui/tooltip.tsx +32 -32
  52. package/src/dialogs/NodePickerDialog.tsx +84 -84
  53. package/src/dialogs/ValidationDialog.tsx +184 -184
  54. package/src/graph/BaseNode.tsx +557 -557
  55. package/src/graph/CustomEdge.tsx +185 -185
  56. package/src/graph/CustomNode.tsx +16 -16
  57. package/src/graph/FunctionCallNode.tsx +30 -30
  58. package/src/graph/PortHandle.tsx +189 -189
  59. package/src/graph/reactFlowRegistry.ts +26 -26
  60. package/src/hooks/use-toast.ts +125 -125
  61. package/src/hooks/useAvailableVariables.ts +20 -20
  62. package/src/hooks/useCanvasHistory.ts +22 -22
  63. package/src/hooks/useCanvasTabs.ts +168 -168
  64. package/src/hooks/useFunctionDiagnosticsSync.ts +40 -40
  65. package/src/hooks/useFunctionRegistry.ts +26 -26
  66. package/src/hooks/useFunctions.ts +44 -44
  67. package/src/hooks/useGraph.ts +161 -161
  68. package/src/hooks/useNodeDefinitions.ts +82 -82
  69. package/src/hooks/useParamErrors.ts +26 -26
  70. package/src/hooks/useResolvedTheme.ts +30 -30
  71. package/src/hooks/useResourceDiagnosticsSync.ts +58 -58
  72. package/src/hooks/useSuppressThemeTransition.ts +79 -79
  73. package/src/hooks/useWorkflowSerialization.ts +127 -127
  74. package/src/i18n/index.ts +53 -53
  75. package/src/i18n/locales/de.json +501 -501
  76. package/src/i18n/locales/en.json +557 -557
  77. package/src/index.ts +27 -27
  78. package/src/inputs/ExpressionInput.tsx +297 -297
  79. package/src/inputs/ParameterEditor.tsx +515 -515
  80. package/src/inputs/PortSection.tsx +144 -144
  81. package/src/panels/BuilderSidebar.tsx +301 -301
  82. package/src/panels/ChannelConfigPanel.tsx +49 -49
  83. package/src/panels/ChannelsPanel.tsx +28 -28
  84. package/src/panels/DebugConsolePanel.tsx +73 -73
  85. package/src/panels/DebugContextPanel.tsx +77 -77
  86. package/src/panels/DebugExternalIOPanel.tsx +180 -180
  87. package/src/panels/DiagnosticsPanel.tsx +170 -170
  88. package/src/panels/EdgeConfigPanel.tsx +104 -104
  89. package/src/panels/FunctionConfigPanel.tsx +179 -179
  90. package/src/panels/FunctionListPanel.tsx +45 -45
  91. package/src/panels/MemoryConfigPanel.tsx +55 -55
  92. package/src/panels/MemoryPanel.tsx +40 -40
  93. package/src/panels/ModelConfigPanel.tsx +41 -41
  94. package/src/panels/ModelsPanel.tsx +36 -36
  95. package/src/panels/NodeConfigPanel.tsx +630 -630
  96. package/src/panels/NodeLibrary.tsx +288 -288
  97. package/src/panels/ResourceConfigPanel.tsx +132 -132
  98. package/src/panels/ResourceListPanel.tsx +113 -113
  99. package/src/panels/VariableConfigPanel.tsx +161 -161
  100. package/src/panels/VariablesPanel.tsx +145 -145
  101. package/src/stores/canvasStore.test.ts +44 -44
  102. package/src/stores/canvasStore.ts +245 -245
  103. package/src/stores/debugStore.ts +74 -74
  104. package/src/stores/diagnosticsStore.ts +130 -130
  105. package/src/stores/editorStore.ts +202 -202
  106. package/src/styles/index.css +526 -526
  107. package/src/utils/categoryConstants.ts +26 -26
  108. package/src/utils/channelOperations.ts +86 -86
  109. package/src/utils/connectionRules.ts +137 -137
  110. package/src/utils/functionOperations.ts +179 -179
  111. package/src/utils/graphOperations.ts +550 -550
  112. package/src/utils/history.ts +207 -207
  113. package/src/utils/memoryOperations.ts +57 -57
  114. package/src/utils/migrateFunctionNodes.ts +107 -107
  115. package/src/utils/modelOperations.ts +55 -55
  116. package/src/utils/paramDisplay.ts +71 -71
  117. package/src/utils/resourceHelpers.ts +32 -32
  118. package/src/utils/translation.ts +28 -28
  119. package/src/utils/variableOperations.ts +75 -75
  120. package/tailwind-preset.ts +166 -166
@@ -1,557 +1,557 @@
1
- import { Badge } from "../components/ui/badge";
2
- import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
3
- import {
4
- NodeBase,
5
- NodeCategory,
6
- NodeDefinition,
7
- NodeData,
8
- getArguments,
9
- getPorts,
10
- } from "@foresthubai/workflow-core/node";
11
- import { NodeProps, Position } from "@xyflow/react";
12
- import { AlertCircle, AlertTriangle } from "lucide-react";
13
- import { memo, useCallback, useEffect, useMemo } from "react";
14
- import { useAvailableVariables } from "../hooks/useAvailableVariables";
15
- import { getOrCreateCanvasStore } from "../stores/canvasStore";
16
- import { useDebugStore } from "../stores/debugStore";
17
- import { useDiagnosticsStore } from "../stores/diagnosticsStore";
18
- import { useEditorStore } from "../stores/editorStore";
19
- import { isReadOnly } from "../WorkflowBuilder";
20
- import { categoryIcons } from "../utils/categoryConstants";
21
- import { computeNodeDiagnostics } from "@foresthubai/workflow-core/diagnostics";
22
- import {
23
- parseExpression,
24
- type ParseResult,
25
- isExpression,
26
- resolveExpression,
27
- type ResolvedExpr,
28
- } from "@foresthubai/workflow-core/expression";
29
- import { isNodeUsedAsTool } from "@foresthubai/workflow-core/node";
30
- import { canPortAcceptEdge } from "../utils/connectionRules";
31
- import { PortHandle } from "./PortHandle";
32
- import { isParameterActive } from "@foresthubai/workflow-core/parameter";
33
- import { formatParamDisplay, displayValue } from "../utils/paramDisplay";
34
-
35
- // Node shape variants
36
- type NodeShape = "rectangle" | "tapered-right";
37
-
38
- export interface BaseNodeProps extends NodeProps {
39
- nodeDefinition: NodeDefinition | undefined;
40
- isStale?: boolean;
41
- isDeleted?: boolean;
42
- }
43
-
44
- // Base Node component - handles all rendering logic
45
- export const BaseNode = memo(
46
- ({ id, data, selected, nodeDefinition, isStale = false, isDeleted = false }: BaseNodeProps) => {
47
- const nodeData = data as NodeData;
48
- const isHighlighted = selected ?? false;
49
- // Skip diagnostics when in read-only mode OR when rendered inside VersionPreviewCanvas
50
- const isPreview = useEditorStore((s) => isReadOnly(s.builderMode)) || !!(data as Record<string, unknown>)?._preview;
51
-
52
- // Debug cursor: true when this node is the current debug step target
53
- const isDebugCursor = useDebugStore(
54
- useCallback(
55
- (s) => {
56
- const p = s.phase;
57
- return (p.status === "paused" || p.status === "stepping") && p.cursorNodeId === id;
58
- },
59
- [id],
60
- ),
61
- );
62
-
63
- // Get active canvas ID with imperative access (no subscription), since it doesn't change for a node
64
- const activeCanvasId = useEditorStore.getState().activeCanvasId;
65
-
66
- // Get necessary canvas store data
67
- const canvasStore = getOrCreateCanvasStore(activeCanvasId);
68
- const edges = canvasStore((s) => s.edges);
69
- const nodes = canvasStore((s) => s.nodes);
70
- const channels = useEditorStore((s) => s.channels);
71
- const memory = useEditorStore((s) => s.memory);
72
- const models = useEditorStore((s) => s.models);
73
- const availableModels = useEditorStore((s) => s.availableModels);
74
-
75
- // Get available variables for resolving expressions
76
- const { lookup: availableVariables } = useAvailableVariables(activeCanvasId);
77
-
78
- // Build channel ID → label lookup for formatParamDisplay
79
- const channelLabels = useMemo(() => {
80
- const labels: Record<string, string> = {};
81
- for (const v of Object.values(channels)) labels[v.id] = v.label;
82
- return labels;
83
- }, [channels]);
84
-
85
- // Build memory ID → label lookup for formatParamDisplay (memorySelect params)
86
- const memoryLabels = useMemo(() => {
87
- const labels: Record<string, string> = {};
88
- for (const m of Object.values(memory)) labels[m.id] = m.label;
89
- return labels;
90
- }, [memory]);
91
-
92
- // Build model ID → label lookup (catalog ∪ declared customs) + the catalog id
93
- // set — used for inline display and modelSelect reference validation.
94
- const { modelLabels, availableModelIds } = useMemo(() => {
95
- const labels: Record<string, string> = {};
96
- const ids = new Set<string>();
97
- for (const m of availableModels) {
98
- labels[m.id] = m.label;
99
- ids.add(m.id);
100
- }
101
- for (const m of Object.values(models)) labels[m.id] = m.label;
102
- return { modelLabels: labels, availableModelIds: ids };
103
- }, [availableModels, models]);
104
-
105
- // Get port definitions using centralized dispatcher
106
- const portDefinitions = getPorts(nodeData);
107
-
108
- // Separate ports by type for positioning
109
- const { executionInputs, toolInputs, executionOutputs, toolOutputs } = useMemo(() => {
110
- return {
111
- executionInputs: portDefinitions.input.filter((p) => p.type === "control"),
112
- toolInputs: portDefinitions.input.filter((p) => p.type === "tool"),
113
- executionOutputs: portDefinitions.output.filter((p) => p.type === "control"),
114
- toolOutputs: portDefinitions.output.filter((p) => p.type === "tool"),
115
- };
116
- }, [portDefinitions]);
117
-
118
- // Mutual exclusion: tool input vs control ports.
119
- // Tool OUTPUT is always allowed (not part of exclusion).
120
- const usedAsToolInput = useMemo(() => {
121
- return isNodeUsedAsTool(id, nodeData, edges);
122
- }, [id, nodeData, edges]);
123
-
124
- const parameters = getArguments(nodeData);
125
-
126
- // Set of parameter IDs that should be hidden given current context
127
- const hiddenParamIds = useMemo(() => {
128
- const ids = new Set<string>();
129
- for (const p of nodeDefinition?.parameters ?? []) {
130
- if (!isParameterActive(p, parameters, usedAsToolInput)) ids.add(p.id);
131
- }
132
- return ids.size > 0 ? ids : null;
133
- }, [usedAsToolInput, nodeDefinition, parameters]);
134
-
135
- const category = nodeDefinition?.category;
136
-
137
- // Compute diagnostics via extracted pure function
138
- const diagnostics = useMemo(
139
- () =>
140
- computeNodeDiagnostics({
141
- canvasId: activeCanvasId,
142
- nodeId: id,
143
- nodeData,
144
- nodeDefinition,
145
- availableVariables,
146
- channels,
147
- memory,
148
- models,
149
- availableModelIds,
150
- edges,
151
- isStale,
152
- isDeleted,
153
- }),
154
- [
155
- activeCanvasId,
156
- id,
157
- nodeData,
158
- nodeDefinition,
159
- availableVariables,
160
- channels,
161
- memory,
162
- models,
163
- availableModelIds,
164
- edges,
165
- isStale,
166
- isDeleted,
167
- ],
168
- );
169
-
170
- // Resolve expressions for display (separate from diagnostics)
171
- const resolvedExpressions = useMemo(() => {
172
- const resolved: Record<string, { expr: ResolvedExpr; parseRes: ParseResult }> = {};
173
- const paramDefs = nodeDefinition?.parameters ?? [];
174
- for (const param of paramDefs) {
175
- if (hiddenParamIds?.has(param.id)) continue;
176
- const value = parameters[param.id];
177
- if (isExpression(value)) {
178
- const expr = resolveExpression(value, availableVariables);
179
- const parseRes = parseExpression(expr);
180
- resolved[param.id] = { expr, parseRes };
181
- }
182
- }
183
- return resolved;
184
- }, [parameters, availableVariables, hiddenParamIds, nodeDefinition]);
185
-
186
- // Derived booleans from diagnostics
187
- const hasErrors = diagnostics.some((d) => d.severity === "error");
188
- const hasWarnings = diagnostics.some((d) => d.severity === "warning");
189
-
190
- // Write diagnostics to store (cleanup on unmount; validateAllCanvases handles full-project)
191
- const setNodeDiagnostics = useDiagnosticsStore((s) => s.setNodeDiagnostics);
192
- const clearNodeDiagnostics = useDiagnosticsStore((s) => s.clearNodeDiagnostics);
193
- useEffect(() => {
194
- if (isPreview) return;
195
- setNodeDiagnostics(id, diagnostics);
196
- return () => clearNodeDiagnostics(id);
197
- }, [id, diagnostics, setNodeDiagnostics, clearNodeDiagnostics, isPreview]);
198
-
199
- const usedInControlFlow = useMemo(() => {
200
- return edges.some((e) => {
201
- if (e.source === id) {
202
- return executionOutputs.some((p) => p.id === e.sourceHandle);
203
- }
204
- if (e.target === id) {
205
- return executionInputs.some((p) => p.id === e.targetHandle);
206
- }
207
- return false;
208
- });
209
- }, [edges, id, executionInputs, executionOutputs]);
210
-
211
- const IconComponent = category ? categoryIcons[category] : null;
212
-
213
- // Determine node shape based on category
214
- const nodeShape: NodeShape = useMemo(() => {
215
- if (category === "Trigger") return "tapered-right";
216
- return "rectangle";
217
- }, [category]);
218
-
219
- // Get the color variable based on category
220
- const nodeColor = useMemo(() => {
221
- if (category === NodeCategory.Trigger) return "--node-trigger";
222
- if (category === NodeCategory.Tool) return "--node-tool";
223
- if (category === NodeCategory.AI) return "--node-agent";
224
- if (category === NodeCategory.Input) return "--node-input";
225
- if (category === NodeCategory.Output) return "--node-output";
226
- if (category === NodeCategory.Logic) return "--node-logic";
227
- if (category === NodeCategory.Data) return "--node-data";
228
- if (category === NodeCategory.Function) return "--node-function";
229
- return "--primary";
230
- }, [category]);
231
-
232
- // Determine if this node should have highlighted styling (Tool, Trigger, Agent)
233
- const fancyBg = category === NodeCategory.Tool || category === NodeCategory.Trigger || category === NodeCategory.AI;
234
-
235
- // Get parameters dynamically from node definition (must be before early return for hooks order)
236
- // Filter out hidden params based on display conditions
237
- const nodeParameters = useMemo(() => {
238
- const params = nodeDefinition?.parameters ?? [];
239
- if (!hiddenParamIds) return params;
240
- return params.filter((p) => !hiddenParamIds.has(p.id));
241
- }, [nodeDefinition, hiddenParamIds]);
242
-
243
- // Count visible inline params for height calculation (show first 3 params always)
244
- const visibleParamCount = useMemo(() => {
245
- const shown = Math.min(nodeParameters.length, 3);
246
- const hasOverflow = nodeParameters.length > 3 ? 1 : 0;
247
- return shown + hasOverflow;
248
- }, [nodeParameters]);
249
-
250
- // Calculate dimensions
251
- const maxExecutionPorts = Math.max(executionInputs.length, executionOutputs.length);
252
- const nodeWidth = 200;
253
- const taperWidth = 24;
254
- const paramLineHeight = 16; // ~text-xs line + space-y-0.5 gap
255
- const headerHeight = 50; // padding + emoji/badge row + margin
256
- const minHeight = useMemo(() => {
257
- const paramHeight = visibleParamCount * paramLineHeight;
258
- const contentHeight = headerHeight + paramHeight;
259
- if (nodeShape === "tapered-right") {
260
- return Math.max(contentHeight, 60 + Math.max(maxExecutionPorts, 1) * 40);
261
- }
262
- return Math.max(contentHeight, 80 + maxExecutionPorts * 40);
263
- }, [nodeShape, maxExecutionPorts, visibleParamCount]);
264
-
265
- if (!nodeDefinition) {
266
- return (
267
- <div className="min-w-[200px] p-3 border border-destructive/50 bg-destructive/10 rounded-lg">
268
- <div className="text-destructive text-sm">Unknown node: {nodeData.type}</div>
269
- </div>
270
- );
271
- }
272
-
273
- // Calculate vertical port positions for even distribution (left/right)
274
- const getVerticalPortPosition = (index: number, total: number) => {
275
- if (total === 1) return minHeight / 2;
276
- const spacing = (minHeight - 40) / (total + 1);
277
- return 20 + spacing * (index + 1);
278
- };
279
-
280
- // Calculate horizontal port positions for even distribution (top/bottom)
281
- const getHorizontalPortPosition = (index: number, total: number) => {
282
- if (total === 1) return nodeWidth / 2;
283
- const spacing = (nodeWidth - 40) / (total + 1);
284
- return 20 + spacing * (index + 1);
285
- };
286
-
287
- // Render SVG background shape
288
- const renderShape = () => {
289
- const gradientId = `node-gradient-${nodeData.id}`;
290
- const strokeW = 2;
291
-
292
- // Dark nodes (Tool, Trigger) use dark gradient, others use white/light background
293
- const gradientStartColor = `hsl(var(--canvas-background))`;
294
- const gradientEndColor = `color-mix(in srgb, hsl(var(${nodeColor})), hsl(var(--canvas-background)) 80%)`;
295
- const lightFill = "hsl(var(--card))";
296
-
297
- // Glow effect for highlighted state (our custom selection, not ReactFlow's)
298
- // Debug cursor gets an orange pulsing glow (takes priority over selection glow)
299
- const glowStyle = isDebugCursor
300
- ? {
301
- filter: `drop-shadow(0 0 14px hsl(var(--warning) / 0.7)) drop-shadow(0 0 24px hsl(var(--warning) / 0.5))`,
302
- animation: "debug-cursor-pulse 1.5s ease-in-out infinite",
303
- }
304
- : isHighlighted
305
- ? {
306
- filter: `drop-shadow(0 0 12px hsl(var(--selection-glow) / 0.6)) drop-shadow(0 0 20px hsl(var(--selection-glow) / 0.4))`,
307
- }
308
- : {};
309
-
310
- if (nodeShape === "tapered-right") {
311
- const inset = strokeW / 2;
312
- return (
313
- <svg
314
- className="absolute inset-0 z-0 transition-all duration-300 will-change-[filter]"
315
- width={nodeWidth}
316
- height={minHeight}
317
- viewBox={`0 0 ${nodeWidth} ${minHeight}`}
318
- preserveAspectRatio="none"
319
- style={glowStyle}
320
- >
321
- <defs>
322
- <linearGradient id={gradientId} x1="0%" y1="70%" x2="100%" y2="0%">
323
- <stop offset="0%" stopColor={gradientStartColor} />
324
- <stop offset="100%" stopColor={gradientEndColor} />
325
- </linearGradient>
326
- </defs>
327
- {/* Tapered right edge shape - rightmost edge at nodeWidth */}
328
- <path
329
- d={`
330
- M ${12 + inset} ${0 + inset}
331
- L ${nodeWidth - taperWidth - inset} ${0 + inset}
332
- L ${nodeWidth - inset} ${minHeight / 2}
333
- L ${nodeWidth - taperWidth - inset} ${minHeight - inset}
334
- L ${12 + inset} ${minHeight - inset}
335
- Q ${0 + inset} ${minHeight - inset} ${0 + inset} ${minHeight - 12 - inset}
336
- L ${0 + inset} ${12 + inset}
337
- Q ${0 + inset} ${0 + inset} ${12 + inset} ${0 + inset}
338
- Z
339
- `}
340
- fill={`url(#${gradientId})`}
341
- stroke={
342
- hasErrors
343
- ? "hsl(var(--destructive))"
344
- : fancyBg
345
- ? `hsl(var(${nodeColor})/0.5)`
346
- : "hsl(var(--edge-default))"
347
- }
348
- strokeWidth={strokeW}
349
- />
350
- </svg>
351
- );
352
- }
353
-
354
- // Rectangle shape (default) - use light or dark based on category
355
- return (
356
- <svg
357
- className="absolute inset-0 z-0 transition-all duration-300 will-change-[filter]"
358
- width={nodeWidth}
359
- height={minHeight}
360
- viewBox={`0 0 ${nodeWidth} ${minHeight}`}
361
- preserveAspectRatio="none"
362
- style={glowStyle}
363
- >
364
- {fancyBg && (
365
- <defs>
366
- <linearGradient id={gradientId} x1="0%" y1="70%" x2="100%" y2="0%">
367
- <stop offset="0%" stopColor={gradientStartColor} />
368
- <stop offset="120%" stopColor={gradientEndColor} />
369
- </linearGradient>
370
- </defs>
371
- )}
372
- <rect
373
- x={strokeW / 2}
374
- y={strokeW / 2}
375
- width={nodeWidth - strokeW}
376
- height={minHeight - strokeW}
377
- rx="10"
378
- ry="10"
379
- fill={fancyBg ? `url(#${gradientId})` : lightFill}
380
- stroke={
381
- hasErrors
382
- ? "hsl(var(--destructive))"
383
- : fancyBg
384
- ? `hsl(var(${nodeColor})/0.5)`
385
- : "hsl(var(--edge-default))"
386
- }
387
- strokeWidth={strokeW}
388
- />
389
- </svg>
390
- );
391
- };
392
-
393
- return (
394
- <div className="relative z-0" style={{ height: `${minHeight}px`, width: `${nodeWidth}px` }}>
395
- {/* Tool input handles (top) — disabled when control ports are connected */}
396
- {toolInputs.map((port, index) => (
397
- <PortHandle
398
- key={port.id}
399
- id={port.id}
400
- type="target"
401
- position={Position.Top}
402
- portType={port.type}
403
- label={port.label}
404
- disabled={usedInControlFlow}
405
- style={{
406
- left: `${getHorizontalPortPosition(index, toolInputs.length)}px`,
407
- }}
408
- />
409
- ))}
410
-
411
- {/* Execution input handles (left) — disabled when tool input is connected */}
412
- {executionInputs.map((port, index) => (
413
- <PortHandle
414
- key={port.id}
415
- id={port.id}
416
- type="target"
417
- position={Position.Left}
418
- portType={port.type}
419
- label={port.label}
420
- disabled={usedAsToolInput}
421
- style={{
422
- top:
423
- nodeShape === "tapered-right"
424
- ? `${minHeight / 2}px`
425
- : `${getVerticalPortPosition(index, executionInputs.length)}px`,
426
- }}
427
- />
428
- ))}
429
-
430
- {/* SVG Shape */}
431
- {renderShape()}
432
-
433
- {/* Error badge */}
434
- {hasErrors && (
435
- <Tooltip delayDuration={300}>
436
- <TooltipTrigger asChild>
437
- <div className="absolute -top-4 -left-4 z-30 cursor-help">
438
- <AlertTriangle className="h-8 w-8 text-destructive fill-card" strokeWidth={2} />
439
- </div>
440
- </TooltipTrigger>
441
- <TooltipContent side="top" className="bg-destructive text-destructive-foreground text-xs px-2 py-1">
442
- {diagnostics.find((d) => d.severity === "error")?.message ?? "This node has errors"}
443
- </TooltipContent>
444
- </Tooltip>
445
- )}
446
-
447
- {/* Warning badge (only when no errors) */}
448
- {!hasErrors && hasWarnings && (
449
- <Tooltip delayDuration={300}>
450
- <TooltipTrigger asChild>
451
- <div className="absolute -top-4 -left-4 z-30 cursor-help">
452
- <AlertCircle className="h-8 w-8 text-warning fill-card" strokeWidth={2} />
453
- </div>
454
- </TooltipTrigger>
455
- <TooltipContent side="top" className="bg-warning text-warning-foreground text-xs px-2 py-1">
456
- {diagnostics.find((d) => d.severity === "warning")?.message ?? "This node has warnings"}
457
- </TooltipContent>
458
- </Tooltip>
459
- )}
460
-
461
- {/* Content overlay */}
462
- {/* Content overlay — top-aligned */}
463
- <div className={`relative z-10 p-3 ${nodeShape === "tapered-right" ? "pr-8" : ""}`}>
464
- {/* Header row: emoji + label badge */}
465
- <div className="flex items-center gap-1.5 min-w-0 mb-1.5">
466
- {IconComponent && (
467
- <IconComponent className="w-4 h-4 shrink-0" style={{ color: `hsl(var(${nodeColor}))` }} />
468
- )}
469
- <Badge
470
- variant="secondary"
471
- className="text-sm truncate max-w-full text-foreground"
472
- style={{
473
- backgroundColor: `hsl(var(${nodeColor}) / 0.3)`,
474
- borderColor: `hsl(var(${nodeColor}) / 0.4)`,
475
- }}
476
- >
477
- {(nodeData as NodeBase).label || nodeDefinition.label}
478
- </Badge>
479
- </div>
480
-
481
- {/* Parameters list */}
482
- {nodeParameters.length > 0 && (
483
- <div className="space-y-0.5">
484
- {nodeParameters.slice(0, 3).map((param) => {
485
- const value = parameters[param.id];
486
- const resolved = resolvedExpressions[param.id];
487
-
488
- const paramDisplay = resolved
489
- ? null
490
- : formatParamDisplay(param, value, availableVariables, channelLabels, memoryLabels, modelLabels);
491
- const isInvalid = resolved ? !resolved.parseRes.isValid : !!paramDisplay?.isInvalid;
492
-
493
- return (
494
- <div key={param.id} className="text-xs leading-4 flex items-baseline gap-1 truncate">
495
- <span className="shrink-0 text-muted-foreground">{param.label}:</span>
496
- <span className={`font-mono truncate ${isInvalid ? "text-destructive" : "text-foreground"}`}>
497
- {resolved ? displayValue(resolved.expr) : paramDisplay!.text}
498
- </span>
499
- </div>
500
- );
501
- })}
502
- {nodeParameters.length > 3 && (
503
- <div className="text-xs text-muted-foreground">+{nodeParameters.length - 3} more...</div>
504
- )}
505
- </div>
506
- )}
507
- </div>
508
-
509
- {/* Execution output handles (right) — disabled when tool input is connected */}
510
- {executionOutputs.map((port, index) => {
511
- const canAccept = !isPreview && !usedAsToolInput && canPortAcceptEdge(id, port.id, nodes, edges);
512
- return (
513
- <PortHandle
514
- key={port.id}
515
- id={port.id}
516
- type="source"
517
- position={Position.Right}
518
- portType={port.type}
519
- label={port.label}
520
- disabled={usedAsToolInput}
521
- showPlus={canAccept}
522
- nodeId={id}
523
- style={{
524
- top:
525
- nodeShape === "tapered-right"
526
- ? `${minHeight / 2}px`
527
- : `${getVerticalPortPosition(index, executionOutputs.length)}px`,
528
- right: "0",
529
- }}
530
- />
531
- );
532
- })}
533
-
534
- {/* Tool output handles (bottom) */}
535
- {toolOutputs.map((port, index) => {
536
- const canAccept = !isPreview && canPortAcceptEdge(id, port.id, nodes, edges);
537
- return (
538
- <PortHandle
539
- key={port.id}
540
- id={port.id}
541
- type="source"
542
- position={Position.Bottom}
543
- portType={port.type}
544
- label={port.label}
545
- showPlus={canAccept}
546
- nodeId={id}
547
- style={{
548
- left: `${getHorizontalPortPosition(index, toolOutputs.length)}px`,
549
- bottom: 0,
550
- }}
551
- />
552
- );
553
- })}
554
- </div>
555
- );
556
- },
557
- );
1
+ import { Badge } from "../components/ui/badge";
2
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
3
+ import {
4
+ NodeBase,
5
+ NodeCategory,
6
+ NodeDefinition,
7
+ NodeData,
8
+ getArguments,
9
+ getPorts,
10
+ } from "@foresthubai/workflow-core/node";
11
+ import { NodeProps, Position } from "@xyflow/react";
12
+ import { AlertCircle, AlertTriangle } from "lucide-react";
13
+ import { memo, useCallback, useEffect, useMemo } from "react";
14
+ import { useAvailableVariables } from "../hooks/useAvailableVariables";
15
+ import { getOrCreateCanvasStore } from "../stores/canvasStore";
16
+ import { useDebugStore } from "../stores/debugStore";
17
+ import { useDiagnosticsStore } from "../stores/diagnosticsStore";
18
+ import { useEditorStore } from "../stores/editorStore";
19
+ import { isReadOnly } from "../WorkflowBuilder";
20
+ import { categoryIcons } from "../utils/categoryConstants";
21
+ import { computeNodeDiagnostics } from "@foresthubai/workflow-core/diagnostics";
22
+ import {
23
+ parseExpression,
24
+ type ParseResult,
25
+ isExpression,
26
+ resolveExpression,
27
+ type ResolvedExpr,
28
+ } from "@foresthubai/workflow-core/expression";
29
+ import { isNodeUsedAsTool } from "@foresthubai/workflow-core/node";
30
+ import { canPortAcceptEdge } from "../utils/connectionRules";
31
+ import { PortHandle } from "./PortHandle";
32
+ import { isParameterActive } from "@foresthubai/workflow-core/parameter";
33
+ import { formatParamDisplay, displayValue } from "../utils/paramDisplay";
34
+
35
+ // Node shape variants
36
+ type NodeShape = "rectangle" | "tapered-right";
37
+
38
+ export interface BaseNodeProps extends NodeProps {
39
+ nodeDefinition: NodeDefinition | undefined;
40
+ isStale?: boolean;
41
+ isDeleted?: boolean;
42
+ }
43
+
44
+ // Base Node component - handles all rendering logic
45
+ export const BaseNode = memo(
46
+ ({ id, data, selected, nodeDefinition, isStale = false, isDeleted = false }: BaseNodeProps) => {
47
+ const nodeData = data as NodeData;
48
+ const isHighlighted = selected ?? false;
49
+ // Skip diagnostics when in read-only mode OR when rendered inside VersionPreviewCanvas
50
+ const isPreview = useEditorStore((s) => isReadOnly(s.builderMode)) || !!(data as Record<string, unknown>)?._preview;
51
+
52
+ // Debug cursor: true when this node is the current debug step target
53
+ const isDebugCursor = useDebugStore(
54
+ useCallback(
55
+ (s) => {
56
+ const p = s.phase;
57
+ return (p.status === "paused" || p.status === "stepping") && p.cursorNodeId === id;
58
+ },
59
+ [id],
60
+ ),
61
+ );
62
+
63
+ // Get active canvas ID with imperative access (no subscription), since it doesn't change for a node
64
+ const activeCanvasId = useEditorStore.getState().activeCanvasId;
65
+
66
+ // Get necessary canvas store data
67
+ const canvasStore = getOrCreateCanvasStore(activeCanvasId);
68
+ const edges = canvasStore((s) => s.edges);
69
+ const nodes = canvasStore((s) => s.nodes);
70
+ const channels = useEditorStore((s) => s.channels);
71
+ const memory = useEditorStore((s) => s.memory);
72
+ const models = useEditorStore((s) => s.models);
73
+ const availableModels = useEditorStore((s) => s.availableModels);
74
+
75
+ // Get available variables for resolving expressions
76
+ const { lookup: availableVariables } = useAvailableVariables(activeCanvasId);
77
+
78
+ // Build channel ID → label lookup for formatParamDisplay
79
+ const channelLabels = useMemo(() => {
80
+ const labels: Record<string, string> = {};
81
+ for (const v of Object.values(channels)) labels[v.id] = v.label;
82
+ return labels;
83
+ }, [channels]);
84
+
85
+ // Build memory ID → label lookup for formatParamDisplay (memorySelect params)
86
+ const memoryLabels = useMemo(() => {
87
+ const labels: Record<string, string> = {};
88
+ for (const m of Object.values(memory)) labels[m.id] = m.label;
89
+ return labels;
90
+ }, [memory]);
91
+
92
+ // Build model ID → label lookup (catalog ∪ declared customs) + the catalog id
93
+ // set — used for inline display and modelSelect reference validation.
94
+ const { modelLabels, availableModelIds } = useMemo(() => {
95
+ const labels: Record<string, string> = {};
96
+ const ids = new Set<string>();
97
+ for (const m of availableModels) {
98
+ labels[m.id] = m.label;
99
+ ids.add(m.id);
100
+ }
101
+ for (const m of Object.values(models)) labels[m.id] = m.label;
102
+ return { modelLabels: labels, availableModelIds: ids };
103
+ }, [availableModels, models]);
104
+
105
+ // Get port definitions using centralized dispatcher
106
+ const portDefinitions = getPorts(nodeData);
107
+
108
+ // Separate ports by type for positioning
109
+ const { executionInputs, toolInputs, executionOutputs, toolOutputs } = useMemo(() => {
110
+ return {
111
+ executionInputs: portDefinitions.input.filter((p) => p.type === "control"),
112
+ toolInputs: portDefinitions.input.filter((p) => p.type === "tool"),
113
+ executionOutputs: portDefinitions.output.filter((p) => p.type === "control"),
114
+ toolOutputs: portDefinitions.output.filter((p) => p.type === "tool"),
115
+ };
116
+ }, [portDefinitions]);
117
+
118
+ // Mutual exclusion: tool input vs control ports.
119
+ // Tool OUTPUT is always allowed (not part of exclusion).
120
+ const usedAsToolInput = useMemo(() => {
121
+ return isNodeUsedAsTool(id, nodeData, edges);
122
+ }, [id, nodeData, edges]);
123
+
124
+ const parameters = getArguments(nodeData);
125
+
126
+ // Set of parameter IDs that should be hidden given current context
127
+ const hiddenParamIds = useMemo(() => {
128
+ const ids = new Set<string>();
129
+ for (const p of nodeDefinition?.parameters ?? []) {
130
+ if (!isParameterActive(p, parameters, usedAsToolInput)) ids.add(p.id);
131
+ }
132
+ return ids.size > 0 ? ids : null;
133
+ }, [usedAsToolInput, nodeDefinition, parameters]);
134
+
135
+ const category = nodeDefinition?.category;
136
+
137
+ // Compute diagnostics via extracted pure function
138
+ const diagnostics = useMemo(
139
+ () =>
140
+ computeNodeDiagnostics({
141
+ canvasId: activeCanvasId,
142
+ nodeId: id,
143
+ nodeData,
144
+ nodeDefinition,
145
+ availableVariables,
146
+ channels,
147
+ memory,
148
+ models,
149
+ availableModelIds,
150
+ edges,
151
+ isStale,
152
+ isDeleted,
153
+ }),
154
+ [
155
+ activeCanvasId,
156
+ id,
157
+ nodeData,
158
+ nodeDefinition,
159
+ availableVariables,
160
+ channels,
161
+ memory,
162
+ models,
163
+ availableModelIds,
164
+ edges,
165
+ isStale,
166
+ isDeleted,
167
+ ],
168
+ );
169
+
170
+ // Resolve expressions for display (separate from diagnostics)
171
+ const resolvedExpressions = useMemo(() => {
172
+ const resolved: Record<string, { expr: ResolvedExpr; parseRes: ParseResult }> = {};
173
+ const paramDefs = nodeDefinition?.parameters ?? [];
174
+ for (const param of paramDefs) {
175
+ if (hiddenParamIds?.has(param.id)) continue;
176
+ const value = parameters[param.id];
177
+ if (isExpression(value)) {
178
+ const expr = resolveExpression(value, availableVariables);
179
+ const parseRes = parseExpression(expr);
180
+ resolved[param.id] = { expr, parseRes };
181
+ }
182
+ }
183
+ return resolved;
184
+ }, [parameters, availableVariables, hiddenParamIds, nodeDefinition]);
185
+
186
+ // Derived booleans from diagnostics
187
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
188
+ const hasWarnings = diagnostics.some((d) => d.severity === "warning");
189
+
190
+ // Write diagnostics to store (cleanup on unmount; validateAllCanvases handles full-project)
191
+ const setNodeDiagnostics = useDiagnosticsStore((s) => s.setNodeDiagnostics);
192
+ const clearNodeDiagnostics = useDiagnosticsStore((s) => s.clearNodeDiagnostics);
193
+ useEffect(() => {
194
+ if (isPreview) return;
195
+ setNodeDiagnostics(id, diagnostics);
196
+ return () => clearNodeDiagnostics(id);
197
+ }, [id, diagnostics, setNodeDiagnostics, clearNodeDiagnostics, isPreview]);
198
+
199
+ const usedInControlFlow = useMemo(() => {
200
+ return edges.some((e) => {
201
+ if (e.source === id) {
202
+ return executionOutputs.some((p) => p.id === e.sourceHandle);
203
+ }
204
+ if (e.target === id) {
205
+ return executionInputs.some((p) => p.id === e.targetHandle);
206
+ }
207
+ return false;
208
+ });
209
+ }, [edges, id, executionInputs, executionOutputs]);
210
+
211
+ const IconComponent = category ? categoryIcons[category] : null;
212
+
213
+ // Determine node shape based on category
214
+ const nodeShape: NodeShape = useMemo(() => {
215
+ if (category === "Trigger") return "tapered-right";
216
+ return "rectangle";
217
+ }, [category]);
218
+
219
+ // Get the color variable based on category
220
+ const nodeColor = useMemo(() => {
221
+ if (category === NodeCategory.Trigger) return "--node-trigger";
222
+ if (category === NodeCategory.Tool) return "--node-tool";
223
+ if (category === NodeCategory.AI) return "--node-agent";
224
+ if (category === NodeCategory.Input) return "--node-input";
225
+ if (category === NodeCategory.Output) return "--node-output";
226
+ if (category === NodeCategory.Logic) return "--node-logic";
227
+ if (category === NodeCategory.Data) return "--node-data";
228
+ if (category === NodeCategory.Function) return "--node-function";
229
+ return "--primary";
230
+ }, [category]);
231
+
232
+ // Determine if this node should have highlighted styling (Tool, Trigger, Agent)
233
+ const fancyBg = category === NodeCategory.Tool || category === NodeCategory.Trigger || category === NodeCategory.AI;
234
+
235
+ // Get parameters dynamically from node definition (must be before early return for hooks order)
236
+ // Filter out hidden params based on display conditions
237
+ const nodeParameters = useMemo(() => {
238
+ const params = nodeDefinition?.parameters ?? [];
239
+ if (!hiddenParamIds) return params;
240
+ return params.filter((p) => !hiddenParamIds.has(p.id));
241
+ }, [nodeDefinition, hiddenParamIds]);
242
+
243
+ // Count visible inline params for height calculation (show first 3 params always)
244
+ const visibleParamCount = useMemo(() => {
245
+ const shown = Math.min(nodeParameters.length, 3);
246
+ const hasOverflow = nodeParameters.length > 3 ? 1 : 0;
247
+ return shown + hasOverflow;
248
+ }, [nodeParameters]);
249
+
250
+ // Calculate dimensions
251
+ const maxExecutionPorts = Math.max(executionInputs.length, executionOutputs.length);
252
+ const nodeWidth = 200;
253
+ const taperWidth = 24;
254
+ const paramLineHeight = 16; // ~text-xs line + space-y-0.5 gap
255
+ const headerHeight = 50; // padding + emoji/badge row + margin
256
+ const minHeight = useMemo(() => {
257
+ const paramHeight = visibleParamCount * paramLineHeight;
258
+ const contentHeight = headerHeight + paramHeight;
259
+ if (nodeShape === "tapered-right") {
260
+ return Math.max(contentHeight, 60 + Math.max(maxExecutionPorts, 1) * 40);
261
+ }
262
+ return Math.max(contentHeight, 80 + maxExecutionPorts * 40);
263
+ }, [nodeShape, maxExecutionPorts, visibleParamCount]);
264
+
265
+ if (!nodeDefinition) {
266
+ return (
267
+ <div className="min-w-[200px] p-3 border border-destructive/50 bg-destructive/10 rounded-lg">
268
+ <div className="text-destructive text-sm">Unknown node: {nodeData.type}</div>
269
+ </div>
270
+ );
271
+ }
272
+
273
+ // Calculate vertical port positions for even distribution (left/right)
274
+ const getVerticalPortPosition = (index: number, total: number) => {
275
+ if (total === 1) return minHeight / 2;
276
+ const spacing = (minHeight - 40) / (total + 1);
277
+ return 20 + spacing * (index + 1);
278
+ };
279
+
280
+ // Calculate horizontal port positions for even distribution (top/bottom)
281
+ const getHorizontalPortPosition = (index: number, total: number) => {
282
+ if (total === 1) return nodeWidth / 2;
283
+ const spacing = (nodeWidth - 40) / (total + 1);
284
+ return 20 + spacing * (index + 1);
285
+ };
286
+
287
+ // Render SVG background shape
288
+ const renderShape = () => {
289
+ const gradientId = `node-gradient-${nodeData.id}`;
290
+ const strokeW = 2;
291
+
292
+ // Dark nodes (Tool, Trigger) use dark gradient, others use white/light background
293
+ const gradientStartColor = `hsl(var(--canvas-background))`;
294
+ const gradientEndColor = `color-mix(in srgb, hsl(var(${nodeColor})), hsl(var(--canvas-background)) 80%)`;
295
+ const lightFill = "hsl(var(--card))";
296
+
297
+ // Glow effect for highlighted state (our custom selection, not ReactFlow's)
298
+ // Debug cursor gets an orange pulsing glow (takes priority over selection glow)
299
+ const glowStyle = isDebugCursor
300
+ ? {
301
+ filter: `drop-shadow(0 0 14px hsl(var(--warning) / 0.7)) drop-shadow(0 0 24px hsl(var(--warning) / 0.5))`,
302
+ animation: "debug-cursor-pulse 1.5s ease-in-out infinite",
303
+ }
304
+ : isHighlighted
305
+ ? {
306
+ filter: `drop-shadow(0 0 12px hsl(var(--selection-glow) / 0.6)) drop-shadow(0 0 20px hsl(var(--selection-glow) / 0.4))`,
307
+ }
308
+ : {};
309
+
310
+ if (nodeShape === "tapered-right") {
311
+ const inset = strokeW / 2;
312
+ return (
313
+ <svg
314
+ className="absolute inset-0 z-0 transition-all duration-300 will-change-[filter]"
315
+ width={nodeWidth}
316
+ height={minHeight}
317
+ viewBox={`0 0 ${nodeWidth} ${minHeight}`}
318
+ preserveAspectRatio="none"
319
+ style={glowStyle}
320
+ >
321
+ <defs>
322
+ <linearGradient id={gradientId} x1="0%" y1="70%" x2="100%" y2="0%">
323
+ <stop offset="0%" stopColor={gradientStartColor} />
324
+ <stop offset="100%" stopColor={gradientEndColor} />
325
+ </linearGradient>
326
+ </defs>
327
+ {/* Tapered right edge shape - rightmost edge at nodeWidth */}
328
+ <path
329
+ d={`
330
+ M ${12 + inset} ${0 + inset}
331
+ L ${nodeWidth - taperWidth - inset} ${0 + inset}
332
+ L ${nodeWidth - inset} ${minHeight / 2}
333
+ L ${nodeWidth - taperWidth - inset} ${minHeight - inset}
334
+ L ${12 + inset} ${minHeight - inset}
335
+ Q ${0 + inset} ${minHeight - inset} ${0 + inset} ${minHeight - 12 - inset}
336
+ L ${0 + inset} ${12 + inset}
337
+ Q ${0 + inset} ${0 + inset} ${12 + inset} ${0 + inset}
338
+ Z
339
+ `}
340
+ fill={`url(#${gradientId})`}
341
+ stroke={
342
+ hasErrors
343
+ ? "hsl(var(--destructive))"
344
+ : fancyBg
345
+ ? `hsl(var(${nodeColor})/0.5)`
346
+ : "hsl(var(--edge-default))"
347
+ }
348
+ strokeWidth={strokeW}
349
+ />
350
+ </svg>
351
+ );
352
+ }
353
+
354
+ // Rectangle shape (default) - use light or dark based on category
355
+ return (
356
+ <svg
357
+ className="absolute inset-0 z-0 transition-all duration-300 will-change-[filter]"
358
+ width={nodeWidth}
359
+ height={minHeight}
360
+ viewBox={`0 0 ${nodeWidth} ${minHeight}`}
361
+ preserveAspectRatio="none"
362
+ style={glowStyle}
363
+ >
364
+ {fancyBg && (
365
+ <defs>
366
+ <linearGradient id={gradientId} x1="0%" y1="70%" x2="100%" y2="0%">
367
+ <stop offset="0%" stopColor={gradientStartColor} />
368
+ <stop offset="120%" stopColor={gradientEndColor} />
369
+ </linearGradient>
370
+ </defs>
371
+ )}
372
+ <rect
373
+ x={strokeW / 2}
374
+ y={strokeW / 2}
375
+ width={nodeWidth - strokeW}
376
+ height={minHeight - strokeW}
377
+ rx="10"
378
+ ry="10"
379
+ fill={fancyBg ? `url(#${gradientId})` : lightFill}
380
+ stroke={
381
+ hasErrors
382
+ ? "hsl(var(--destructive))"
383
+ : fancyBg
384
+ ? `hsl(var(${nodeColor})/0.5)`
385
+ : "hsl(var(--edge-default))"
386
+ }
387
+ strokeWidth={strokeW}
388
+ />
389
+ </svg>
390
+ );
391
+ };
392
+
393
+ return (
394
+ <div className="relative z-0" style={{ height: `${minHeight}px`, width: `${nodeWidth}px` }}>
395
+ {/* Tool input handles (top) — disabled when control ports are connected */}
396
+ {toolInputs.map((port, index) => (
397
+ <PortHandle
398
+ key={port.id}
399
+ id={port.id}
400
+ type="target"
401
+ position={Position.Top}
402
+ portType={port.type}
403
+ label={port.label}
404
+ disabled={usedInControlFlow}
405
+ style={{
406
+ left: `${getHorizontalPortPosition(index, toolInputs.length)}px`,
407
+ }}
408
+ />
409
+ ))}
410
+
411
+ {/* Execution input handles (left) — disabled when tool input is connected */}
412
+ {executionInputs.map((port, index) => (
413
+ <PortHandle
414
+ key={port.id}
415
+ id={port.id}
416
+ type="target"
417
+ position={Position.Left}
418
+ portType={port.type}
419
+ label={port.label}
420
+ disabled={usedAsToolInput}
421
+ style={{
422
+ top:
423
+ nodeShape === "tapered-right"
424
+ ? `${minHeight / 2}px`
425
+ : `${getVerticalPortPosition(index, executionInputs.length)}px`,
426
+ }}
427
+ />
428
+ ))}
429
+
430
+ {/* SVG Shape */}
431
+ {renderShape()}
432
+
433
+ {/* Error badge */}
434
+ {hasErrors && (
435
+ <Tooltip delayDuration={300}>
436
+ <TooltipTrigger asChild>
437
+ <div className="absolute -top-4 -left-4 z-30 cursor-help">
438
+ <AlertTriangle className="h-8 w-8 text-destructive fill-card" strokeWidth={2} />
439
+ </div>
440
+ </TooltipTrigger>
441
+ <TooltipContent side="top" className="bg-destructive text-destructive-foreground text-xs px-2 py-1">
442
+ {diagnostics.find((d) => d.severity === "error")?.message ?? "This node has errors"}
443
+ </TooltipContent>
444
+ </Tooltip>
445
+ )}
446
+
447
+ {/* Warning badge (only when no errors) */}
448
+ {!hasErrors && hasWarnings && (
449
+ <Tooltip delayDuration={300}>
450
+ <TooltipTrigger asChild>
451
+ <div className="absolute -top-4 -left-4 z-30 cursor-help">
452
+ <AlertCircle className="h-8 w-8 text-warning fill-card" strokeWidth={2} />
453
+ </div>
454
+ </TooltipTrigger>
455
+ <TooltipContent side="top" className="bg-warning text-warning-foreground text-xs px-2 py-1">
456
+ {diagnostics.find((d) => d.severity === "warning")?.message ?? "This node has warnings"}
457
+ </TooltipContent>
458
+ </Tooltip>
459
+ )}
460
+
461
+ {/* Content overlay */}
462
+ {/* Content overlay — top-aligned */}
463
+ <div className={`relative z-10 p-3 ${nodeShape === "tapered-right" ? "pr-8" : ""}`}>
464
+ {/* Header row: emoji + label badge */}
465
+ <div className="flex items-center gap-1.5 min-w-0 mb-1.5">
466
+ {IconComponent && (
467
+ <IconComponent className="w-4 h-4 shrink-0" style={{ color: `hsl(var(${nodeColor}))` }} />
468
+ )}
469
+ <Badge
470
+ variant="secondary"
471
+ className="text-sm truncate max-w-full text-foreground"
472
+ style={{
473
+ backgroundColor: `hsl(var(${nodeColor}) / 0.3)`,
474
+ borderColor: `hsl(var(${nodeColor}) / 0.4)`,
475
+ }}
476
+ >
477
+ {(nodeData as NodeBase).label || nodeDefinition.label}
478
+ </Badge>
479
+ </div>
480
+
481
+ {/* Parameters list */}
482
+ {nodeParameters.length > 0 && (
483
+ <div className="space-y-0.5">
484
+ {nodeParameters.slice(0, 3).map((param) => {
485
+ const value = parameters[param.id];
486
+ const resolved = resolvedExpressions[param.id];
487
+
488
+ const paramDisplay = resolved
489
+ ? null
490
+ : formatParamDisplay(param, value, availableVariables, channelLabels, memoryLabels, modelLabels);
491
+ const isInvalid = resolved ? !resolved.parseRes.isValid : !!paramDisplay?.isInvalid;
492
+
493
+ return (
494
+ <div key={param.id} className="text-xs leading-4 flex items-baseline gap-1 truncate">
495
+ <span className="shrink-0 text-muted-foreground">{param.label}:</span>
496
+ <span className={`font-mono truncate ${isInvalid ? "text-destructive" : "text-foreground"}`}>
497
+ {resolved ? displayValue(resolved.expr) : paramDisplay!.text}
498
+ </span>
499
+ </div>
500
+ );
501
+ })}
502
+ {nodeParameters.length > 3 && (
503
+ <div className="text-xs text-muted-foreground">+{nodeParameters.length - 3} more...</div>
504
+ )}
505
+ </div>
506
+ )}
507
+ </div>
508
+
509
+ {/* Execution output handles (right) — disabled when tool input is connected */}
510
+ {executionOutputs.map((port, index) => {
511
+ const canAccept = !isPreview && !usedAsToolInput && canPortAcceptEdge(id, port.id, nodes, edges);
512
+ return (
513
+ <PortHandle
514
+ key={port.id}
515
+ id={port.id}
516
+ type="source"
517
+ position={Position.Right}
518
+ portType={port.type}
519
+ label={port.label}
520
+ disabled={usedAsToolInput}
521
+ showPlus={canAccept}
522
+ nodeId={id}
523
+ style={{
524
+ top:
525
+ nodeShape === "tapered-right"
526
+ ? `${minHeight / 2}px`
527
+ : `${getVerticalPortPosition(index, executionOutputs.length)}px`,
528
+ right: "0",
529
+ }}
530
+ />
531
+ );
532
+ })}
533
+
534
+ {/* Tool output handles (bottom) */}
535
+ {toolOutputs.map((port, index) => {
536
+ const canAccept = !isPreview && canPortAcceptEdge(id, port.id, nodes, edges);
537
+ return (
538
+ <PortHandle
539
+ key={port.id}
540
+ id={port.id}
541
+ type="source"
542
+ position={Position.Bottom}
543
+ portType={port.type}
544
+ label={port.label}
545
+ showPlus={canAccept}
546
+ nodeId={id}
547
+ style={{
548
+ left: `${getHorizontalPortPosition(index, toolOutputs.length)}px`,
549
+ bottom: 0,
550
+ }}
551
+ />
552
+ );
553
+ })}
554
+ </div>
555
+ );
556
+ },
557
+ );