@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.
- package/LICENSE +661 -661
- package/NOTICE +16 -16
- package/README.md +110 -93
- package/dist/components/ui/command.d.ts +2 -2
- package/dist/components/ui/input.d.ts +1 -1
- package/dist/components/ui/resizable.d.ts +1 -1
- package/dist/components/ui/textarea.d.ts +1 -1
- package/dist/graph/BaseNode.js +10 -10
- package/dist/graph/reactFlowRegistry.d.ts.map +1 -1
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/toolbars/CanvasTabsToolbar.d.ts +11 -0
- package/dist/toolbars/CanvasTabsToolbar.d.ts.map +1 -0
- package/dist/toolbars/CanvasTabsToolbar.js +101 -0
- package/dist/toolbars/CanvasTabsToolbar.js.map +1 -0
- package/package.json +2 -2
- package/src/BuilderLayout.tsx +345 -345
- package/src/Canvas.tsx +261 -261
- package/src/CanvasEditor.tsx +142 -142
- package/src/CanvasTabsToolbar.tsx +176 -176
- package/src/RightConfigPanel.tsx +266 -266
- package/src/WorkflowBuilder.tsx +412 -412
- package/src/cn.ts +6 -6
- package/src/components/ui/add-button.tsx +39 -39
- package/src/components/ui/alert-dialog.tsx +141 -141
- package/src/components/ui/alert.tsx +59 -59
- package/src/components/ui/badge.tsx +36 -36
- package/src/components/ui/button.tsx +85 -85
- package/src/components/ui/card.tsx +79 -79
- package/src/components/ui/checkbox.tsx +28 -28
- package/src/components/ui/collapsible.tsx +9 -9
- package/src/components/ui/command.tsx +153 -153
- package/src/components/ui/delete-button.tsx +23 -23
- package/src/components/ui/dialog.tsx +125 -125
- package/src/components/ui/dropdown-menu.tsx +198 -198
- package/src/components/ui/input.tsx +55 -55
- package/src/components/ui/label.tsx +24 -24
- package/src/components/ui/readonly-banner.tsx +15 -15
- package/src/components/ui/resizable.tsx +43 -43
- package/src/components/ui/scroll-area.tsx +102 -102
- package/src/components/ui/select.tsx +160 -160
- package/src/components/ui/separator.tsx +29 -29
- package/src/components/ui/switch.tsx +27 -27
- package/src/components/ui/textarea.tsx +51 -51
- package/src/components/ui/toast.tsx +127 -127
- package/src/components/ui/toaster.tsx +33 -33
- package/src/components/ui/toggle-group.tsx +59 -59
- package/src/components/ui/toggle.tsx +43 -43
- package/src/components/ui/tooltip.tsx +32 -32
- package/src/dialogs/NodePickerDialog.tsx +84 -84
- package/src/dialogs/ValidationDialog.tsx +184 -184
- package/src/graph/BaseNode.tsx +557 -557
- package/src/graph/CustomEdge.tsx +185 -185
- package/src/graph/CustomNode.tsx +16 -16
- package/src/graph/FunctionCallNode.tsx +30 -30
- package/src/graph/PortHandle.tsx +189 -189
- package/src/graph/reactFlowRegistry.ts +26 -26
- package/src/hooks/use-toast.ts +125 -125
- package/src/hooks/useAvailableVariables.ts +20 -20
- package/src/hooks/useCanvasHistory.ts +22 -22
- package/src/hooks/useCanvasTabs.ts +168 -168
- package/src/hooks/useFunctionDiagnosticsSync.ts +40 -40
- package/src/hooks/useFunctionRegistry.ts +26 -26
- package/src/hooks/useFunctions.ts +44 -44
- package/src/hooks/useGraph.ts +161 -161
- package/src/hooks/useNodeDefinitions.ts +82 -82
- package/src/hooks/useParamErrors.ts +26 -26
- package/src/hooks/useResolvedTheme.ts +30 -30
- package/src/hooks/useResourceDiagnosticsSync.ts +58 -58
- package/src/hooks/useSuppressThemeTransition.ts +79 -79
- package/src/hooks/useWorkflowSerialization.ts +127 -127
- package/src/i18n/index.ts +53 -53
- package/src/i18n/locales/de.json +501 -501
- package/src/i18n/locales/en.json +557 -557
- package/src/index.ts +27 -27
- package/src/inputs/ExpressionInput.tsx +297 -297
- package/src/inputs/ParameterEditor.tsx +515 -515
- package/src/inputs/PortSection.tsx +144 -144
- package/src/panels/BuilderSidebar.tsx +301 -301
- package/src/panels/ChannelConfigPanel.tsx +49 -49
- package/src/panels/ChannelsPanel.tsx +28 -28
- package/src/panels/DebugConsolePanel.tsx +73 -73
- package/src/panels/DebugContextPanel.tsx +77 -77
- package/src/panels/DebugExternalIOPanel.tsx +180 -180
- package/src/panels/DiagnosticsPanel.tsx +170 -170
- package/src/panels/EdgeConfigPanel.tsx +104 -104
- package/src/panels/FunctionConfigPanel.tsx +179 -179
- package/src/panels/FunctionListPanel.tsx +45 -45
- package/src/panels/MemoryConfigPanel.tsx +55 -55
- package/src/panels/MemoryPanel.tsx +40 -40
- package/src/panels/ModelConfigPanel.tsx +41 -41
- package/src/panels/ModelsPanel.tsx +36 -36
- package/src/panels/NodeConfigPanel.tsx +630 -630
- package/src/panels/NodeLibrary.tsx +288 -288
- package/src/panels/ResourceConfigPanel.tsx +132 -132
- package/src/panels/ResourceListPanel.tsx +113 -113
- package/src/panels/VariableConfigPanel.tsx +161 -161
- package/src/panels/VariablesPanel.tsx +145 -145
- package/src/stores/canvasStore.test.ts +44 -44
- package/src/stores/canvasStore.ts +245 -245
- package/src/stores/debugStore.ts +74 -74
- package/src/stores/diagnosticsStore.ts +130 -130
- package/src/stores/editorStore.ts +202 -202
- package/src/styles/index.css +526 -526
- package/src/utils/categoryConstants.ts +26 -26
- package/src/utils/channelOperations.ts +86 -86
- package/src/utils/connectionRules.ts +137 -137
- package/src/utils/functionOperations.ts +179 -179
- package/src/utils/graphOperations.ts +550 -550
- package/src/utils/history.ts +207 -207
- package/src/utils/memoryOperations.ts +57 -57
- package/src/utils/migrateFunctionNodes.ts +107 -107
- package/src/utils/modelOperations.ts +55 -55
- package/src/utils/paramDisplay.ts +71 -71
- package/src/utils/resourceHelpers.ts +32 -32
- package/src/utils/translation.ts +28 -28
- package/src/utils/variableOperations.ts +75 -75
- package/tailwind-preset.ts +166 -166
|
@@ -1,550 +1,550 @@
|
|
|
1
|
-
import {
|
|
2
|
-
NodeBase,
|
|
3
|
-
NodeCategory,
|
|
4
|
-
NodeDefinition,
|
|
5
|
-
NodeData,
|
|
6
|
-
NodeOutput,
|
|
7
|
-
NodeRegistry,
|
|
8
|
-
NodeType,
|
|
9
|
-
OutputBinding,
|
|
10
|
-
FunctionCallNode,
|
|
11
|
-
FunctionNodeDefinition,
|
|
12
|
-
getArguments,
|
|
13
|
-
getNodeOutput,
|
|
14
|
-
} from "@foresthubai/workflow-core/node";
|
|
15
|
-
import type { Expression } from "@foresthubai/workflow-core";
|
|
16
|
-
import type { FunctionInfo } from "@foresthubai/workflow-core/function";
|
|
17
|
-
import type { OutputDeclaration } from "@foresthubai/workflow-core/parameter";
|
|
18
|
-
import { addEdge, Connection, Edge, Node } from "@xyflow/react";
|
|
19
|
-
import type { EdgeData, EdgeType } from "@foresthubai/workflow-core/edge";
|
|
20
|
-
import { CanvasStore } from "../stores/canvasStore";
|
|
21
|
-
import { computeVariablesFromNodes } from "@foresthubai/workflow-core/workflow";
|
|
22
|
-
import { nodeOutputVarKey, paramKey } from "@foresthubai/workflow-core/variable";
|
|
23
|
-
import { isExpression } from "@foresthubai/workflow-core/expression";
|
|
24
|
-
import { isValidConnection } from "./connectionRules";
|
|
25
|
-
import { generateId } from "@foresthubai/workflow-core/id";
|
|
26
|
-
import { uniqueName } from "./resourceHelpers";
|
|
27
|
-
|
|
28
|
-
// ============================================================================
|
|
29
|
-
// Output Name Deduplication
|
|
30
|
-
// ============================================================================
|
|
31
|
-
|
|
32
|
-
/** Collect all output variable names currently in the store. */
|
|
33
|
-
function collectVariableNames(store: CanvasStore): Set<string> {
|
|
34
|
-
const vars = store.getState().variables;
|
|
35
|
-
return new Set(Object.values(vars).map((v) => v.name));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Walk a node instance and rename any emit bindings/declarations whose name collides
|
|
40
|
-
* with `existingNames`. Mutates in place. Handles all three locations:
|
|
41
|
-
* - Static outputs, incl. FunctionCall returns (direct field on arguments — OutputBinding)
|
|
42
|
-
* - List output entries (each entry IS an OutputDeclaration — mutate its `name` field directly
|
|
43
|
-
* when mode === "emit". Assign-mode entries have no name to rename, so they're skipped.)
|
|
44
|
-
* `existingNames` is updated with each rename so subsequent calls see the new names.
|
|
45
|
-
*/
|
|
46
|
-
function deduplicateEmitNames(node: NodeData, existingNames: Set<string>): void {
|
|
47
|
-
const args = node.arguments as Record<string, unknown>;
|
|
48
|
-
|
|
49
|
-
const renameBindingAt = (key: string): void => {
|
|
50
|
-
const binding = args[key] as OutputBinding | undefined;
|
|
51
|
-
// Only active emit bindings produce a variable that could collide.
|
|
52
|
-
if (!binding || !binding.active || binding.mode !== "emit" || !existingNames.has(binding.name)) return;
|
|
53
|
-
const deduped = uniqueName(binding.name, existingNames);
|
|
54
|
-
existingNames.add(deduped);
|
|
55
|
-
args[key] = { active: true, mode: "emit", name: deduped };
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
if (node.type === "FunctionCall") {
|
|
59
|
-
for (const ret of node.functionInfo.returns) {
|
|
60
|
-
renameBindingAt(paramKey(ret));
|
|
61
|
-
}
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const def = NodeRegistry.getByType(node.type);
|
|
66
|
-
if (!def?.outputs) return;
|
|
67
|
-
|
|
68
|
-
for (const out of def.outputs) {
|
|
69
|
-
if (out.type === "static") {
|
|
70
|
-
renameBindingAt(out.id);
|
|
71
|
-
} else {
|
|
72
|
-
const entries = (args[out.id] as OutputDeclaration[] | undefined) ?? [];
|
|
73
|
-
for (const entry of entries) {
|
|
74
|
-
if (entry.mode !== "emit") continue;
|
|
75
|
-
if (!existingNames.has(entry.name)) continue;
|
|
76
|
-
const deduped = uniqueName(entry.name, existingNames);
|
|
77
|
-
existingNames.add(deduped);
|
|
78
|
-
entry.name = deduped;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ============================================================================
|
|
85
|
-
// Pure Store Operations
|
|
86
|
-
// ============================================================================
|
|
87
|
-
|
|
88
|
-
/** Check if a node definition can be added to the store (respects isUnremovable and isSingleton). */
|
|
89
|
-
export function canAddNode(store: CanvasStore, nodeDef: NodeDefinition): boolean {
|
|
90
|
-
if (nodeDef.isUnremovable) return false;
|
|
91
|
-
if (nodeDef.isSingleton) {
|
|
92
|
-
const { nodes } = store.getState();
|
|
93
|
-
if (nodes.some((n) => n.data.type === nodeDef.type)) return false;
|
|
94
|
-
}
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Add a node to the store given a node definition and optional position. Returns the new node ID, or null if the node cannot be added. */
|
|
99
|
-
export function addNodeToStore(store: CanvasStore, nodeDef: NodeDefinition, position?: { x: number; y: number }): string | null {
|
|
100
|
-
if (!canAddNode(store, nodeDef)) return null;
|
|
101
|
-
|
|
102
|
-
const nodeId = generateId();
|
|
103
|
-
|
|
104
|
-
// Initialize parameters with default values. Clone so object/array defaults
|
|
105
|
-
// (Expression, weekdays `[]`, memory-refs `[]`) aren't shared by reference
|
|
106
|
-
// across instances or with the definition itself — a shared mutable default
|
|
107
|
-
// would alias on edit.
|
|
108
|
-
const args: Record<string, unknown> = {};
|
|
109
|
-
nodeDef.parameters.forEach((param) => {
|
|
110
|
-
if (param.default !== undefined) {
|
|
111
|
-
args[param.id] = structuredClone(param.default);
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// Seed outputs directly into args:
|
|
116
|
-
// - Static outputs: `args[out.id] = { active: true, mode: "emit", name: out.id }` (OutputBinding)
|
|
117
|
-
// - List outputs: `args[out.id] = []` (empty OutputDeclaration[] — user adds entries)
|
|
118
|
-
for (const out of nodeDef.outputs ?? []) {
|
|
119
|
-
if (out.type === "static") {
|
|
120
|
-
args[out.id] = { active: true, mode: "emit", name: out.id };
|
|
121
|
-
} else {
|
|
122
|
-
args[out.id] = [];
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const nodeData: NodeBase = {
|
|
127
|
-
id: nodeId,
|
|
128
|
-
type: nodeDef.type,
|
|
129
|
-
arguments: args,
|
|
130
|
-
};
|
|
131
|
-
if (nodeDef.type === "FunctionCall") {
|
|
132
|
-
const functionDef = nodeDef as FunctionNodeDefinition;
|
|
133
|
-
const functionNode = nodeData as FunctionCallNode;
|
|
134
|
-
functionNode.functionInfo = functionDef.functionInfo;
|
|
135
|
-
// Seed input bindings keyed by arg uid — empty expressions with the declared dataType.
|
|
136
|
-
// Output bindings were already seeded above by the generic outputs[] loop since
|
|
137
|
-
// buildFunctionNodeDef now emits real StaticOutput entries.
|
|
138
|
-
for (const arg of functionDef.functionInfo.arguments) {
|
|
139
|
-
args[paramKey(arg)] = { expression: "", references: [], dataType: arg.dataType };
|
|
140
|
-
}
|
|
141
|
-
// Generic seeding uses the output id as the default emit name, but for function
|
|
142
|
-
// returns we want the return's variable name.
|
|
143
|
-
for (const ret of functionDef.functionInfo.returns) {
|
|
144
|
-
args[paramKey(ret)] = { active: true, mode: "emit" as const, name: ret.name };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Deduplicate emit binding names against existing variables on the canvas
|
|
149
|
-
const existingNames = collectVariableNames(store);
|
|
150
|
-
deduplicateEmitNames(nodeData as NodeData, existingNames);
|
|
151
|
-
|
|
152
|
-
// Nudge position if another node is already nearby (avoids stacking on click-to-add)
|
|
153
|
-
const currentNodes = store.getState().nodes;
|
|
154
|
-
let pos = position || { x: 250, y: 100 };
|
|
155
|
-
const SNAP_DISTANCE = 50;
|
|
156
|
-
while (currentNodes.some((n) => Math.abs(n.position.x - pos.x) < SNAP_DISTANCE && Math.abs(n.position.y - pos.y) < SNAP_DISTANCE)) {
|
|
157
|
-
pos = { x: pos.x + 40, y: pos.y + 40 };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const newNode: Node<NodeData> = {
|
|
161
|
-
id: nodeData.id,
|
|
162
|
-
type: getReactFlowType(nodeDef.type),
|
|
163
|
-
position: pos,
|
|
164
|
-
data: nodeData as NodeData,
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const { setNodes, setVariables } = store.getState();
|
|
168
|
-
setNodes((nds) => [...nds, newNode]);
|
|
169
|
-
|
|
170
|
-
// Add new node's output variables to store (computed via getNodeOutput)
|
|
171
|
-
setVariables((vars) => ({ ...vars, ...computeVariablesFromNodes([newNode.data]) }));
|
|
172
|
-
|
|
173
|
-
return nodeId;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Update a node's data in the store. Returns true if the node was found and updated.
|
|
178
|
-
*
|
|
179
|
-
* FunctionCallNode is no longer a special case — its arguments are flat, so the same
|
|
180
|
-
* shallow-merge path handles input edits, output binding edits, and migrations uniformly.
|
|
181
|
-
* Migration callers simply pass the full replacement arguments record alongside
|
|
182
|
-
* `functionInfo`; normal edits pass the single changed key.
|
|
183
|
-
*/
|
|
184
|
-
export function updateNodeInStore(
|
|
185
|
-
store: CanvasStore,
|
|
186
|
-
nodeId: string,
|
|
187
|
-
updates: {
|
|
188
|
-
arguments?: Record<string, unknown>;
|
|
189
|
-
label?: string;
|
|
190
|
-
functionInfo?: FunctionInfo;
|
|
191
|
-
},
|
|
192
|
-
): boolean {
|
|
193
|
-
const { setNodes, setVariables } = store.getState();
|
|
194
|
-
|
|
195
|
-
// Track output changes to update variables store
|
|
196
|
-
let oldOutputs: Record<string, NodeOutput> = {};
|
|
197
|
-
let newOutputs: Record<string, NodeOutput> = {};
|
|
198
|
-
let outputsChanged = false;
|
|
199
|
-
let found = false;
|
|
200
|
-
|
|
201
|
-
setNodes((nds) => {
|
|
202
|
-
const targetNodeIndex = nds.findIndex((node) => node.id === nodeId);
|
|
203
|
-
if (targetNodeIndex === -1) return nds;
|
|
204
|
-
|
|
205
|
-
const targetNode = nds[targetNodeIndex];
|
|
206
|
-
if (!targetNode) return nds;
|
|
207
|
-
found = true;
|
|
208
|
-
const currentData = targetNode.data as NodeData;
|
|
209
|
-
oldOutputs = getNodeOutput(currentData);
|
|
210
|
-
|
|
211
|
-
// When functionInfo changes (FunctionCall signature update), arguments are a
|
|
212
|
-
// full replacement so stale keys for removed args/returns get dropped;
|
|
213
|
-
// otherwise shallow-merge.
|
|
214
|
-
let updatedNodeData: NodeData;
|
|
215
|
-
|
|
216
|
-
if (updates.arguments) {
|
|
217
|
-
const mergedArgs = updates.functionInfo
|
|
218
|
-
? updates.arguments
|
|
219
|
-
: { ...(currentData.arguments as Record<string, unknown>), ...updates.arguments };
|
|
220
|
-
updatedNodeData = {
|
|
221
|
-
...(currentData as Record<string, unknown>),
|
|
222
|
-
...updates,
|
|
223
|
-
arguments: mergedArgs,
|
|
224
|
-
} as NodeData;
|
|
225
|
-
} else {
|
|
226
|
-
updatedNodeData = { ...(currentData as Record<string, unknown>), ...updates } as NodeData;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
newOutputs = getNodeOutput(updatedNodeData);
|
|
230
|
-
|
|
231
|
-
// Build the updated nodes array
|
|
232
|
-
const updatedNodes = nds.map((node, index) => {
|
|
233
|
-
if (index === targetNodeIndex) {
|
|
234
|
-
return { ...node, data: updatedNodeData };
|
|
235
|
-
}
|
|
236
|
-
return node;
|
|
237
|
-
}) as Node<NodeData>[];
|
|
238
|
-
|
|
239
|
-
// Check if any output variable changed (keys added/removed or values changed)
|
|
240
|
-
const oldKeys = Object.keys(oldOutputs);
|
|
241
|
-
const newKeys = Object.keys(newOutputs);
|
|
242
|
-
outputsChanged =
|
|
243
|
-
oldKeys.length !== newKeys.length ||
|
|
244
|
-
newKeys.some((key) => {
|
|
245
|
-
const oldVar = oldOutputs[key];
|
|
246
|
-
const newVar = newOutputs[key];
|
|
247
|
-
return !oldVar || !newVar || oldVar.name !== newVar.name || oldVar.dataType !== newVar.dataType;
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
return updatedNodes;
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
// Update variables store if outputs changed
|
|
254
|
-
if (outputsChanged) {
|
|
255
|
-
setVariables((vars) => {
|
|
256
|
-
const updated = { ...vars };
|
|
257
|
-
// Remove old outputs that no longer exist in new outputs
|
|
258
|
-
for (const outputId of Object.keys(oldOutputs)) {
|
|
259
|
-
if (!(outputId in newOutputs)) {
|
|
260
|
-
delete updated[nodeOutputVarKey(nodeId, outputId)];
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
// Add/update new outputs (only if actually changed)
|
|
264
|
-
for (const [outputId, variable] of Object.entries(newOutputs)) {
|
|
265
|
-
const key = nodeOutputVarKey(nodeId, outputId);
|
|
266
|
-
const oldVar = vars[key];
|
|
267
|
-
if (!oldVar || oldVar.name !== variable.name || oldVar.dataType !== variable.dataType) {
|
|
268
|
-
updated[key] = { kind: "node", nodeId, outputId, name: variable.name, dataType: variable.dataType };
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
return updated;
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return found;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Delete a node from the store, removing connected edges and variables.
|
|
280
|
-
* Takes optional getNodeDefinition callback for isUnremovable check.
|
|
281
|
-
* Returns true if the node was deleted.
|
|
282
|
-
*/
|
|
283
|
-
export function deleteNodeFromStore(
|
|
284
|
-
store: CanvasStore,
|
|
285
|
-
nodeId: string,
|
|
286
|
-
getNodeDefinition?: (node: NodeData) => NodeDefinition | undefined,
|
|
287
|
-
): boolean {
|
|
288
|
-
const { nodes, edges, setNodes, setEdges, setVariables } = store.getState();
|
|
289
|
-
|
|
290
|
-
// Check if node can be deleted
|
|
291
|
-
const nodeToDelete = nodes.find((node) => node.id === nodeId);
|
|
292
|
-
if (!nodeToDelete) return false;
|
|
293
|
-
|
|
294
|
-
if (getNodeDefinition) {
|
|
295
|
-
const nodeDef = getNodeDefinition(nodeToDelete.data);
|
|
296
|
-
if (nodeDef?.isUnremovable ?? false) return false;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
|
|
300
|
-
setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
|
|
301
|
-
|
|
302
|
-
// Remove deleted node's emitted variables from store
|
|
303
|
-
const outputKeys = Object.keys(getNodeOutput(nodeToDelete.data));
|
|
304
|
-
if (outputKeys.length > 0) {
|
|
305
|
-
setVariables((vars) => {
|
|
306
|
-
const updated = { ...vars };
|
|
307
|
-
for (const outputId of outputKeys) {
|
|
308
|
-
delete updated[nodeOutputVarKey(nodeId, outputId)];
|
|
309
|
-
}
|
|
310
|
-
return updated;
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
return true;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Determine the specific edge type based on base port type and connected nodes.
|
|
319
|
-
* Agent nodes get specialized control-edge types (agentTask, agentChoice, agentDelegate).
|
|
320
|
-
* Tool edges have no agent-specific variant — every tool edge points at an agent.
|
|
321
|
-
*/
|
|
322
|
-
function resolveEdgeType(basePortType: EdgeType, sourceNode: Node<NodeData>, targetNode: Node<NodeData>): EdgeType {
|
|
323
|
-
const sourceIsAgent = sourceNode.data.type === "Agent";
|
|
324
|
-
const targetIsAgent = targetNode.data.type === "Agent";
|
|
325
|
-
|
|
326
|
-
if (basePortType === "control") {
|
|
327
|
-
if (sourceIsAgent && targetIsAgent) return "agentDelegate";
|
|
328
|
-
if (targetIsAgent) return "agentTask";
|
|
329
|
-
if (sourceIsAgent) return "agentChoice";
|
|
330
|
-
return "control";
|
|
331
|
-
}
|
|
332
|
-
return basePortType;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Add an edge if the connection is valid. Returns true if the edge was added. */
|
|
336
|
-
export function connectNodesInStore(store: CanvasStore, connection: Connection): EdgeType | false {
|
|
337
|
-
const { nodes, edges, setEdges } = store.getState();
|
|
338
|
-
|
|
339
|
-
const portType = isValidConnection(connection.source, connection.target, connection.sourceHandle, connection.targetHandle, nodes, edges);
|
|
340
|
-
if (!portType) return false;
|
|
341
|
-
|
|
342
|
-
const sourceNode = nodes.find((n) => n.id === connection.source);
|
|
343
|
-
const targetNode = nodes.find((n) => n.id === connection.target);
|
|
344
|
-
if (!sourceNode || !targetNode) return false;
|
|
345
|
-
|
|
346
|
-
const edgeType = resolveEdgeType(portType, sourceNode, targetNode);
|
|
347
|
-
setEdges((eds) => addEdge({ ...connection, type: edgeType }, eds) as typeof eds);
|
|
348
|
-
return edgeType;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** Update an edge's data in the store. Returns true if the edge was found and updated. */
|
|
352
|
-
export function updateEdgeInStore(store: CanvasStore, edgeId: string, updates: Record<string, unknown>): boolean {
|
|
353
|
-
const { edges, setEdges } = store.getState();
|
|
354
|
-
const edge = edges.find((e) => e.id === edgeId);
|
|
355
|
-
if (!edge) return false;
|
|
356
|
-
|
|
357
|
-
setEdges((eds) => eds.map((e) => (e.id === edgeId ? { ...e, data: { ...e.data, ...updates } as EdgeData } : e)));
|
|
358
|
-
return true;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Delete edges by ID from the store. */
|
|
362
|
-
export function deleteEdgesFromStore(store: CanvasStore, edgeIds: string[]): void {
|
|
363
|
-
const { setEdges } = store.getState();
|
|
364
|
-
const idSet = new Set(edgeIds);
|
|
365
|
-
setEdges((eds) => eds.filter((e) => !idSet.has(e.id)));
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// ============================================================================
|
|
369
|
-
// Clipboard
|
|
370
|
-
// ============================================================================
|
|
371
|
-
|
|
372
|
-
export interface Clipboard {
|
|
373
|
-
nodes: Node<NodeData>[];
|
|
374
|
-
edges: Edge<EdgeData>[];
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
export interface PasteResult {
|
|
378
|
-
pasted: boolean;
|
|
379
|
-
/** Labels of nodes that were skipped because they cannot be added (singleton already present or unremovable). */
|
|
380
|
-
skippedLabels: string[];
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** Paste clipboard contents into the store with new IDs and optional position offset. */
|
|
384
|
-
export function pasteToStore(
|
|
385
|
-
store: CanvasStore,
|
|
386
|
-
clipboard: Clipboard,
|
|
387
|
-
offset: { x: number; y: number } = { x: 50, y: 50 },
|
|
388
|
-
getNodeDefinition?: (node: NodeData) => NodeDefinition | undefined,
|
|
389
|
-
): PasteResult {
|
|
390
|
-
const empty: PasteResult = { pasted: false, skippedLabels: [] };
|
|
391
|
-
if (clipboard.nodes.length === 0) return empty;
|
|
392
|
-
|
|
393
|
-
const { setNodes, setEdges, setVariables } = store.getState();
|
|
394
|
-
|
|
395
|
-
// Filter out nodes that cannot be pasted (unremovable or singleton already on canvas)
|
|
396
|
-
const skippedLabels: string[] = [];
|
|
397
|
-
const pastableNodes = clipboard.nodes.filter((node) => {
|
|
398
|
-
if (!getNodeDefinition) return true;
|
|
399
|
-
const def = getNodeDefinition(node.data);
|
|
400
|
-
if (!def) return true;
|
|
401
|
-
if (!canAddNode(store, def)) {
|
|
402
|
-
skippedLabels.push(def.label);
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
405
|
-
return true;
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
if (pastableNodes.length === 0) return { pasted: false, skippedLabels };
|
|
409
|
-
|
|
410
|
-
// Build old ID -> new ID mapping (only for pastable nodes)
|
|
411
|
-
const idMap = new Map<string, string>();
|
|
412
|
-
pastableNodes.forEach((node) => {
|
|
413
|
-
const newId = generateId();
|
|
414
|
-
idMap.set(node.id, newId);
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
// Create new nodes with updated IDs and positions
|
|
418
|
-
const newNodes: Node<NodeData>[] = pastableNodes.map((node) => {
|
|
419
|
-
const newId = idMap.get(node.id)!;
|
|
420
|
-
|
|
421
|
-
// Deep copy and update node data
|
|
422
|
-
const newData = JSON.parse(JSON.stringify(node.data)) as NodeData;
|
|
423
|
-
newData.id = newId;
|
|
424
|
-
|
|
425
|
-
// Update expression references to point to new node IDs
|
|
426
|
-
newData.arguments = updateExpressionsInArgs(getArguments(newData), idMap) as typeof newData.arguments;
|
|
427
|
-
|
|
428
|
-
return {
|
|
429
|
-
...node,
|
|
430
|
-
id: newId,
|
|
431
|
-
position: {
|
|
432
|
-
x: node.position.x + offset.x,
|
|
433
|
-
y: node.position.y + offset.y,
|
|
434
|
-
},
|
|
435
|
-
data: newData,
|
|
436
|
-
selected: true,
|
|
437
|
-
};
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
// Dedupe emit binding names on pasted nodes against existing variables.
|
|
441
|
-
// (Pasted nodes come from JSON.parse(JSON.stringify(...)) of live nodes, so
|
|
442
|
-
// their bindings are already present in the correct arguments locations.)
|
|
443
|
-
const existingNames = collectVariableNames(store);
|
|
444
|
-
for (const node of newNodes) {
|
|
445
|
-
deduplicateEmitNames(node.data, existingNames);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Create new edges with updated IDs (only for edges where both nodes are pasted)
|
|
449
|
-
const newEdges: Edge<EdgeData>[] = clipboard.edges
|
|
450
|
-
.filter((edge) => idMap.has(edge.source) && idMap.has(edge.target))
|
|
451
|
-
.map((edge) => {
|
|
452
|
-
const newEdge = {
|
|
453
|
-
...edge,
|
|
454
|
-
id: generateId(),
|
|
455
|
-
source: idMap.get(edge.source)!,
|
|
456
|
-
target: idMap.get(edge.target)!,
|
|
457
|
-
data: edge.data ? { ...edge.data } : edge.data,
|
|
458
|
-
};
|
|
459
|
-
// Remap prompt references on agentTask edges
|
|
460
|
-
if (newEdge.data?.prompt && isExpression(newEdge.data.prompt)) {
|
|
461
|
-
const prompt = newEdge.data.prompt as Expression;
|
|
462
|
-
newEdge.data = {
|
|
463
|
-
...newEdge.data,
|
|
464
|
-
prompt: {
|
|
465
|
-
...prompt,
|
|
466
|
-
references: prompt.references.map((ref) => ({
|
|
467
|
-
srcId: idMap.get(ref.srcId) ?? ref.srcId,
|
|
468
|
-
varId: ref.varId,
|
|
469
|
-
})),
|
|
470
|
-
},
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
return newEdge;
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
// Deselect existing nodes before adding new ones
|
|
477
|
-
setNodes((nds) => [...nds.map((n) => ({ ...n, selected: false })), ...newNodes]);
|
|
478
|
-
setEdges((eds) => [...eds, ...newEdges]);
|
|
479
|
-
|
|
480
|
-
// Add pasted nodes' variables to store
|
|
481
|
-
setVariables((vars) => ({ ...vars, ...computeVariablesFromNodes(newNodes.map((n) => n.data)) }));
|
|
482
|
-
|
|
483
|
-
return { pasted: true, skippedLabels };
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// ============================================================================
|
|
487
|
-
// Expression Reference Updater (for paste)
|
|
488
|
-
// ============================================================================
|
|
489
|
-
|
|
490
|
-
/** Update expression references to point to new node IDs after paste */
|
|
491
|
-
function updateExpressionsInArgs(args: Record<string, unknown>, idMap: Map<string, string>): Record<string, unknown> {
|
|
492
|
-
const updated: Record<string, unknown> = {};
|
|
493
|
-
|
|
494
|
-
for (const [key, value] of Object.entries(args)) {
|
|
495
|
-
if (isExpression(value)) {
|
|
496
|
-
// Update references with new node IDs
|
|
497
|
-
const updatedExpr: Expression = {
|
|
498
|
-
expression: value.expression,
|
|
499
|
-
dataType: value.dataType,
|
|
500
|
-
references: value.references.map((ref) => ({
|
|
501
|
-
srcId: idMap.get(ref.srcId) ?? ref.srcId,
|
|
502
|
-
varId: ref.varId,
|
|
503
|
-
})),
|
|
504
|
-
};
|
|
505
|
-
updated[key] = updatedExpr;
|
|
506
|
-
} else if (Array.isArray(value)) {
|
|
507
|
-
// Handle arrays of expressions
|
|
508
|
-
updated[key] = value.map((item) => {
|
|
509
|
-
if (isExpression(item)) {
|
|
510
|
-
return {
|
|
511
|
-
expression: item.expression,
|
|
512
|
-
dataType: item.dataType,
|
|
513
|
-
references: item.references.map((ref) => ({
|
|
514
|
-
srcId: idMap.get(ref.srcId) ?? ref.srcId,
|
|
515
|
-
varId: ref.varId,
|
|
516
|
-
})),
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
return item;
|
|
520
|
-
});
|
|
521
|
-
} else if (value !== null && typeof value === "object") {
|
|
522
|
-
// Handle nested objects
|
|
523
|
-
updated[key] = updateExpressionsInArgs(value as Record<string, unknown>, idMap);
|
|
524
|
-
} else {
|
|
525
|
-
updated[key] = value;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return updated;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// ============================================================================
|
|
533
|
-
// ReactFlow Type Resolution
|
|
534
|
-
// ============================================================================
|
|
535
|
-
|
|
536
|
-
/** Determine React Flow node type based on NodeCategory */
|
|
537
|
-
export function getReactFlowType(type: NodeType): string {
|
|
538
|
-
if (type === "FunctionCall") {
|
|
539
|
-
return "FunctionCall";
|
|
540
|
-
}
|
|
541
|
-
const category = NodeRegistry.getByType(type)?.category ?? "Uncategorized";
|
|
542
|
-
switch (category) {
|
|
543
|
-
case NodeCategory.Trigger:
|
|
544
|
-
case NodeCategory.Tool:
|
|
545
|
-
case NodeCategory.AI:
|
|
546
|
-
return category;
|
|
547
|
-
default:
|
|
548
|
-
return "Standard";
|
|
549
|
-
}
|
|
550
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
NodeBase,
|
|
3
|
+
NodeCategory,
|
|
4
|
+
NodeDefinition,
|
|
5
|
+
NodeData,
|
|
6
|
+
NodeOutput,
|
|
7
|
+
NodeRegistry,
|
|
8
|
+
NodeType,
|
|
9
|
+
OutputBinding,
|
|
10
|
+
FunctionCallNode,
|
|
11
|
+
FunctionNodeDefinition,
|
|
12
|
+
getArguments,
|
|
13
|
+
getNodeOutput,
|
|
14
|
+
} from "@foresthubai/workflow-core/node";
|
|
15
|
+
import type { Expression } from "@foresthubai/workflow-core";
|
|
16
|
+
import type { FunctionInfo } from "@foresthubai/workflow-core/function";
|
|
17
|
+
import type { OutputDeclaration } from "@foresthubai/workflow-core/parameter";
|
|
18
|
+
import { addEdge, Connection, Edge, Node } from "@xyflow/react";
|
|
19
|
+
import type { EdgeData, EdgeType } from "@foresthubai/workflow-core/edge";
|
|
20
|
+
import { CanvasStore } from "../stores/canvasStore";
|
|
21
|
+
import { computeVariablesFromNodes } from "@foresthubai/workflow-core/workflow";
|
|
22
|
+
import { nodeOutputVarKey, paramKey } from "@foresthubai/workflow-core/variable";
|
|
23
|
+
import { isExpression } from "@foresthubai/workflow-core/expression";
|
|
24
|
+
import { isValidConnection } from "./connectionRules";
|
|
25
|
+
import { generateId } from "@foresthubai/workflow-core/id";
|
|
26
|
+
import { uniqueName } from "./resourceHelpers";
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Output Name Deduplication
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/** Collect all output variable names currently in the store. */
|
|
33
|
+
function collectVariableNames(store: CanvasStore): Set<string> {
|
|
34
|
+
const vars = store.getState().variables;
|
|
35
|
+
return new Set(Object.values(vars).map((v) => v.name));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Walk a node instance and rename any emit bindings/declarations whose name collides
|
|
40
|
+
* with `existingNames`. Mutates in place. Handles all three locations:
|
|
41
|
+
* - Static outputs, incl. FunctionCall returns (direct field on arguments — OutputBinding)
|
|
42
|
+
* - List output entries (each entry IS an OutputDeclaration — mutate its `name` field directly
|
|
43
|
+
* when mode === "emit". Assign-mode entries have no name to rename, so they're skipped.)
|
|
44
|
+
* `existingNames` is updated with each rename so subsequent calls see the new names.
|
|
45
|
+
*/
|
|
46
|
+
function deduplicateEmitNames(node: NodeData, existingNames: Set<string>): void {
|
|
47
|
+
const args = node.arguments as Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
const renameBindingAt = (key: string): void => {
|
|
50
|
+
const binding = args[key] as OutputBinding | undefined;
|
|
51
|
+
// Only active emit bindings produce a variable that could collide.
|
|
52
|
+
if (!binding || !binding.active || binding.mode !== "emit" || !existingNames.has(binding.name)) return;
|
|
53
|
+
const deduped = uniqueName(binding.name, existingNames);
|
|
54
|
+
existingNames.add(deduped);
|
|
55
|
+
args[key] = { active: true, mode: "emit", name: deduped };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (node.type === "FunctionCall") {
|
|
59
|
+
for (const ret of node.functionInfo.returns) {
|
|
60
|
+
renameBindingAt(paramKey(ret));
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const def = NodeRegistry.getByType(node.type);
|
|
66
|
+
if (!def?.outputs) return;
|
|
67
|
+
|
|
68
|
+
for (const out of def.outputs) {
|
|
69
|
+
if (out.type === "static") {
|
|
70
|
+
renameBindingAt(out.id);
|
|
71
|
+
} else {
|
|
72
|
+
const entries = (args[out.id] as OutputDeclaration[] | undefined) ?? [];
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.mode !== "emit") continue;
|
|
75
|
+
if (!existingNames.has(entry.name)) continue;
|
|
76
|
+
const deduped = uniqueName(entry.name, existingNames);
|
|
77
|
+
existingNames.add(deduped);
|
|
78
|
+
entry.name = deduped;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Pure Store Operations
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
/** Check if a node definition can be added to the store (respects isUnremovable and isSingleton). */
|
|
89
|
+
export function canAddNode(store: CanvasStore, nodeDef: NodeDefinition): boolean {
|
|
90
|
+
if (nodeDef.isUnremovable) return false;
|
|
91
|
+
if (nodeDef.isSingleton) {
|
|
92
|
+
const { nodes } = store.getState();
|
|
93
|
+
if (nodes.some((n) => n.data.type === nodeDef.type)) return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Add a node to the store given a node definition and optional position. Returns the new node ID, or null if the node cannot be added. */
|
|
99
|
+
export function addNodeToStore(store: CanvasStore, nodeDef: NodeDefinition, position?: { x: number; y: number }): string | null {
|
|
100
|
+
if (!canAddNode(store, nodeDef)) return null;
|
|
101
|
+
|
|
102
|
+
const nodeId = generateId();
|
|
103
|
+
|
|
104
|
+
// Initialize parameters with default values. Clone so object/array defaults
|
|
105
|
+
// (Expression, weekdays `[]`, memory-refs `[]`) aren't shared by reference
|
|
106
|
+
// across instances or with the definition itself — a shared mutable default
|
|
107
|
+
// would alias on edit.
|
|
108
|
+
const args: Record<string, unknown> = {};
|
|
109
|
+
nodeDef.parameters.forEach((param) => {
|
|
110
|
+
if (param.default !== undefined) {
|
|
111
|
+
args[param.id] = structuredClone(param.default);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Seed outputs directly into args:
|
|
116
|
+
// - Static outputs: `args[out.id] = { active: true, mode: "emit", name: out.id }` (OutputBinding)
|
|
117
|
+
// - List outputs: `args[out.id] = []` (empty OutputDeclaration[] — user adds entries)
|
|
118
|
+
for (const out of nodeDef.outputs ?? []) {
|
|
119
|
+
if (out.type === "static") {
|
|
120
|
+
args[out.id] = { active: true, mode: "emit", name: out.id };
|
|
121
|
+
} else {
|
|
122
|
+
args[out.id] = [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const nodeData: NodeBase = {
|
|
127
|
+
id: nodeId,
|
|
128
|
+
type: nodeDef.type,
|
|
129
|
+
arguments: args,
|
|
130
|
+
};
|
|
131
|
+
if (nodeDef.type === "FunctionCall") {
|
|
132
|
+
const functionDef = nodeDef as FunctionNodeDefinition;
|
|
133
|
+
const functionNode = nodeData as FunctionCallNode;
|
|
134
|
+
functionNode.functionInfo = functionDef.functionInfo;
|
|
135
|
+
// Seed input bindings keyed by arg uid — empty expressions with the declared dataType.
|
|
136
|
+
// Output bindings were already seeded above by the generic outputs[] loop since
|
|
137
|
+
// buildFunctionNodeDef now emits real StaticOutput entries.
|
|
138
|
+
for (const arg of functionDef.functionInfo.arguments) {
|
|
139
|
+
args[paramKey(arg)] = { expression: "", references: [], dataType: arg.dataType };
|
|
140
|
+
}
|
|
141
|
+
// Generic seeding uses the output id as the default emit name, but for function
|
|
142
|
+
// returns we want the return's variable name.
|
|
143
|
+
for (const ret of functionDef.functionInfo.returns) {
|
|
144
|
+
args[paramKey(ret)] = { active: true, mode: "emit" as const, name: ret.name };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Deduplicate emit binding names against existing variables on the canvas
|
|
149
|
+
const existingNames = collectVariableNames(store);
|
|
150
|
+
deduplicateEmitNames(nodeData as NodeData, existingNames);
|
|
151
|
+
|
|
152
|
+
// Nudge position if another node is already nearby (avoids stacking on click-to-add)
|
|
153
|
+
const currentNodes = store.getState().nodes;
|
|
154
|
+
let pos = position || { x: 250, y: 100 };
|
|
155
|
+
const SNAP_DISTANCE = 50;
|
|
156
|
+
while (currentNodes.some((n) => Math.abs(n.position.x - pos.x) < SNAP_DISTANCE && Math.abs(n.position.y - pos.y) < SNAP_DISTANCE)) {
|
|
157
|
+
pos = { x: pos.x + 40, y: pos.y + 40 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const newNode: Node<NodeData> = {
|
|
161
|
+
id: nodeData.id,
|
|
162
|
+
type: getReactFlowType(nodeDef.type),
|
|
163
|
+
position: pos,
|
|
164
|
+
data: nodeData as NodeData,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const { setNodes, setVariables } = store.getState();
|
|
168
|
+
setNodes((nds) => [...nds, newNode]);
|
|
169
|
+
|
|
170
|
+
// Add new node's output variables to store (computed via getNodeOutput)
|
|
171
|
+
setVariables((vars) => ({ ...vars, ...computeVariablesFromNodes([newNode.data]) }));
|
|
172
|
+
|
|
173
|
+
return nodeId;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Update a node's data in the store. Returns true if the node was found and updated.
|
|
178
|
+
*
|
|
179
|
+
* FunctionCallNode is no longer a special case — its arguments are flat, so the same
|
|
180
|
+
* shallow-merge path handles input edits, output binding edits, and migrations uniformly.
|
|
181
|
+
* Migration callers simply pass the full replacement arguments record alongside
|
|
182
|
+
* `functionInfo`; normal edits pass the single changed key.
|
|
183
|
+
*/
|
|
184
|
+
export function updateNodeInStore(
|
|
185
|
+
store: CanvasStore,
|
|
186
|
+
nodeId: string,
|
|
187
|
+
updates: {
|
|
188
|
+
arguments?: Record<string, unknown>;
|
|
189
|
+
label?: string;
|
|
190
|
+
functionInfo?: FunctionInfo;
|
|
191
|
+
},
|
|
192
|
+
): boolean {
|
|
193
|
+
const { setNodes, setVariables } = store.getState();
|
|
194
|
+
|
|
195
|
+
// Track output changes to update variables store
|
|
196
|
+
let oldOutputs: Record<string, NodeOutput> = {};
|
|
197
|
+
let newOutputs: Record<string, NodeOutput> = {};
|
|
198
|
+
let outputsChanged = false;
|
|
199
|
+
let found = false;
|
|
200
|
+
|
|
201
|
+
setNodes((nds) => {
|
|
202
|
+
const targetNodeIndex = nds.findIndex((node) => node.id === nodeId);
|
|
203
|
+
if (targetNodeIndex === -1) return nds;
|
|
204
|
+
|
|
205
|
+
const targetNode = nds[targetNodeIndex];
|
|
206
|
+
if (!targetNode) return nds;
|
|
207
|
+
found = true;
|
|
208
|
+
const currentData = targetNode.data as NodeData;
|
|
209
|
+
oldOutputs = getNodeOutput(currentData);
|
|
210
|
+
|
|
211
|
+
// When functionInfo changes (FunctionCall signature update), arguments are a
|
|
212
|
+
// full replacement so stale keys for removed args/returns get dropped;
|
|
213
|
+
// otherwise shallow-merge.
|
|
214
|
+
let updatedNodeData: NodeData;
|
|
215
|
+
|
|
216
|
+
if (updates.arguments) {
|
|
217
|
+
const mergedArgs = updates.functionInfo
|
|
218
|
+
? updates.arguments
|
|
219
|
+
: { ...(currentData.arguments as Record<string, unknown>), ...updates.arguments };
|
|
220
|
+
updatedNodeData = {
|
|
221
|
+
...(currentData as Record<string, unknown>),
|
|
222
|
+
...updates,
|
|
223
|
+
arguments: mergedArgs,
|
|
224
|
+
} as NodeData;
|
|
225
|
+
} else {
|
|
226
|
+
updatedNodeData = { ...(currentData as Record<string, unknown>), ...updates } as NodeData;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
newOutputs = getNodeOutput(updatedNodeData);
|
|
230
|
+
|
|
231
|
+
// Build the updated nodes array
|
|
232
|
+
const updatedNodes = nds.map((node, index) => {
|
|
233
|
+
if (index === targetNodeIndex) {
|
|
234
|
+
return { ...node, data: updatedNodeData };
|
|
235
|
+
}
|
|
236
|
+
return node;
|
|
237
|
+
}) as Node<NodeData>[];
|
|
238
|
+
|
|
239
|
+
// Check if any output variable changed (keys added/removed or values changed)
|
|
240
|
+
const oldKeys = Object.keys(oldOutputs);
|
|
241
|
+
const newKeys = Object.keys(newOutputs);
|
|
242
|
+
outputsChanged =
|
|
243
|
+
oldKeys.length !== newKeys.length ||
|
|
244
|
+
newKeys.some((key) => {
|
|
245
|
+
const oldVar = oldOutputs[key];
|
|
246
|
+
const newVar = newOutputs[key];
|
|
247
|
+
return !oldVar || !newVar || oldVar.name !== newVar.name || oldVar.dataType !== newVar.dataType;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return updatedNodes;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Update variables store if outputs changed
|
|
254
|
+
if (outputsChanged) {
|
|
255
|
+
setVariables((vars) => {
|
|
256
|
+
const updated = { ...vars };
|
|
257
|
+
// Remove old outputs that no longer exist in new outputs
|
|
258
|
+
for (const outputId of Object.keys(oldOutputs)) {
|
|
259
|
+
if (!(outputId in newOutputs)) {
|
|
260
|
+
delete updated[nodeOutputVarKey(nodeId, outputId)];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Add/update new outputs (only if actually changed)
|
|
264
|
+
for (const [outputId, variable] of Object.entries(newOutputs)) {
|
|
265
|
+
const key = nodeOutputVarKey(nodeId, outputId);
|
|
266
|
+
const oldVar = vars[key];
|
|
267
|
+
if (!oldVar || oldVar.name !== variable.name || oldVar.dataType !== variable.dataType) {
|
|
268
|
+
updated[key] = { kind: "node", nodeId, outputId, name: variable.name, dataType: variable.dataType };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return updated;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return found;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Delete a node from the store, removing connected edges and variables.
|
|
280
|
+
* Takes optional getNodeDefinition callback for isUnremovable check.
|
|
281
|
+
* Returns true if the node was deleted.
|
|
282
|
+
*/
|
|
283
|
+
export function deleteNodeFromStore(
|
|
284
|
+
store: CanvasStore,
|
|
285
|
+
nodeId: string,
|
|
286
|
+
getNodeDefinition?: (node: NodeData) => NodeDefinition | undefined,
|
|
287
|
+
): boolean {
|
|
288
|
+
const { nodes, edges, setNodes, setEdges, setVariables } = store.getState();
|
|
289
|
+
|
|
290
|
+
// Check if node can be deleted
|
|
291
|
+
const nodeToDelete = nodes.find((node) => node.id === nodeId);
|
|
292
|
+
if (!nodeToDelete) return false;
|
|
293
|
+
|
|
294
|
+
if (getNodeDefinition) {
|
|
295
|
+
const nodeDef = getNodeDefinition(nodeToDelete.data);
|
|
296
|
+
if (nodeDef?.isUnremovable ?? false) return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
|
|
300
|
+
setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
|
|
301
|
+
|
|
302
|
+
// Remove deleted node's emitted variables from store
|
|
303
|
+
const outputKeys = Object.keys(getNodeOutput(nodeToDelete.data));
|
|
304
|
+
if (outputKeys.length > 0) {
|
|
305
|
+
setVariables((vars) => {
|
|
306
|
+
const updated = { ...vars };
|
|
307
|
+
for (const outputId of outputKeys) {
|
|
308
|
+
delete updated[nodeOutputVarKey(nodeId, outputId)];
|
|
309
|
+
}
|
|
310
|
+
return updated;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Determine the specific edge type based on base port type and connected nodes.
|
|
319
|
+
* Agent nodes get specialized control-edge types (agentTask, agentChoice, agentDelegate).
|
|
320
|
+
* Tool edges have no agent-specific variant — every tool edge points at an agent.
|
|
321
|
+
*/
|
|
322
|
+
function resolveEdgeType(basePortType: EdgeType, sourceNode: Node<NodeData>, targetNode: Node<NodeData>): EdgeType {
|
|
323
|
+
const sourceIsAgent = sourceNode.data.type === "Agent";
|
|
324
|
+
const targetIsAgent = targetNode.data.type === "Agent";
|
|
325
|
+
|
|
326
|
+
if (basePortType === "control") {
|
|
327
|
+
if (sourceIsAgent && targetIsAgent) return "agentDelegate";
|
|
328
|
+
if (targetIsAgent) return "agentTask";
|
|
329
|
+
if (sourceIsAgent) return "agentChoice";
|
|
330
|
+
return "control";
|
|
331
|
+
}
|
|
332
|
+
return basePortType;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Add an edge if the connection is valid. Returns true if the edge was added. */
|
|
336
|
+
export function connectNodesInStore(store: CanvasStore, connection: Connection): EdgeType | false {
|
|
337
|
+
const { nodes, edges, setEdges } = store.getState();
|
|
338
|
+
|
|
339
|
+
const portType = isValidConnection(connection.source, connection.target, connection.sourceHandle, connection.targetHandle, nodes, edges);
|
|
340
|
+
if (!portType) return false;
|
|
341
|
+
|
|
342
|
+
const sourceNode = nodes.find((n) => n.id === connection.source);
|
|
343
|
+
const targetNode = nodes.find((n) => n.id === connection.target);
|
|
344
|
+
if (!sourceNode || !targetNode) return false;
|
|
345
|
+
|
|
346
|
+
const edgeType = resolveEdgeType(portType, sourceNode, targetNode);
|
|
347
|
+
setEdges((eds) => addEdge({ ...connection, type: edgeType }, eds) as typeof eds);
|
|
348
|
+
return edgeType;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Update an edge's data in the store. Returns true if the edge was found and updated. */
|
|
352
|
+
export function updateEdgeInStore(store: CanvasStore, edgeId: string, updates: Record<string, unknown>): boolean {
|
|
353
|
+
const { edges, setEdges } = store.getState();
|
|
354
|
+
const edge = edges.find((e) => e.id === edgeId);
|
|
355
|
+
if (!edge) return false;
|
|
356
|
+
|
|
357
|
+
setEdges((eds) => eds.map((e) => (e.id === edgeId ? { ...e, data: { ...e.data, ...updates } as EdgeData } : e)));
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Delete edges by ID from the store. */
|
|
362
|
+
export function deleteEdgesFromStore(store: CanvasStore, edgeIds: string[]): void {
|
|
363
|
+
const { setEdges } = store.getState();
|
|
364
|
+
const idSet = new Set(edgeIds);
|
|
365
|
+
setEdges((eds) => eds.filter((e) => !idSet.has(e.id)));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================================
|
|
369
|
+
// Clipboard
|
|
370
|
+
// ============================================================================
|
|
371
|
+
|
|
372
|
+
export interface Clipboard {
|
|
373
|
+
nodes: Node<NodeData>[];
|
|
374
|
+
edges: Edge<EdgeData>[];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface PasteResult {
|
|
378
|
+
pasted: boolean;
|
|
379
|
+
/** Labels of nodes that were skipped because they cannot be added (singleton already present or unremovable). */
|
|
380
|
+
skippedLabels: string[];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Paste clipboard contents into the store with new IDs and optional position offset. */
|
|
384
|
+
export function pasteToStore(
|
|
385
|
+
store: CanvasStore,
|
|
386
|
+
clipboard: Clipboard,
|
|
387
|
+
offset: { x: number; y: number } = { x: 50, y: 50 },
|
|
388
|
+
getNodeDefinition?: (node: NodeData) => NodeDefinition | undefined,
|
|
389
|
+
): PasteResult {
|
|
390
|
+
const empty: PasteResult = { pasted: false, skippedLabels: [] };
|
|
391
|
+
if (clipboard.nodes.length === 0) return empty;
|
|
392
|
+
|
|
393
|
+
const { setNodes, setEdges, setVariables } = store.getState();
|
|
394
|
+
|
|
395
|
+
// Filter out nodes that cannot be pasted (unremovable or singleton already on canvas)
|
|
396
|
+
const skippedLabels: string[] = [];
|
|
397
|
+
const pastableNodes = clipboard.nodes.filter((node) => {
|
|
398
|
+
if (!getNodeDefinition) return true;
|
|
399
|
+
const def = getNodeDefinition(node.data);
|
|
400
|
+
if (!def) return true;
|
|
401
|
+
if (!canAddNode(store, def)) {
|
|
402
|
+
skippedLabels.push(def.label);
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
return true;
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (pastableNodes.length === 0) return { pasted: false, skippedLabels };
|
|
409
|
+
|
|
410
|
+
// Build old ID -> new ID mapping (only for pastable nodes)
|
|
411
|
+
const idMap = new Map<string, string>();
|
|
412
|
+
pastableNodes.forEach((node) => {
|
|
413
|
+
const newId = generateId();
|
|
414
|
+
idMap.set(node.id, newId);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Create new nodes with updated IDs and positions
|
|
418
|
+
const newNodes: Node<NodeData>[] = pastableNodes.map((node) => {
|
|
419
|
+
const newId = idMap.get(node.id)!;
|
|
420
|
+
|
|
421
|
+
// Deep copy and update node data
|
|
422
|
+
const newData = JSON.parse(JSON.stringify(node.data)) as NodeData;
|
|
423
|
+
newData.id = newId;
|
|
424
|
+
|
|
425
|
+
// Update expression references to point to new node IDs
|
|
426
|
+
newData.arguments = updateExpressionsInArgs(getArguments(newData), idMap) as typeof newData.arguments;
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
...node,
|
|
430
|
+
id: newId,
|
|
431
|
+
position: {
|
|
432
|
+
x: node.position.x + offset.x,
|
|
433
|
+
y: node.position.y + offset.y,
|
|
434
|
+
},
|
|
435
|
+
data: newData,
|
|
436
|
+
selected: true,
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Dedupe emit binding names on pasted nodes against existing variables.
|
|
441
|
+
// (Pasted nodes come from JSON.parse(JSON.stringify(...)) of live nodes, so
|
|
442
|
+
// their bindings are already present in the correct arguments locations.)
|
|
443
|
+
const existingNames = collectVariableNames(store);
|
|
444
|
+
for (const node of newNodes) {
|
|
445
|
+
deduplicateEmitNames(node.data, existingNames);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Create new edges with updated IDs (only for edges where both nodes are pasted)
|
|
449
|
+
const newEdges: Edge<EdgeData>[] = clipboard.edges
|
|
450
|
+
.filter((edge) => idMap.has(edge.source) && idMap.has(edge.target))
|
|
451
|
+
.map((edge) => {
|
|
452
|
+
const newEdge = {
|
|
453
|
+
...edge,
|
|
454
|
+
id: generateId(),
|
|
455
|
+
source: idMap.get(edge.source)!,
|
|
456
|
+
target: idMap.get(edge.target)!,
|
|
457
|
+
data: edge.data ? { ...edge.data } : edge.data,
|
|
458
|
+
};
|
|
459
|
+
// Remap prompt references on agentTask edges
|
|
460
|
+
if (newEdge.data?.prompt && isExpression(newEdge.data.prompt)) {
|
|
461
|
+
const prompt = newEdge.data.prompt as Expression;
|
|
462
|
+
newEdge.data = {
|
|
463
|
+
...newEdge.data,
|
|
464
|
+
prompt: {
|
|
465
|
+
...prompt,
|
|
466
|
+
references: prompt.references.map((ref) => ({
|
|
467
|
+
srcId: idMap.get(ref.srcId) ?? ref.srcId,
|
|
468
|
+
varId: ref.varId,
|
|
469
|
+
})),
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return newEdge;
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Deselect existing nodes before adding new ones
|
|
477
|
+
setNodes((nds) => [...nds.map((n) => ({ ...n, selected: false })), ...newNodes]);
|
|
478
|
+
setEdges((eds) => [...eds, ...newEdges]);
|
|
479
|
+
|
|
480
|
+
// Add pasted nodes' variables to store
|
|
481
|
+
setVariables((vars) => ({ ...vars, ...computeVariablesFromNodes(newNodes.map((n) => n.data)) }));
|
|
482
|
+
|
|
483
|
+
return { pasted: true, skippedLabels };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ============================================================================
|
|
487
|
+
// Expression Reference Updater (for paste)
|
|
488
|
+
// ============================================================================
|
|
489
|
+
|
|
490
|
+
/** Update expression references to point to new node IDs after paste */
|
|
491
|
+
function updateExpressionsInArgs(args: Record<string, unknown>, idMap: Map<string, string>): Record<string, unknown> {
|
|
492
|
+
const updated: Record<string, unknown> = {};
|
|
493
|
+
|
|
494
|
+
for (const [key, value] of Object.entries(args)) {
|
|
495
|
+
if (isExpression(value)) {
|
|
496
|
+
// Update references with new node IDs
|
|
497
|
+
const updatedExpr: Expression = {
|
|
498
|
+
expression: value.expression,
|
|
499
|
+
dataType: value.dataType,
|
|
500
|
+
references: value.references.map((ref) => ({
|
|
501
|
+
srcId: idMap.get(ref.srcId) ?? ref.srcId,
|
|
502
|
+
varId: ref.varId,
|
|
503
|
+
})),
|
|
504
|
+
};
|
|
505
|
+
updated[key] = updatedExpr;
|
|
506
|
+
} else if (Array.isArray(value)) {
|
|
507
|
+
// Handle arrays of expressions
|
|
508
|
+
updated[key] = value.map((item) => {
|
|
509
|
+
if (isExpression(item)) {
|
|
510
|
+
return {
|
|
511
|
+
expression: item.expression,
|
|
512
|
+
dataType: item.dataType,
|
|
513
|
+
references: item.references.map((ref) => ({
|
|
514
|
+
srcId: idMap.get(ref.srcId) ?? ref.srcId,
|
|
515
|
+
varId: ref.varId,
|
|
516
|
+
})),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return item;
|
|
520
|
+
});
|
|
521
|
+
} else if (value !== null && typeof value === "object") {
|
|
522
|
+
// Handle nested objects
|
|
523
|
+
updated[key] = updateExpressionsInArgs(value as Record<string, unknown>, idMap);
|
|
524
|
+
} else {
|
|
525
|
+
updated[key] = value;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return updated;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// ReactFlow Type Resolution
|
|
534
|
+
// ============================================================================
|
|
535
|
+
|
|
536
|
+
/** Determine React Flow node type based on NodeCategory */
|
|
537
|
+
export function getReactFlowType(type: NodeType): string {
|
|
538
|
+
if (type === "FunctionCall") {
|
|
539
|
+
return "FunctionCall";
|
|
540
|
+
}
|
|
541
|
+
const category = NodeRegistry.getByType(type)?.category ?? "Uncategorized";
|
|
542
|
+
switch (category) {
|
|
543
|
+
case NodeCategory.Trigger:
|
|
544
|
+
case NodeCategory.Tool:
|
|
545
|
+
case NodeCategory.AI:
|
|
546
|
+
return category;
|
|
547
|
+
default:
|
|
548
|
+
return "Standard";
|
|
549
|
+
}
|
|
550
|
+
}
|