@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,630 +1,630 @@
|
|
|
1
|
-
import { Button } from "../components/ui/button";
|
|
2
|
-
import { AddButton } from "../components/ui/add-button";
|
|
3
|
-
import { ReadOnlyBanner } from "../components/ui/readonly-banner";
|
|
4
|
-
import { DeleteButton } from "../components/ui/delete-button";
|
|
5
|
-
import { Checkbox } from "../components/ui/checkbox";
|
|
6
|
-
import { Input } from "../components/ui/input";
|
|
7
|
-
import { Separator } from "../components/ui/separator";
|
|
8
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
|
9
|
-
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
|
|
10
|
-
import {
|
|
11
|
-
NodeDefinition,
|
|
12
|
-
NodeData,
|
|
13
|
-
OutputBinding,
|
|
14
|
-
FunctionCallNode,
|
|
15
|
-
getArguments,
|
|
16
|
-
getNodeAvailableOutput,
|
|
17
|
-
getOutputBinding,
|
|
18
|
-
} from "@foresthubai/workflow-core/node";
|
|
19
|
-
import type { DataType, Reference } from "@foresthubai/workflow-core";
|
|
20
|
-
import type { StaticOutput, OutputList, OutputDeclaration } from "@foresthubai/workflow-core/parameter";
|
|
21
|
-
import { isParameterActive, Parameter } from "@foresthubai/workflow-core/parameter";
|
|
22
|
-
import { generateId } from "@foresthubai/workflow-core/id";
|
|
23
|
-
import { ArrowRight, ChevronRight, Plus, RefreshCw, Trash2 } from "lucide-react";
|
|
24
|
-
import { getOrCreateCanvasStore } from "../stores/canvasStore";
|
|
25
|
-
import { isNodeUsedAsTool } from "@foresthubai/workflow-core/node";
|
|
26
|
-
import { varKey, refToLookupKey } from "@foresthubai/workflow-core/variable";
|
|
27
|
-
import type { Diagnostic } from "@foresthubai/workflow-core/diagnostics";
|
|
28
|
-
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
|
29
|
-
import { useTranslation } from "react-i18next";
|
|
30
|
-
import ParameterEditor from "../inputs/ParameterEditor";
|
|
31
|
-
import { useDiagnosticsStore } from "../stores/diagnosticsStore";
|
|
32
|
-
import { useFunctionRegistry } from "../hooks/useFunctionRegistry";
|
|
33
|
-
import { useParamErrors } from "../hooks/useParamErrors";
|
|
34
|
-
import { buildFunctionNodeDef } from "../hooks/useNodeDefinitions";
|
|
35
|
-
import { useEditorStore } from "../stores/editorStore";
|
|
36
|
-
import { isReadOnly } from "../WorkflowBuilder";
|
|
37
|
-
import { migrateFunctionCallNodes } from "../utils/migrateFunctionNodes";
|
|
38
|
-
import { getNodeDescription } from "../utils/translation";
|
|
39
|
-
import { useAvailableVariables } from "../hooks/useAvailableVariables";
|
|
40
|
-
|
|
41
|
-
interface NodeConfigPanelProps {
|
|
42
|
-
canvasId: string;
|
|
43
|
-
selectedNode: NodeData;
|
|
44
|
-
onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown>; label?: string }) => void;
|
|
45
|
-
onNodeDelete: (nodeId: string) => void;
|
|
46
|
-
onClose: () => void;
|
|
47
|
-
onOpenTest: (nodeId: string) => void;
|
|
48
|
-
getNodeDef: (node: NodeData) => NodeDefinition | undefined;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export const NodeConfigPanel = ({
|
|
52
|
-
canvasId,
|
|
53
|
-
selectedNode,
|
|
54
|
-
onNodeUpdate,
|
|
55
|
-
onNodeDelete,
|
|
56
|
-
onClose,
|
|
57
|
-
onOpenTest,
|
|
58
|
-
getNodeDef,
|
|
59
|
-
}: NodeConfigPanelProps) => {
|
|
60
|
-
const { t } = useTranslation();
|
|
61
|
-
const readOnly = useEditorStore((s) => isReadOnly(s.builderMode));
|
|
62
|
-
|
|
63
|
-
// Local state for label input to preserve cursor position
|
|
64
|
-
const [localLabel, setLocalLabel] = useState(selectedNode.label || "");
|
|
65
|
-
const [isFocused, setIsFocused] = useState(false);
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
setLocalLabel(selectedNode.label || "");
|
|
68
|
-
}, [selectedNode.id, selectedNode.label]);
|
|
69
|
-
|
|
70
|
-
// Check if FunctionCall node is stale (e.g. after undo reverted migration)
|
|
71
|
-
const functionId = selectedNode.type === "FunctionCall" ? (selectedNode as FunctionCallNode).functionInfo.id : null;
|
|
72
|
-
const { getFunction } = useFunctionRegistry();
|
|
73
|
-
const functionInfo = functionId ? getFunction(functionId) : undefined;
|
|
74
|
-
const isStaleFunction = (() => {
|
|
75
|
-
if (selectedNode.type !== "FunctionCall" || !functionInfo) return false;
|
|
76
|
-
const functionNode = selectedNode as FunctionCallNode;
|
|
77
|
-
return functionNode.functionInfo.version !== functionInfo.version;
|
|
78
|
-
})();
|
|
79
|
-
|
|
80
|
-
// Get node definition - for FunctionCallNodes, build from node's stored functionInfo
|
|
81
|
-
const nodeDefinition =
|
|
82
|
-
selectedNode.type === "FunctionCall"
|
|
83
|
-
? buildFunctionNodeDef({
|
|
84
|
-
...(selectedNode as FunctionCallNode).functionInfo,
|
|
85
|
-
name: functionInfo?.name ?? (selectedNode as FunctionCallNode).functionInfo.name,
|
|
86
|
-
})
|
|
87
|
-
: getNodeDef(selectedNode);
|
|
88
|
-
const cannotDelete = nodeDefinition?.isUnremovable ?? false;
|
|
89
|
-
|
|
90
|
-
// Detect whether this node is currently used as a tool input
|
|
91
|
-
const edges = getOrCreateCanvasStore(canvasId)((s) => s.edges);
|
|
92
|
-
const usedAsToolInput = useMemo(() => isNodeUsedAsTool(selectedNode.id, selectedNode, edges), [selectedNode, edges]);
|
|
93
|
-
|
|
94
|
-
// Read per-parameter error state from diagnostics store
|
|
95
|
-
const nodeDiags = useDiagnosticsStore((s) => s.byNodeId[selectedNode.id]);
|
|
96
|
-
const paramErrors = useParamErrors(nodeDiags);
|
|
97
|
-
|
|
98
|
-
if (!nodeDefinition) {
|
|
99
|
-
return (
|
|
100
|
-
<div className="p-4">
|
|
101
|
-
<p className="text-sm text-muted-foreground">{t("unknownNodeType", { type: selectedNode.type })}</p>
|
|
102
|
-
</div>
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
const allArguments = getArguments(selectedNode);
|
|
106
|
-
const parameters = nodeDefinition.parameters.filter((p) => isParameterActive(p, allArguments, usedAsToolInput));
|
|
107
|
-
// OutputsSection self-hides when there's nothing to render; gate on "has any output defined".
|
|
108
|
-
const hasAnyOutputs = (nodeDefinition.outputs ?? []).length > 0;
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<div className="p-4">
|
|
112
|
-
<div className="space-y-4">
|
|
113
|
-
<div className="flex items-center justify-between gap-2">
|
|
114
|
-
<div className="flex-1 min-w-0">
|
|
115
|
-
<div className="group flex items-center gap-1.5 rounded-md border border-transparent px-1.5 -mx-1.5 hover:border-input focus-within:border-input transition-colors">
|
|
116
|
-
<input
|
|
117
|
-
type="text"
|
|
118
|
-
title={t("nodeLabel")}
|
|
119
|
-
className="font-semibold text-lg bg-transparent w-full outline-none cursor-text py-0.5"
|
|
120
|
-
value={isFocused ? localLabel : localLabel || nodeDefinition.label}
|
|
121
|
-
readOnly={readOnly}
|
|
122
|
-
onFocus={() => {
|
|
123
|
-
if (readOnly) return;
|
|
124
|
-
setIsFocused(true);
|
|
125
|
-
if (!localLabel) {
|
|
126
|
-
setLocalLabel(nodeDefinition.label);
|
|
127
|
-
}
|
|
128
|
-
}}
|
|
129
|
-
onBlur={() => {
|
|
130
|
-
setIsFocused(false);
|
|
131
|
-
if (!localLabel || localLabel === nodeDefinition.label) {
|
|
132
|
-
setLocalLabel("");
|
|
133
|
-
onNodeUpdate(selectedNode.id, { label: undefined });
|
|
134
|
-
}
|
|
135
|
-
}}
|
|
136
|
-
onChange={(e) => {
|
|
137
|
-
setLocalLabel(e.target.value);
|
|
138
|
-
onNodeUpdate(selectedNode.id, { label: e.target.value });
|
|
139
|
-
}}
|
|
140
|
-
/>
|
|
141
|
-
</div>
|
|
142
|
-
<p className="text-sm text-muted-foreground">{getNodeDescription(t, nodeDefinition)}</p>
|
|
143
|
-
</div>
|
|
144
|
-
<Button variant="ghost" size="icon" className="shrink-0" onClick={onClose}>
|
|
145
|
-
<ChevronRight className="h-4 w-4" />
|
|
146
|
-
</Button>
|
|
147
|
-
</div>
|
|
148
|
-
|
|
149
|
-
{readOnly && <ReadOnlyBanner />}
|
|
150
|
-
|
|
151
|
-
{parameters.length > 0 && (
|
|
152
|
-
<>
|
|
153
|
-
<Separator />
|
|
154
|
-
<div className={`space-y-3 ${readOnly ? "pointer-events-none opacity-60" : ""}`}>
|
|
155
|
-
{parameters.map((param: Parameter) => (
|
|
156
|
-
<ParameterEditor
|
|
157
|
-
canvasId={canvasId}
|
|
158
|
-
key={param.id}
|
|
159
|
-
parameter={param}
|
|
160
|
-
value={allArguments[param.id]}
|
|
161
|
-
allArguments={allArguments}
|
|
162
|
-
onChange={(value) => onNodeUpdate(selectedNode.id, { arguments: { [param.id]: value } })}
|
|
163
|
-
errors={paramErrors.get(param.id)}
|
|
164
|
-
translationPrefix={`nodes.${selectedNode.type}`}
|
|
165
|
-
/>
|
|
166
|
-
))}
|
|
167
|
-
</div>
|
|
168
|
-
</>
|
|
169
|
-
)}
|
|
170
|
-
|
|
171
|
-
{!readOnly && isStaleFunction && (
|
|
172
|
-
<>
|
|
173
|
-
<Separator />
|
|
174
|
-
<Button
|
|
175
|
-
variant="outline"
|
|
176
|
-
className="w-full border-warning text-warning hover:bg-warning/10"
|
|
177
|
-
onClick={() => migrateFunctionCallNodes()}
|
|
178
|
-
>
|
|
179
|
-
<RefreshCw className="w-4 h-4 mr-2" />
|
|
180
|
-
{t("updateToLatestDefinition")}
|
|
181
|
-
</Button>
|
|
182
|
-
</>
|
|
183
|
-
)}
|
|
184
|
-
|
|
185
|
-
{!readOnly && hasAnyOutputs && !usedAsToolInput && (
|
|
186
|
-
<>
|
|
187
|
-
<Separator />
|
|
188
|
-
<OutputsSection
|
|
189
|
-
canvasId={canvasId}
|
|
190
|
-
node={selectedNode}
|
|
191
|
-
nodeDefinition={nodeDefinition}
|
|
192
|
-
onNodeUpdate={onNodeUpdate}
|
|
193
|
-
nodeDiags={nodeDiags}
|
|
194
|
-
/>
|
|
195
|
-
</>
|
|
196
|
-
)}
|
|
197
|
-
|
|
198
|
-
{!readOnly && selectedNode.type === "Agent" && (
|
|
199
|
-
<>
|
|
200
|
-
<Separator />
|
|
201
|
-
<Button variant="outline" className="w-full" onClick={() => onOpenTest(selectedNode.id)}>
|
|
202
|
-
{t("testAgent")}
|
|
203
|
-
</Button>
|
|
204
|
-
</>
|
|
205
|
-
)}
|
|
206
|
-
|
|
207
|
-
{!readOnly && !cannotDelete && (
|
|
208
|
-
<>
|
|
209
|
-
<Separator />
|
|
210
|
-
<DeleteButton onClick={() => onNodeDelete(selectedNode.id)}>{t("deleteNode")}</DeleteButton>
|
|
211
|
-
</>
|
|
212
|
-
)}
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
215
|
-
);
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
// ============================================================================
|
|
219
|
-
// Outputs Section
|
|
220
|
-
// ============================================================================
|
|
221
|
-
//
|
|
222
|
-
// Single unified section for all of a node's outputs. Renders under one "OUTPUTS"
|
|
223
|
-
// header with sequential rows — no sub-sections, no per-list separator. Two row
|
|
224
|
-
// shapes live side by side here:
|
|
225
|
-
//
|
|
226
|
-
// 1. Static output row — rows for NodeDefinition.outputs.type === "static"
|
|
227
|
-
// (FunctionCall returns are just StaticOutputs — they
|
|
228
|
-
// flow through this same path). Active checkbox toggles
|
|
229
|
-
// binding.active; mode toggle picks emit vs. assign.
|
|
230
|
-
// 2. List declaration row — rows for each entry in an OutputList's backing array
|
|
231
|
-
// (mode: emit/assign, own dataType selector, own delete button)
|
|
232
|
-
//
|
|
233
|
-
// List outputs also render an "+ Add" button after their entries so new declarations
|
|
234
|
-
// can be appended. Errors on any row (static or list) apply a destructive ring using
|
|
235
|
-
// the same `outputId` key diagnostics.ts produces.
|
|
236
|
-
|
|
237
|
-
const DATA_TYPES: DataType[] = ["int", "float", "bool", "string"];
|
|
238
|
-
|
|
239
|
-
const DATA_TYPE_LABELS: Record<DataType, string> = {
|
|
240
|
-
int: "int",
|
|
241
|
-
float: "float",
|
|
242
|
-
bool: "bool",
|
|
243
|
-
string: "string",
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
/** Stable synthetic outputId used to key list-entry diagnostics (matches diagnostics.ts). */
|
|
247
|
-
function listEntryOutputId(listId: string, index: number): string {
|
|
248
|
-
return `${listId}[${index}]`;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Convert an AvailableVariable into a canonical Reference (srcId + varId). */
|
|
252
|
-
function availableVarToRef(
|
|
253
|
-
v:
|
|
254
|
-
| { kind: "node"; nodeId: string; outputId: string }
|
|
255
|
-
| { kind: "declared"; uid: string }
|
|
256
|
-
| { kind: "fnarg"; uid: string },
|
|
257
|
-
): Reference {
|
|
258
|
-
if (v.kind === "node") return { srcId: v.nodeId, varId: v.outputId };
|
|
259
|
-
if (v.kind === "declared") return { srcId: "declared", varId: v.uid };
|
|
260
|
-
return { srcId: "fnarg", varId: v.uid };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function OutputsSection({
|
|
264
|
-
canvasId,
|
|
265
|
-
node,
|
|
266
|
-
nodeDefinition,
|
|
267
|
-
onNodeUpdate,
|
|
268
|
-
nodeDiags,
|
|
269
|
-
}: {
|
|
270
|
-
canvasId: string;
|
|
271
|
-
node: NodeData;
|
|
272
|
-
nodeDefinition: NodeDefinition;
|
|
273
|
-
onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown> }) => void;
|
|
274
|
-
nodeDiags: Diagnostic[] | undefined;
|
|
275
|
-
}) {
|
|
276
|
-
const { t } = useTranslation();
|
|
277
|
-
const { list: availableVars } = useAvailableVariables(canvasId);
|
|
278
|
-
const availableOutput = useMemo(() => getNodeAvailableOutput(node), [node]);
|
|
279
|
-
|
|
280
|
-
const staticOutputs = useMemo(
|
|
281
|
-
() => (nodeDefinition.outputs ?? []).filter((o): o is StaticOutput => o.type === "static"),
|
|
282
|
-
[nodeDefinition],
|
|
283
|
-
);
|
|
284
|
-
const listOutputs = useMemo(
|
|
285
|
-
() => (nodeDefinition.outputs ?? []).filter((o): o is OutputList => o.type === "list"),
|
|
286
|
-
[nodeDefinition],
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
// Collect diagnostics keyed by outputId so rows can look themselves up.
|
|
290
|
-
const outputErrors = useMemo(() => {
|
|
291
|
-
const map = new Map<string, string[]>();
|
|
292
|
-
if (!nodeDiags) return map;
|
|
293
|
-
for (const d of nodeDiags) {
|
|
294
|
-
if (d.outputId && d.severity === "error") {
|
|
295
|
-
const arr = map.get(d.outputId);
|
|
296
|
-
if (arr) arr.push(d.message);
|
|
297
|
-
else map.set(d.outputId, [d.message]);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
return map;
|
|
301
|
-
}, [nodeDiags]);
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Write a binding for a static output (or FunctionCall return). List entries
|
|
305
|
-
* have their own update path via `replaceListEntry`.
|
|
306
|
-
*/
|
|
307
|
-
const updateStaticBinding = useCallback(
|
|
308
|
-
(key: string, binding: OutputBinding) => {
|
|
309
|
-
onNodeUpdate(node.id, { arguments: { [key]: binding } });
|
|
310
|
-
},
|
|
311
|
-
[node.id, onNodeUpdate],
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
const writeListEntries = useCallback(
|
|
315
|
-
(listId: string, next: OutputDeclaration[]) => onNodeUpdate(node.id, { arguments: { [listId]: next } }),
|
|
316
|
-
[node.id, onNodeUpdate],
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
// Filter available vars to matching dataType, excluding this node's own outputs
|
|
320
|
-
// (a node can't assign its own output back into itself).
|
|
321
|
-
const filterCompatible = useCallback(
|
|
322
|
-
(dataType: DataType) =>
|
|
323
|
-
availableVars.filter((v) => v.dataType === dataType && !("nodeId" in v && v.nodeId === node.id)),
|
|
324
|
-
[availableVars, node.id],
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
// Any rows to show? Early-exit so the parent panel doesn't render an empty section.
|
|
328
|
-
if (staticOutputs.length === 0 && listOutputs.length === 0) return null;
|
|
329
|
-
|
|
330
|
-
// --- Row renderer: static output / FunctionCall return ------------------------------
|
|
331
|
-
// UX shape:
|
|
332
|
-
// ┌─ card ────────────────────────────────────────┐
|
|
333
|
-
// │ [☑] label dataType │
|
|
334
|
-
// │ [emit | assign] [ name OR variable picker ] │
|
|
335
|
-
// └────────────────────────────────────────────────┘
|
|
336
|
-
// Checkbox sits inline with the label (so the card width matches list-entry rows
|
|
337
|
-
// which have no checkbox). Toggles `binding.active`; mode/name/target are kept as
|
|
338
|
-
// draft state when inactive so off→on round-trips identically. Body is dimmed and
|
|
339
|
-
// pointer-event-disabled while inactive.
|
|
340
|
-
const renderStaticRow = (key: string, output: { name: string; dataType: DataType }, displayLabel: string) => {
|
|
341
|
-
const binding = getOutputBinding(node, key) ?? ({ active: true, mode: "emit", name: output.name } as OutputBinding);
|
|
342
|
-
const compatibleVars = filterCompatible(output.dataType);
|
|
343
|
-
const errors = outputErrors.get(key);
|
|
344
|
-
const hasError = !!errors?.length;
|
|
345
|
-
const enabled = binding.active;
|
|
346
|
-
|
|
347
|
-
const setEnabled = (next: boolean) => {
|
|
348
|
-
if (next === enabled) return;
|
|
349
|
-
updateStaticBinding(key, { ...binding, active: next });
|
|
350
|
-
};
|
|
351
|
-
const setMode = (mode: "emit" | "assign") => {
|
|
352
|
-
if (mode === binding.mode) return;
|
|
353
|
-
if (mode === "emit") updateStaticBinding(key, { active: binding.active, mode: "emit", name: output.name });
|
|
354
|
-
else updateStaticBinding(key, { active: binding.active, mode: "assign", target: { srcId: "", varId: "" } });
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
return (
|
|
358
|
-
<div
|
|
359
|
-
key={key}
|
|
360
|
-
className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
|
|
361
|
-
>
|
|
362
|
-
<div className="flex items-center gap-2">
|
|
363
|
-
<Checkbox
|
|
364
|
-
checked={enabled}
|
|
365
|
-
onCheckedChange={(c) => setEnabled(c === true)}
|
|
366
|
-
aria-label={t("outputBinding.enable", "Enable output")}
|
|
367
|
-
className="shrink-0"
|
|
368
|
-
/>
|
|
369
|
-
<span className={`text-xs font-medium flex-1 truncate ${enabled ? "" : "opacity-50"}`}>{displayLabel}</span>
|
|
370
|
-
<span className={`text-xs text-muted-foreground ${enabled ? "" : "opacity-50"}`}>
|
|
371
|
-
{DATA_TYPE_LABELS[output.dataType]}
|
|
372
|
-
</span>
|
|
373
|
-
</div>
|
|
374
|
-
<div className={enabled ? "" : "opacity-50"}>
|
|
375
|
-
<div className={`flex items-center gap-2 ${enabled ? "" : "pointer-events-none"}`}>
|
|
376
|
-
<ToggleGroup
|
|
377
|
-
type="single"
|
|
378
|
-
value={binding.mode}
|
|
379
|
-
onValueChange={(v) => v && setMode(v as "emit" | "assign")}
|
|
380
|
-
className="gap-0"
|
|
381
|
-
>
|
|
382
|
-
<ToggleGroupItem
|
|
383
|
-
value="emit"
|
|
384
|
-
size="sm"
|
|
385
|
-
variant="outline"
|
|
386
|
-
className="h-7 w-7 p-0 rounded-r-none"
|
|
387
|
-
aria-label={t("outputBinding.emit", "Emit")}
|
|
388
|
-
title={t("outputBinding.emit", "Emit")}
|
|
389
|
-
>
|
|
390
|
-
<Plus className="w-3.5 h-3.5" />
|
|
391
|
-
</ToggleGroupItem>
|
|
392
|
-
<ToggleGroupItem
|
|
393
|
-
value="assign"
|
|
394
|
-
size="sm"
|
|
395
|
-
variant="outline"
|
|
396
|
-
className="h-7 w-7 p-0 rounded-l-none -ml-px"
|
|
397
|
-
aria-label={t("outputBinding.assign", "Assign")}
|
|
398
|
-
title={t("outputBinding.assign", "Assign")}
|
|
399
|
-
>
|
|
400
|
-
<ArrowRight className="w-3.5 h-3.5" />
|
|
401
|
-
</ToggleGroupItem>
|
|
402
|
-
</ToggleGroup>
|
|
403
|
-
|
|
404
|
-
{binding.mode === "emit" ? (
|
|
405
|
-
<Input
|
|
406
|
-
className="h-7 text-xs flex-1"
|
|
407
|
-
value={binding.name}
|
|
408
|
-
disabled={!enabled}
|
|
409
|
-
onChange={(e) =>
|
|
410
|
-
updateStaticBinding(key, { active: binding.active, mode: "emit", name: e.target.value })
|
|
411
|
-
}
|
|
412
|
-
/>
|
|
413
|
-
) : compatibleVars.length > 0 ? (
|
|
414
|
-
<Select
|
|
415
|
-
value={binding.target.srcId ? refToLookupKey(binding.target) : ""}
|
|
416
|
-
onValueChange={(lookupKey) => {
|
|
417
|
-
const v = availableVars.find((av) => varKey(av) === lookupKey);
|
|
418
|
-
if (!v) return;
|
|
419
|
-
updateStaticBinding(key, { active: binding.active, mode: "assign", target: availableVarToRef(v) });
|
|
420
|
-
}}
|
|
421
|
-
disabled={!enabled}
|
|
422
|
-
>
|
|
423
|
-
<SelectTrigger className="h-7 text-xs flex-1">
|
|
424
|
-
<SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
|
|
425
|
-
</SelectTrigger>
|
|
426
|
-
<SelectContent>
|
|
427
|
-
{compatibleVars.map((v) => (
|
|
428
|
-
<SelectItem key={varKey(v)} value={varKey(v)}>
|
|
429
|
-
{v.name}
|
|
430
|
-
</SelectItem>
|
|
431
|
-
))}
|
|
432
|
-
</SelectContent>
|
|
433
|
-
</Select>
|
|
434
|
-
) : (
|
|
435
|
-
<span className="text-xs text-muted-foreground italic flex-1">
|
|
436
|
-
{t("outputBinding.noCompatibleVariables", "No compatible variables")}
|
|
437
|
-
</span>
|
|
438
|
-
)}
|
|
439
|
-
</div>
|
|
440
|
-
{hasError && (
|
|
441
|
-
<div className="space-y-0.5">
|
|
442
|
-
{errors.map((msg, i) => (
|
|
443
|
-
<p key={i} className="text-xs text-destructive">
|
|
444
|
-
{msg}
|
|
445
|
-
</p>
|
|
446
|
-
))}
|
|
447
|
-
</div>
|
|
448
|
-
)}
|
|
449
|
-
</div>
|
|
450
|
-
</div>
|
|
451
|
-
);
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
// --- Row renderer: list declaration entry ------------------------------------------
|
|
455
|
-
const renderListEntryRow = (listId: string, entries: OutputDeclaration[], index: number) => {
|
|
456
|
-
const entry = entries[index];
|
|
457
|
-
if (!entry) return null;
|
|
458
|
-
const compatibleVars = filterCompatible(entry.dataType);
|
|
459
|
-
const outputId = listEntryOutputId(listId, index);
|
|
460
|
-
const errors = outputErrors.get(outputId);
|
|
461
|
-
const hasError = !!errors?.length;
|
|
462
|
-
|
|
463
|
-
const replace = (next: OutputDeclaration) =>
|
|
464
|
-
writeListEntries(
|
|
465
|
-
listId,
|
|
466
|
-
entries.map((e, i) => (i === index ? next : e)),
|
|
467
|
-
);
|
|
468
|
-
const remove = () =>
|
|
469
|
-
writeListEntries(
|
|
470
|
-
listId,
|
|
471
|
-
entries.filter((_, i) => i !== index),
|
|
472
|
-
);
|
|
473
|
-
// Mode flip preserves name + dataType — the user already typed/picked them.
|
|
474
|
-
// emit↔assign only swaps the trailing payload (uid vs target).
|
|
475
|
-
const changeMode = (newMode: "emit" | "assign") => {
|
|
476
|
-
if (entry.mode === newMode) return;
|
|
477
|
-
if (newMode === "emit") {
|
|
478
|
-
replace({ mode: "emit", uid: generateId(), name: entry.name, dataType: entry.dataType });
|
|
479
|
-
} else {
|
|
480
|
-
replace({ mode: "assign", name: entry.name, dataType: entry.dataType, target: { srcId: "", varId: "" } });
|
|
481
|
-
}
|
|
482
|
-
};
|
|
483
|
-
const changeName = (name: string) => replace({ ...entry, name });
|
|
484
|
-
const changeDataType = (dt: DataType) => replace({ ...entry, dataType: dt });
|
|
485
|
-
|
|
486
|
-
return (
|
|
487
|
-
<div
|
|
488
|
-
key={`${listId}-${entry.mode === "emit" ? entry.uid : `assign-${index}`}`}
|
|
489
|
-
className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
|
|
490
|
-
>
|
|
491
|
-
<div className="flex items-center gap-2">
|
|
492
|
-
{/* Row 1 mirrors OutputBinding's header: leading control (trash↔checkbox),
|
|
493
|
-
name (editable here, label-only there), dataType (editable here, RO there). */}
|
|
494
|
-
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={remove}>
|
|
495
|
-
<Trash2 className="w-3.5 h-3.5" />
|
|
496
|
-
</Button>
|
|
497
|
-
<Input
|
|
498
|
-
className="h-7 text-xs flex-1"
|
|
499
|
-
value={entry.name}
|
|
500
|
-
placeholder={t("outputBinding.name", "Name")}
|
|
501
|
-
onChange={(e) => changeName(e.target.value)}
|
|
502
|
-
/>
|
|
503
|
-
<Select value={entry.dataType} onValueChange={(dt: DataType) => changeDataType(dt)}>
|
|
504
|
-
<SelectTrigger className="h-7 text-xs w-20">
|
|
505
|
-
<SelectValue />
|
|
506
|
-
</SelectTrigger>
|
|
507
|
-
<SelectContent>
|
|
508
|
-
{DATA_TYPES.map((dt) => (
|
|
509
|
-
<SelectItem key={dt} value={dt}>
|
|
510
|
-
{dt}
|
|
511
|
-
</SelectItem>
|
|
512
|
-
))}
|
|
513
|
-
</SelectContent>
|
|
514
|
-
</Select>
|
|
515
|
-
</div>
|
|
516
|
-
<div className="flex items-center gap-2">
|
|
517
|
-
<ToggleGroup
|
|
518
|
-
type="single"
|
|
519
|
-
value={entry.mode}
|
|
520
|
-
onValueChange={(v) => v && changeMode(v as "emit" | "assign")}
|
|
521
|
-
className="gap-0"
|
|
522
|
-
>
|
|
523
|
-
<ToggleGroupItem
|
|
524
|
-
value="emit"
|
|
525
|
-
size="sm"
|
|
526
|
-
variant="outline"
|
|
527
|
-
className="h-7 w-7 p-0 rounded-r-none"
|
|
528
|
-
aria-label={t("outputBinding.emit", "Emit")}
|
|
529
|
-
title={t("outputBinding.emit", "Emit")}
|
|
530
|
-
>
|
|
531
|
-
<Plus className="w-3.5 h-3.5" />
|
|
532
|
-
</ToggleGroupItem>
|
|
533
|
-
<ToggleGroupItem
|
|
534
|
-
value="assign"
|
|
535
|
-
size="sm"
|
|
536
|
-
variant="outline"
|
|
537
|
-
className="h-7 w-7 p-0 rounded-l-none -ml-px"
|
|
538
|
-
aria-label={t("outputBinding.assign", "Assign")}
|
|
539
|
-
title={t("outputBinding.assign", "Assign")}
|
|
540
|
-
>
|
|
541
|
-
<ArrowRight className="w-3.5 h-3.5" />
|
|
542
|
-
</ToggleGroupItem>
|
|
543
|
-
</ToggleGroup>
|
|
544
|
-
{entry.mode === "emit" ? (
|
|
545
|
-
<span className="text-xs text-muted-foreground italic flex-1">
|
|
546
|
-
{t("outputBinding.emitHint", "creates new variable in scope")}
|
|
547
|
-
</span>
|
|
548
|
-
) : compatibleVars.length > 0 ? (
|
|
549
|
-
<Select
|
|
550
|
-
value={entry.target.srcId ? refToLookupKey(entry.target) : ""}
|
|
551
|
-
onValueChange={(lookupKey) => {
|
|
552
|
-
const v = availableVars.find((av) => varKey(av) === lookupKey);
|
|
553
|
-
if (!v) return;
|
|
554
|
-
replace({ ...entry, target: availableVarToRef(v) });
|
|
555
|
-
}}
|
|
556
|
-
>
|
|
557
|
-
<SelectTrigger className="h-7 text-xs flex-1">
|
|
558
|
-
<SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
|
|
559
|
-
</SelectTrigger>
|
|
560
|
-
<SelectContent>
|
|
561
|
-
{compatibleVars.map((v) => (
|
|
562
|
-
<SelectItem key={varKey(v)} value={varKey(v)}>
|
|
563
|
-
{v.name}
|
|
564
|
-
</SelectItem>
|
|
565
|
-
))}
|
|
566
|
-
</SelectContent>
|
|
567
|
-
</Select>
|
|
568
|
-
) : (
|
|
569
|
-
<span className="text-xs text-muted-foreground italic flex-1">
|
|
570
|
-
{t("outputBinding.noCompatibleVariables", "No compatible variables")}
|
|
571
|
-
</span>
|
|
572
|
-
)}
|
|
573
|
-
</div>
|
|
574
|
-
{hasError && (
|
|
575
|
-
<div className="space-y-0.5">
|
|
576
|
-
{errors.map((msg, i) => (
|
|
577
|
-
<p key={i} className="text-xs text-destructive">
|
|
578
|
-
{msg}
|
|
579
|
-
</p>
|
|
580
|
-
))}
|
|
581
|
-
</div>
|
|
582
|
-
)}
|
|
583
|
-
</div>
|
|
584
|
-
);
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
const addListEntry = (listId: string, entries: OutputDeclaration[]) => {
|
|
588
|
-
const fresh: OutputDeclaration = {
|
|
589
|
-
mode: "emit",
|
|
590
|
-
uid: generateId(),
|
|
591
|
-
name: `output${entries.length + 1}`,
|
|
592
|
-
dataType: "string",
|
|
593
|
-
};
|
|
594
|
-
writeListEntries(listId, [...entries, fresh]);
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
return (
|
|
598
|
-
<div className="space-y-2">
|
|
599
|
-
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t("outputs", "Outputs")}</p>
|
|
600
|
-
<div className="space-y-2">
|
|
601
|
-
{/* Static outputs from the node definition (FunctionCall returns flow here too). */}
|
|
602
|
-
{staticOutputs.map((out) => {
|
|
603
|
-
const output = availableOutput[out.id];
|
|
604
|
-
if (!output) return null;
|
|
605
|
-
return renderStaticRow(out.id, output, out.label);
|
|
606
|
-
})}
|
|
607
|
-
|
|
608
|
-
{/* List outputs — each list gets a minor subheader (its label), then its entries,
|
|
609
|
-
then an "Add" button. The subheader also serves as the visual boundary between
|
|
610
|
-
static outputs above and list entries below. */}
|
|
611
|
-
{listOutputs.map((out) => {
|
|
612
|
-
const entries =
|
|
613
|
-
((node.arguments as Record<string, unknown>)[out.id] as OutputDeclaration[] | undefined) ?? [];
|
|
614
|
-
return (
|
|
615
|
-
<Fragment key={out.id}>
|
|
616
|
-
<div className="flex items-center gap-2 pt-1">
|
|
617
|
-
<span className="text-[11px] font-medium text-muted-foreground">{out.label}</span>
|
|
618
|
-
<div className="flex-1 h-px bg-border/60" />
|
|
619
|
-
</div>
|
|
620
|
-
{entries.map((_, index) => renderListEntryRow(out.id, entries, index))}
|
|
621
|
-
<AddButton onClick={() => addListEntry(out.id, entries)}>
|
|
622
|
-
{t("addOutput", { label: out.label, defaultValue: `Add ${out.label.toLowerCase()}` })}
|
|
623
|
-
</AddButton>
|
|
624
|
-
</Fragment>
|
|
625
|
-
);
|
|
626
|
-
})}
|
|
627
|
-
</div>
|
|
628
|
-
</div>
|
|
629
|
-
);
|
|
630
|
-
}
|
|
1
|
+
import { Button } from "../components/ui/button";
|
|
2
|
+
import { AddButton } from "../components/ui/add-button";
|
|
3
|
+
import { ReadOnlyBanner } from "../components/ui/readonly-banner";
|
|
4
|
+
import { DeleteButton } from "../components/ui/delete-button";
|
|
5
|
+
import { Checkbox } from "../components/ui/checkbox";
|
|
6
|
+
import { Input } from "../components/ui/input";
|
|
7
|
+
import { Separator } from "../components/ui/separator";
|
|
8
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
|
9
|
+
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
|
|
10
|
+
import {
|
|
11
|
+
NodeDefinition,
|
|
12
|
+
NodeData,
|
|
13
|
+
OutputBinding,
|
|
14
|
+
FunctionCallNode,
|
|
15
|
+
getArguments,
|
|
16
|
+
getNodeAvailableOutput,
|
|
17
|
+
getOutputBinding,
|
|
18
|
+
} from "@foresthubai/workflow-core/node";
|
|
19
|
+
import type { DataType, Reference } from "@foresthubai/workflow-core";
|
|
20
|
+
import type { StaticOutput, OutputList, OutputDeclaration } from "@foresthubai/workflow-core/parameter";
|
|
21
|
+
import { isParameterActive, Parameter } from "@foresthubai/workflow-core/parameter";
|
|
22
|
+
import { generateId } from "@foresthubai/workflow-core/id";
|
|
23
|
+
import { ArrowRight, ChevronRight, Plus, RefreshCw, Trash2 } from "lucide-react";
|
|
24
|
+
import { getOrCreateCanvasStore } from "../stores/canvasStore";
|
|
25
|
+
import { isNodeUsedAsTool } from "@foresthubai/workflow-core/node";
|
|
26
|
+
import { varKey, refToLookupKey } from "@foresthubai/workflow-core/variable";
|
|
27
|
+
import type { Diagnostic } from "@foresthubai/workflow-core/diagnostics";
|
|
28
|
+
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
|
29
|
+
import { useTranslation } from "react-i18next";
|
|
30
|
+
import ParameterEditor from "../inputs/ParameterEditor";
|
|
31
|
+
import { useDiagnosticsStore } from "../stores/diagnosticsStore";
|
|
32
|
+
import { useFunctionRegistry } from "../hooks/useFunctionRegistry";
|
|
33
|
+
import { useParamErrors } from "../hooks/useParamErrors";
|
|
34
|
+
import { buildFunctionNodeDef } from "../hooks/useNodeDefinitions";
|
|
35
|
+
import { useEditorStore } from "../stores/editorStore";
|
|
36
|
+
import { isReadOnly } from "../WorkflowBuilder";
|
|
37
|
+
import { migrateFunctionCallNodes } from "../utils/migrateFunctionNodes";
|
|
38
|
+
import { getNodeDescription } from "../utils/translation";
|
|
39
|
+
import { useAvailableVariables } from "../hooks/useAvailableVariables";
|
|
40
|
+
|
|
41
|
+
interface NodeConfigPanelProps {
|
|
42
|
+
canvasId: string;
|
|
43
|
+
selectedNode: NodeData;
|
|
44
|
+
onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown>; label?: string }) => void;
|
|
45
|
+
onNodeDelete: (nodeId: string) => void;
|
|
46
|
+
onClose: () => void;
|
|
47
|
+
onOpenTest: (nodeId: string) => void;
|
|
48
|
+
getNodeDef: (node: NodeData) => NodeDefinition | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const NodeConfigPanel = ({
|
|
52
|
+
canvasId,
|
|
53
|
+
selectedNode,
|
|
54
|
+
onNodeUpdate,
|
|
55
|
+
onNodeDelete,
|
|
56
|
+
onClose,
|
|
57
|
+
onOpenTest,
|
|
58
|
+
getNodeDef,
|
|
59
|
+
}: NodeConfigPanelProps) => {
|
|
60
|
+
const { t } = useTranslation();
|
|
61
|
+
const readOnly = useEditorStore((s) => isReadOnly(s.builderMode));
|
|
62
|
+
|
|
63
|
+
// Local state for label input to preserve cursor position
|
|
64
|
+
const [localLabel, setLocalLabel] = useState(selectedNode.label || "");
|
|
65
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
setLocalLabel(selectedNode.label || "");
|
|
68
|
+
}, [selectedNode.id, selectedNode.label]);
|
|
69
|
+
|
|
70
|
+
// Check if FunctionCall node is stale (e.g. after undo reverted migration)
|
|
71
|
+
const functionId = selectedNode.type === "FunctionCall" ? (selectedNode as FunctionCallNode).functionInfo.id : null;
|
|
72
|
+
const { getFunction } = useFunctionRegistry();
|
|
73
|
+
const functionInfo = functionId ? getFunction(functionId) : undefined;
|
|
74
|
+
const isStaleFunction = (() => {
|
|
75
|
+
if (selectedNode.type !== "FunctionCall" || !functionInfo) return false;
|
|
76
|
+
const functionNode = selectedNode as FunctionCallNode;
|
|
77
|
+
return functionNode.functionInfo.version !== functionInfo.version;
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
// Get node definition - for FunctionCallNodes, build from node's stored functionInfo
|
|
81
|
+
const nodeDefinition =
|
|
82
|
+
selectedNode.type === "FunctionCall"
|
|
83
|
+
? buildFunctionNodeDef({
|
|
84
|
+
...(selectedNode as FunctionCallNode).functionInfo,
|
|
85
|
+
name: functionInfo?.name ?? (selectedNode as FunctionCallNode).functionInfo.name,
|
|
86
|
+
})
|
|
87
|
+
: getNodeDef(selectedNode);
|
|
88
|
+
const cannotDelete = nodeDefinition?.isUnremovable ?? false;
|
|
89
|
+
|
|
90
|
+
// Detect whether this node is currently used as a tool input
|
|
91
|
+
const edges = getOrCreateCanvasStore(canvasId)((s) => s.edges);
|
|
92
|
+
const usedAsToolInput = useMemo(() => isNodeUsedAsTool(selectedNode.id, selectedNode, edges), [selectedNode, edges]);
|
|
93
|
+
|
|
94
|
+
// Read per-parameter error state from diagnostics store
|
|
95
|
+
const nodeDiags = useDiagnosticsStore((s) => s.byNodeId[selectedNode.id]);
|
|
96
|
+
const paramErrors = useParamErrors(nodeDiags);
|
|
97
|
+
|
|
98
|
+
if (!nodeDefinition) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="p-4">
|
|
101
|
+
<p className="text-sm text-muted-foreground">{t("unknownNodeType", { type: selectedNode.type })}</p>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const allArguments = getArguments(selectedNode);
|
|
106
|
+
const parameters = nodeDefinition.parameters.filter((p) => isParameterActive(p, allArguments, usedAsToolInput));
|
|
107
|
+
// OutputsSection self-hides when there's nothing to render; gate on "has any output defined".
|
|
108
|
+
const hasAnyOutputs = (nodeDefinition.outputs ?? []).length > 0;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="p-4">
|
|
112
|
+
<div className="space-y-4">
|
|
113
|
+
<div className="flex items-center justify-between gap-2">
|
|
114
|
+
<div className="flex-1 min-w-0">
|
|
115
|
+
<div className="group flex items-center gap-1.5 rounded-md border border-transparent px-1.5 -mx-1.5 hover:border-input focus-within:border-input transition-colors">
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
title={t("nodeLabel")}
|
|
119
|
+
className="font-semibold text-lg bg-transparent w-full outline-none cursor-text py-0.5"
|
|
120
|
+
value={isFocused ? localLabel : localLabel || nodeDefinition.label}
|
|
121
|
+
readOnly={readOnly}
|
|
122
|
+
onFocus={() => {
|
|
123
|
+
if (readOnly) return;
|
|
124
|
+
setIsFocused(true);
|
|
125
|
+
if (!localLabel) {
|
|
126
|
+
setLocalLabel(nodeDefinition.label);
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
onBlur={() => {
|
|
130
|
+
setIsFocused(false);
|
|
131
|
+
if (!localLabel || localLabel === nodeDefinition.label) {
|
|
132
|
+
setLocalLabel("");
|
|
133
|
+
onNodeUpdate(selectedNode.id, { label: undefined });
|
|
134
|
+
}
|
|
135
|
+
}}
|
|
136
|
+
onChange={(e) => {
|
|
137
|
+
setLocalLabel(e.target.value);
|
|
138
|
+
onNodeUpdate(selectedNode.id, { label: e.target.value });
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<p className="text-sm text-muted-foreground">{getNodeDescription(t, nodeDefinition)}</p>
|
|
143
|
+
</div>
|
|
144
|
+
<Button variant="ghost" size="icon" className="shrink-0" onClick={onClose}>
|
|
145
|
+
<ChevronRight className="h-4 w-4" />
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{readOnly && <ReadOnlyBanner />}
|
|
150
|
+
|
|
151
|
+
{parameters.length > 0 && (
|
|
152
|
+
<>
|
|
153
|
+
<Separator />
|
|
154
|
+
<div className={`space-y-3 ${readOnly ? "pointer-events-none opacity-60" : ""}`}>
|
|
155
|
+
{parameters.map((param: Parameter) => (
|
|
156
|
+
<ParameterEditor
|
|
157
|
+
canvasId={canvasId}
|
|
158
|
+
key={param.id}
|
|
159
|
+
parameter={param}
|
|
160
|
+
value={allArguments[param.id]}
|
|
161
|
+
allArguments={allArguments}
|
|
162
|
+
onChange={(value) => onNodeUpdate(selectedNode.id, { arguments: { [param.id]: value } })}
|
|
163
|
+
errors={paramErrors.get(param.id)}
|
|
164
|
+
translationPrefix={`nodes.${selectedNode.type}`}
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{!readOnly && isStaleFunction && (
|
|
172
|
+
<>
|
|
173
|
+
<Separator />
|
|
174
|
+
<Button
|
|
175
|
+
variant="outline"
|
|
176
|
+
className="w-full border-warning text-warning hover:bg-warning/10"
|
|
177
|
+
onClick={() => migrateFunctionCallNodes()}
|
|
178
|
+
>
|
|
179
|
+
<RefreshCw className="w-4 h-4 mr-2" />
|
|
180
|
+
{t("updateToLatestDefinition")}
|
|
181
|
+
</Button>
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{!readOnly && hasAnyOutputs && !usedAsToolInput && (
|
|
186
|
+
<>
|
|
187
|
+
<Separator />
|
|
188
|
+
<OutputsSection
|
|
189
|
+
canvasId={canvasId}
|
|
190
|
+
node={selectedNode}
|
|
191
|
+
nodeDefinition={nodeDefinition}
|
|
192
|
+
onNodeUpdate={onNodeUpdate}
|
|
193
|
+
nodeDiags={nodeDiags}
|
|
194
|
+
/>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{!readOnly && selectedNode.type === "Agent" && (
|
|
199
|
+
<>
|
|
200
|
+
<Separator />
|
|
201
|
+
<Button variant="outline" className="w-full" onClick={() => onOpenTest(selectedNode.id)}>
|
|
202
|
+
{t("testAgent")}
|
|
203
|
+
</Button>
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{!readOnly && !cannotDelete && (
|
|
208
|
+
<>
|
|
209
|
+
<Separator />
|
|
210
|
+
<DeleteButton onClick={() => onNodeDelete(selectedNode.id)}>{t("deleteNode")}</DeleteButton>
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// Outputs Section
|
|
220
|
+
// ============================================================================
|
|
221
|
+
//
|
|
222
|
+
// Single unified section for all of a node's outputs. Renders under one "OUTPUTS"
|
|
223
|
+
// header with sequential rows — no sub-sections, no per-list separator. Two row
|
|
224
|
+
// shapes live side by side here:
|
|
225
|
+
//
|
|
226
|
+
// 1. Static output row — rows for NodeDefinition.outputs.type === "static"
|
|
227
|
+
// (FunctionCall returns are just StaticOutputs — they
|
|
228
|
+
// flow through this same path). Active checkbox toggles
|
|
229
|
+
// binding.active; mode toggle picks emit vs. assign.
|
|
230
|
+
// 2. List declaration row — rows for each entry in an OutputList's backing array
|
|
231
|
+
// (mode: emit/assign, own dataType selector, own delete button)
|
|
232
|
+
//
|
|
233
|
+
// List outputs also render an "+ Add" button after their entries so new declarations
|
|
234
|
+
// can be appended. Errors on any row (static or list) apply a destructive ring using
|
|
235
|
+
// the same `outputId` key diagnostics.ts produces.
|
|
236
|
+
|
|
237
|
+
const DATA_TYPES: DataType[] = ["int", "float", "bool", "string"];
|
|
238
|
+
|
|
239
|
+
const DATA_TYPE_LABELS: Record<DataType, string> = {
|
|
240
|
+
int: "int",
|
|
241
|
+
float: "float",
|
|
242
|
+
bool: "bool",
|
|
243
|
+
string: "string",
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Stable synthetic outputId used to key list-entry diagnostics (matches diagnostics.ts). */
|
|
247
|
+
function listEntryOutputId(listId: string, index: number): string {
|
|
248
|
+
return `${listId}[${index}]`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Convert an AvailableVariable into a canonical Reference (srcId + varId). */
|
|
252
|
+
function availableVarToRef(
|
|
253
|
+
v:
|
|
254
|
+
| { kind: "node"; nodeId: string; outputId: string }
|
|
255
|
+
| { kind: "declared"; uid: string }
|
|
256
|
+
| { kind: "fnarg"; uid: string },
|
|
257
|
+
): Reference {
|
|
258
|
+
if (v.kind === "node") return { srcId: v.nodeId, varId: v.outputId };
|
|
259
|
+
if (v.kind === "declared") return { srcId: "declared", varId: v.uid };
|
|
260
|
+
return { srcId: "fnarg", varId: v.uid };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function OutputsSection({
|
|
264
|
+
canvasId,
|
|
265
|
+
node,
|
|
266
|
+
nodeDefinition,
|
|
267
|
+
onNodeUpdate,
|
|
268
|
+
nodeDiags,
|
|
269
|
+
}: {
|
|
270
|
+
canvasId: string;
|
|
271
|
+
node: NodeData;
|
|
272
|
+
nodeDefinition: NodeDefinition;
|
|
273
|
+
onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown> }) => void;
|
|
274
|
+
nodeDiags: Diagnostic[] | undefined;
|
|
275
|
+
}) {
|
|
276
|
+
const { t } = useTranslation();
|
|
277
|
+
const { list: availableVars } = useAvailableVariables(canvasId);
|
|
278
|
+
const availableOutput = useMemo(() => getNodeAvailableOutput(node), [node]);
|
|
279
|
+
|
|
280
|
+
const staticOutputs = useMemo(
|
|
281
|
+
() => (nodeDefinition.outputs ?? []).filter((o): o is StaticOutput => o.type === "static"),
|
|
282
|
+
[nodeDefinition],
|
|
283
|
+
);
|
|
284
|
+
const listOutputs = useMemo(
|
|
285
|
+
() => (nodeDefinition.outputs ?? []).filter((o): o is OutputList => o.type === "list"),
|
|
286
|
+
[nodeDefinition],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Collect diagnostics keyed by outputId so rows can look themselves up.
|
|
290
|
+
const outputErrors = useMemo(() => {
|
|
291
|
+
const map = new Map<string, string[]>();
|
|
292
|
+
if (!nodeDiags) return map;
|
|
293
|
+
for (const d of nodeDiags) {
|
|
294
|
+
if (d.outputId && d.severity === "error") {
|
|
295
|
+
const arr = map.get(d.outputId);
|
|
296
|
+
if (arr) arr.push(d.message);
|
|
297
|
+
else map.set(d.outputId, [d.message]);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return map;
|
|
301
|
+
}, [nodeDiags]);
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Write a binding for a static output (or FunctionCall return). List entries
|
|
305
|
+
* have their own update path via `replaceListEntry`.
|
|
306
|
+
*/
|
|
307
|
+
const updateStaticBinding = useCallback(
|
|
308
|
+
(key: string, binding: OutputBinding) => {
|
|
309
|
+
onNodeUpdate(node.id, { arguments: { [key]: binding } });
|
|
310
|
+
},
|
|
311
|
+
[node.id, onNodeUpdate],
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const writeListEntries = useCallback(
|
|
315
|
+
(listId: string, next: OutputDeclaration[]) => onNodeUpdate(node.id, { arguments: { [listId]: next } }),
|
|
316
|
+
[node.id, onNodeUpdate],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// Filter available vars to matching dataType, excluding this node's own outputs
|
|
320
|
+
// (a node can't assign its own output back into itself).
|
|
321
|
+
const filterCompatible = useCallback(
|
|
322
|
+
(dataType: DataType) =>
|
|
323
|
+
availableVars.filter((v) => v.dataType === dataType && !("nodeId" in v && v.nodeId === node.id)),
|
|
324
|
+
[availableVars, node.id],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Any rows to show? Early-exit so the parent panel doesn't render an empty section.
|
|
328
|
+
if (staticOutputs.length === 0 && listOutputs.length === 0) return null;
|
|
329
|
+
|
|
330
|
+
// --- Row renderer: static output / FunctionCall return ------------------------------
|
|
331
|
+
// UX shape:
|
|
332
|
+
// ┌─ card ────────────────────────────────────────┐
|
|
333
|
+
// │ [☑] label dataType │
|
|
334
|
+
// │ [emit | assign] [ name OR variable picker ] │
|
|
335
|
+
// └────────────────────────────────────────────────┘
|
|
336
|
+
// Checkbox sits inline with the label (so the card width matches list-entry rows
|
|
337
|
+
// which have no checkbox). Toggles `binding.active`; mode/name/target are kept as
|
|
338
|
+
// draft state when inactive so off→on round-trips identically. Body is dimmed and
|
|
339
|
+
// pointer-event-disabled while inactive.
|
|
340
|
+
const renderStaticRow = (key: string, output: { name: string; dataType: DataType }, displayLabel: string) => {
|
|
341
|
+
const binding = getOutputBinding(node, key) ?? ({ active: true, mode: "emit", name: output.name } as OutputBinding);
|
|
342
|
+
const compatibleVars = filterCompatible(output.dataType);
|
|
343
|
+
const errors = outputErrors.get(key);
|
|
344
|
+
const hasError = !!errors?.length;
|
|
345
|
+
const enabled = binding.active;
|
|
346
|
+
|
|
347
|
+
const setEnabled = (next: boolean) => {
|
|
348
|
+
if (next === enabled) return;
|
|
349
|
+
updateStaticBinding(key, { ...binding, active: next });
|
|
350
|
+
};
|
|
351
|
+
const setMode = (mode: "emit" | "assign") => {
|
|
352
|
+
if (mode === binding.mode) return;
|
|
353
|
+
if (mode === "emit") updateStaticBinding(key, { active: binding.active, mode: "emit", name: output.name });
|
|
354
|
+
else updateStaticBinding(key, { active: binding.active, mode: "assign", target: { srcId: "", varId: "" } });
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<div
|
|
359
|
+
key={key}
|
|
360
|
+
className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
|
|
361
|
+
>
|
|
362
|
+
<div className="flex items-center gap-2">
|
|
363
|
+
<Checkbox
|
|
364
|
+
checked={enabled}
|
|
365
|
+
onCheckedChange={(c) => setEnabled(c === true)}
|
|
366
|
+
aria-label={t("outputBinding.enable", "Enable output")}
|
|
367
|
+
className="shrink-0"
|
|
368
|
+
/>
|
|
369
|
+
<span className={`text-xs font-medium flex-1 truncate ${enabled ? "" : "opacity-50"}`}>{displayLabel}</span>
|
|
370
|
+
<span className={`text-xs text-muted-foreground ${enabled ? "" : "opacity-50"}`}>
|
|
371
|
+
{DATA_TYPE_LABELS[output.dataType]}
|
|
372
|
+
</span>
|
|
373
|
+
</div>
|
|
374
|
+
<div className={enabled ? "" : "opacity-50"}>
|
|
375
|
+
<div className={`flex items-center gap-2 ${enabled ? "" : "pointer-events-none"}`}>
|
|
376
|
+
<ToggleGroup
|
|
377
|
+
type="single"
|
|
378
|
+
value={binding.mode}
|
|
379
|
+
onValueChange={(v) => v && setMode(v as "emit" | "assign")}
|
|
380
|
+
className="gap-0"
|
|
381
|
+
>
|
|
382
|
+
<ToggleGroupItem
|
|
383
|
+
value="emit"
|
|
384
|
+
size="sm"
|
|
385
|
+
variant="outline"
|
|
386
|
+
className="h-7 w-7 p-0 rounded-r-none"
|
|
387
|
+
aria-label={t("outputBinding.emit", "Emit")}
|
|
388
|
+
title={t("outputBinding.emit", "Emit")}
|
|
389
|
+
>
|
|
390
|
+
<Plus className="w-3.5 h-3.5" />
|
|
391
|
+
</ToggleGroupItem>
|
|
392
|
+
<ToggleGroupItem
|
|
393
|
+
value="assign"
|
|
394
|
+
size="sm"
|
|
395
|
+
variant="outline"
|
|
396
|
+
className="h-7 w-7 p-0 rounded-l-none -ml-px"
|
|
397
|
+
aria-label={t("outputBinding.assign", "Assign")}
|
|
398
|
+
title={t("outputBinding.assign", "Assign")}
|
|
399
|
+
>
|
|
400
|
+
<ArrowRight className="w-3.5 h-3.5" />
|
|
401
|
+
</ToggleGroupItem>
|
|
402
|
+
</ToggleGroup>
|
|
403
|
+
|
|
404
|
+
{binding.mode === "emit" ? (
|
|
405
|
+
<Input
|
|
406
|
+
className="h-7 text-xs flex-1"
|
|
407
|
+
value={binding.name}
|
|
408
|
+
disabled={!enabled}
|
|
409
|
+
onChange={(e) =>
|
|
410
|
+
updateStaticBinding(key, { active: binding.active, mode: "emit", name: e.target.value })
|
|
411
|
+
}
|
|
412
|
+
/>
|
|
413
|
+
) : compatibleVars.length > 0 ? (
|
|
414
|
+
<Select
|
|
415
|
+
value={binding.target.srcId ? refToLookupKey(binding.target) : ""}
|
|
416
|
+
onValueChange={(lookupKey) => {
|
|
417
|
+
const v = availableVars.find((av) => varKey(av) === lookupKey);
|
|
418
|
+
if (!v) return;
|
|
419
|
+
updateStaticBinding(key, { active: binding.active, mode: "assign", target: availableVarToRef(v) });
|
|
420
|
+
}}
|
|
421
|
+
disabled={!enabled}
|
|
422
|
+
>
|
|
423
|
+
<SelectTrigger className="h-7 text-xs flex-1">
|
|
424
|
+
<SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
|
|
425
|
+
</SelectTrigger>
|
|
426
|
+
<SelectContent>
|
|
427
|
+
{compatibleVars.map((v) => (
|
|
428
|
+
<SelectItem key={varKey(v)} value={varKey(v)}>
|
|
429
|
+
{v.name}
|
|
430
|
+
</SelectItem>
|
|
431
|
+
))}
|
|
432
|
+
</SelectContent>
|
|
433
|
+
</Select>
|
|
434
|
+
) : (
|
|
435
|
+
<span className="text-xs text-muted-foreground italic flex-1">
|
|
436
|
+
{t("outputBinding.noCompatibleVariables", "No compatible variables")}
|
|
437
|
+
</span>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
{hasError && (
|
|
441
|
+
<div className="space-y-0.5">
|
|
442
|
+
{errors.map((msg, i) => (
|
|
443
|
+
<p key={i} className="text-xs text-destructive">
|
|
444
|
+
{msg}
|
|
445
|
+
</p>
|
|
446
|
+
))}
|
|
447
|
+
</div>
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// --- Row renderer: list declaration entry ------------------------------------------
|
|
455
|
+
const renderListEntryRow = (listId: string, entries: OutputDeclaration[], index: number) => {
|
|
456
|
+
const entry = entries[index];
|
|
457
|
+
if (!entry) return null;
|
|
458
|
+
const compatibleVars = filterCompatible(entry.dataType);
|
|
459
|
+
const outputId = listEntryOutputId(listId, index);
|
|
460
|
+
const errors = outputErrors.get(outputId);
|
|
461
|
+
const hasError = !!errors?.length;
|
|
462
|
+
|
|
463
|
+
const replace = (next: OutputDeclaration) =>
|
|
464
|
+
writeListEntries(
|
|
465
|
+
listId,
|
|
466
|
+
entries.map((e, i) => (i === index ? next : e)),
|
|
467
|
+
);
|
|
468
|
+
const remove = () =>
|
|
469
|
+
writeListEntries(
|
|
470
|
+
listId,
|
|
471
|
+
entries.filter((_, i) => i !== index),
|
|
472
|
+
);
|
|
473
|
+
// Mode flip preserves name + dataType — the user already typed/picked them.
|
|
474
|
+
// emit↔assign only swaps the trailing payload (uid vs target).
|
|
475
|
+
const changeMode = (newMode: "emit" | "assign") => {
|
|
476
|
+
if (entry.mode === newMode) return;
|
|
477
|
+
if (newMode === "emit") {
|
|
478
|
+
replace({ mode: "emit", uid: generateId(), name: entry.name, dataType: entry.dataType });
|
|
479
|
+
} else {
|
|
480
|
+
replace({ mode: "assign", name: entry.name, dataType: entry.dataType, target: { srcId: "", varId: "" } });
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
const changeName = (name: string) => replace({ ...entry, name });
|
|
484
|
+
const changeDataType = (dt: DataType) => replace({ ...entry, dataType: dt });
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<div
|
|
488
|
+
key={`${listId}-${entry.mode === "emit" ? entry.uid : `assign-${index}`}`}
|
|
489
|
+
className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
|
|
490
|
+
>
|
|
491
|
+
<div className="flex items-center gap-2">
|
|
492
|
+
{/* Row 1 mirrors OutputBinding's header: leading control (trash↔checkbox),
|
|
493
|
+
name (editable here, label-only there), dataType (editable here, RO there). */}
|
|
494
|
+
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={remove}>
|
|
495
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
496
|
+
</Button>
|
|
497
|
+
<Input
|
|
498
|
+
className="h-7 text-xs flex-1"
|
|
499
|
+
value={entry.name}
|
|
500
|
+
placeholder={t("outputBinding.name", "Name")}
|
|
501
|
+
onChange={(e) => changeName(e.target.value)}
|
|
502
|
+
/>
|
|
503
|
+
<Select value={entry.dataType} onValueChange={(dt: DataType) => changeDataType(dt)}>
|
|
504
|
+
<SelectTrigger className="h-7 text-xs w-20">
|
|
505
|
+
<SelectValue />
|
|
506
|
+
</SelectTrigger>
|
|
507
|
+
<SelectContent>
|
|
508
|
+
{DATA_TYPES.map((dt) => (
|
|
509
|
+
<SelectItem key={dt} value={dt}>
|
|
510
|
+
{dt}
|
|
511
|
+
</SelectItem>
|
|
512
|
+
))}
|
|
513
|
+
</SelectContent>
|
|
514
|
+
</Select>
|
|
515
|
+
</div>
|
|
516
|
+
<div className="flex items-center gap-2">
|
|
517
|
+
<ToggleGroup
|
|
518
|
+
type="single"
|
|
519
|
+
value={entry.mode}
|
|
520
|
+
onValueChange={(v) => v && changeMode(v as "emit" | "assign")}
|
|
521
|
+
className="gap-0"
|
|
522
|
+
>
|
|
523
|
+
<ToggleGroupItem
|
|
524
|
+
value="emit"
|
|
525
|
+
size="sm"
|
|
526
|
+
variant="outline"
|
|
527
|
+
className="h-7 w-7 p-0 rounded-r-none"
|
|
528
|
+
aria-label={t("outputBinding.emit", "Emit")}
|
|
529
|
+
title={t("outputBinding.emit", "Emit")}
|
|
530
|
+
>
|
|
531
|
+
<Plus className="w-3.5 h-3.5" />
|
|
532
|
+
</ToggleGroupItem>
|
|
533
|
+
<ToggleGroupItem
|
|
534
|
+
value="assign"
|
|
535
|
+
size="sm"
|
|
536
|
+
variant="outline"
|
|
537
|
+
className="h-7 w-7 p-0 rounded-l-none -ml-px"
|
|
538
|
+
aria-label={t("outputBinding.assign", "Assign")}
|
|
539
|
+
title={t("outputBinding.assign", "Assign")}
|
|
540
|
+
>
|
|
541
|
+
<ArrowRight className="w-3.5 h-3.5" />
|
|
542
|
+
</ToggleGroupItem>
|
|
543
|
+
</ToggleGroup>
|
|
544
|
+
{entry.mode === "emit" ? (
|
|
545
|
+
<span className="text-xs text-muted-foreground italic flex-1">
|
|
546
|
+
{t("outputBinding.emitHint", "creates new variable in scope")}
|
|
547
|
+
</span>
|
|
548
|
+
) : compatibleVars.length > 0 ? (
|
|
549
|
+
<Select
|
|
550
|
+
value={entry.target.srcId ? refToLookupKey(entry.target) : ""}
|
|
551
|
+
onValueChange={(lookupKey) => {
|
|
552
|
+
const v = availableVars.find((av) => varKey(av) === lookupKey);
|
|
553
|
+
if (!v) return;
|
|
554
|
+
replace({ ...entry, target: availableVarToRef(v) });
|
|
555
|
+
}}
|
|
556
|
+
>
|
|
557
|
+
<SelectTrigger className="h-7 text-xs flex-1">
|
|
558
|
+
<SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
|
|
559
|
+
</SelectTrigger>
|
|
560
|
+
<SelectContent>
|
|
561
|
+
{compatibleVars.map((v) => (
|
|
562
|
+
<SelectItem key={varKey(v)} value={varKey(v)}>
|
|
563
|
+
{v.name}
|
|
564
|
+
</SelectItem>
|
|
565
|
+
))}
|
|
566
|
+
</SelectContent>
|
|
567
|
+
</Select>
|
|
568
|
+
) : (
|
|
569
|
+
<span className="text-xs text-muted-foreground italic flex-1">
|
|
570
|
+
{t("outputBinding.noCompatibleVariables", "No compatible variables")}
|
|
571
|
+
</span>
|
|
572
|
+
)}
|
|
573
|
+
</div>
|
|
574
|
+
{hasError && (
|
|
575
|
+
<div className="space-y-0.5">
|
|
576
|
+
{errors.map((msg, i) => (
|
|
577
|
+
<p key={i} className="text-xs text-destructive">
|
|
578
|
+
{msg}
|
|
579
|
+
</p>
|
|
580
|
+
))}
|
|
581
|
+
</div>
|
|
582
|
+
)}
|
|
583
|
+
</div>
|
|
584
|
+
);
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const addListEntry = (listId: string, entries: OutputDeclaration[]) => {
|
|
588
|
+
const fresh: OutputDeclaration = {
|
|
589
|
+
mode: "emit",
|
|
590
|
+
uid: generateId(),
|
|
591
|
+
name: `output${entries.length + 1}`,
|
|
592
|
+
dataType: "string",
|
|
593
|
+
};
|
|
594
|
+
writeListEntries(listId, [...entries, fresh]);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
return (
|
|
598
|
+
<div className="space-y-2">
|
|
599
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t("outputs", "Outputs")}</p>
|
|
600
|
+
<div className="space-y-2">
|
|
601
|
+
{/* Static outputs from the node definition (FunctionCall returns flow here too). */}
|
|
602
|
+
{staticOutputs.map((out) => {
|
|
603
|
+
const output = availableOutput[out.id];
|
|
604
|
+
if (!output) return null;
|
|
605
|
+
return renderStaticRow(out.id, output, out.label);
|
|
606
|
+
})}
|
|
607
|
+
|
|
608
|
+
{/* List outputs — each list gets a minor subheader (its label), then its entries,
|
|
609
|
+
then an "Add" button. The subheader also serves as the visual boundary between
|
|
610
|
+
static outputs above and list entries below. */}
|
|
611
|
+
{listOutputs.map((out) => {
|
|
612
|
+
const entries =
|
|
613
|
+
((node.arguments as Record<string, unknown>)[out.id] as OutputDeclaration[] | undefined) ?? [];
|
|
614
|
+
return (
|
|
615
|
+
<Fragment key={out.id}>
|
|
616
|
+
<div className="flex items-center gap-2 pt-1">
|
|
617
|
+
<span className="text-[11px] font-medium text-muted-foreground">{out.label}</span>
|
|
618
|
+
<div className="flex-1 h-px bg-border/60" />
|
|
619
|
+
</div>
|
|
620
|
+
{entries.map((_, index) => renderListEntryRow(out.id, entries, index))}
|
|
621
|
+
<AddButton onClick={() => addListEntry(out.id, entries)}>
|
|
622
|
+
{t("addOutput", { label: out.label, defaultValue: `Add ${out.label.toLowerCase()}` })}
|
|
623
|
+
</AddButton>
|
|
624
|
+
</Fragment>
|
|
625
|
+
);
|
|
626
|
+
})}
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|