@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
package/src/index.ts
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
// Main component + its contract
|
|
2
|
-
export { WorkflowBuilder } from "./WorkflowBuilder";
|
|
3
|
-
export type { WorkflowBuilderProps, WorkflowBuilderHandle } from "./WorkflowBuilder";
|
|
4
|
-
|
|
5
|
-
// Editor mode the embedder constructs and passes via setMode / initialMode
|
|
6
|
-
export type { BuilderMode } from "./WorkflowBuilder";
|
|
7
|
-
|
|
8
|
-
// Debug phase the embedder pushes from the engine via setDebugPhase
|
|
9
|
-
export type { DebugSessionPhase } from "./stores/debugStore";
|
|
10
|
-
|
|
11
|
-
// Validation result types. handle.validate() presents results itself (toast when
|
|
12
|
-
// clean, the dialog below otherwise); these are exported for embedder-side tooling.
|
|
13
|
-
export type { ValidationResult, Diagnostic, CanvasValidationResult } from "@foresthubai/workflow-core/diagnostics";
|
|
14
|
-
|
|
15
|
-
// The validation dialog the builder renders on validate(). Also exported for
|
|
16
|
-
// embedders that drive their own validation flow.
|
|
17
|
-
export { default as ValidationDialog } from "./dialogs/ValidationDialog";
|
|
18
|
-
|
|
19
|
-
// Post a toast to the builder's notification surface. The builder mounts its own
|
|
20
|
-
// <Toaster> (for internal notices like singleton-node rejection); exporting this
|
|
21
|
-
// lets the embedder render host-level notices (save/load errors) in the SAME
|
|
22
|
-
// toaster, so they share one style and one surface. shadcn API:
|
|
23
|
-
// toast({ title, description?, variant?: "default" | "destructive" }).
|
|
24
|
-
export { toast } from "./hooks/use-toast";
|
|
25
|
-
|
|
26
|
-
// Workflow snapshot type that crosses the boundary via loadWorkflow / exportWorkflow
|
|
27
|
-
export type { ApiWorkflow as Workflow } from "@foresthubai/workflow-core/workflow";
|
|
1
|
+
// Main component + its contract
|
|
2
|
+
export { WorkflowBuilder } from "./WorkflowBuilder";
|
|
3
|
+
export type { WorkflowBuilderProps, WorkflowBuilderHandle } from "./WorkflowBuilder";
|
|
4
|
+
|
|
5
|
+
// Editor mode the embedder constructs and passes via setMode / initialMode
|
|
6
|
+
export type { BuilderMode } from "./WorkflowBuilder";
|
|
7
|
+
|
|
8
|
+
// Debug phase the embedder pushes from the engine via setDebugPhase
|
|
9
|
+
export type { DebugSessionPhase } from "./stores/debugStore";
|
|
10
|
+
|
|
11
|
+
// Validation result types. handle.validate() presents results itself (toast when
|
|
12
|
+
// clean, the dialog below otherwise); these are exported for embedder-side tooling.
|
|
13
|
+
export type { ValidationResult, Diagnostic, CanvasValidationResult } from "@foresthubai/workflow-core/diagnostics";
|
|
14
|
+
|
|
15
|
+
// The validation dialog the builder renders on validate(). Also exported for
|
|
16
|
+
// embedders that drive their own validation flow.
|
|
17
|
+
export { default as ValidationDialog } from "./dialogs/ValidationDialog";
|
|
18
|
+
|
|
19
|
+
// Post a toast to the builder's notification surface. The builder mounts its own
|
|
20
|
+
// <Toaster> (for internal notices like singleton-node rejection); exporting this
|
|
21
|
+
// lets the embedder render host-level notices (save/load errors) in the SAME
|
|
22
|
+
// toaster, so they share one style and one surface. shadcn API:
|
|
23
|
+
// toast({ title, description?, variant?: "default" | "destructive" }).
|
|
24
|
+
export { toast } from "./hooks/use-toast";
|
|
25
|
+
|
|
26
|
+
// Workflow snapshot type that crosses the boundary via loadWorkflow / exportWorkflow
|
|
27
|
+
export type { ApiWorkflow as Workflow } from "@foresthubai/workflow-core/workflow";
|
|
@@ -1,297 +1,297 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
2
|
-
import { useTranslation } from "react-i18next";
|
|
3
|
-
import { NodeRegistry } from "@foresthubai/workflow-core/node";
|
|
4
|
-
import type { DataType, Expression, Reference } from "@foresthubai/workflow-core";
|
|
5
|
-
import { ResolvedExpr, resolveExpression } from "@foresthubai/workflow-core/expression";
|
|
6
|
-
import { varKey, type Variable } from "@foresthubai/workflow-core/variable";
|
|
7
|
-
import { useEditorStore } from "../stores/editorStore";
|
|
8
|
-
import { getOrCreateCanvasStore } from "../stores/canvasStore";
|
|
9
|
-
import { cn } from "../cn";
|
|
10
|
-
|
|
11
|
-
interface ExpressionInputProps {
|
|
12
|
-
value: Expression;
|
|
13
|
-
onChange: (value: Expression) => void;
|
|
14
|
-
expressionType: DataType;
|
|
15
|
-
availableVariables: Record<string, Variable>;
|
|
16
|
-
placeholder?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const ExpressionInput = ({
|
|
20
|
-
value: apiValue,
|
|
21
|
-
onChange,
|
|
22
|
-
expressionType,
|
|
23
|
-
availableVariables,
|
|
24
|
-
placeholder,
|
|
25
|
-
}: ExpressionInputProps) => {
|
|
26
|
-
const { t } = useTranslation();
|
|
27
|
-
const resolvedPlaceholder = placeholder ?? "${var1}";
|
|
28
|
-
// Convert to resolved expression for internal use
|
|
29
|
-
const value = useMemo((): ResolvedExpr => {
|
|
30
|
-
return resolveExpression(apiValue, availableVariables);
|
|
31
|
-
}, [apiValue, availableVariables]);
|
|
32
|
-
|
|
33
|
-
// Convert Record to array for iteration
|
|
34
|
-
const variableList = useMemo(() => Object.values(availableVariables), [availableVariables]);
|
|
35
|
-
|
|
36
|
-
const [inputValue, setInputValue] = useState(value?.expression ?? "");
|
|
37
|
-
const [showDropdown, setShowDropdown] = useState(false);
|
|
38
|
-
const [dropdownPosition, setDropdownPosition] = useState(0);
|
|
39
|
-
const [filterText, setFilterText] = useState("");
|
|
40
|
-
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
41
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
|
-
const isEditingRef = useRef(false);
|
|
44
|
-
|
|
45
|
-
// Filter variables based on what user typed after $
|
|
46
|
-
const filteredVariables = useMemo(
|
|
47
|
-
() => variableList.filter((v) => v.name.toLowerCase().includes(filterText.toLowerCase())),
|
|
48
|
-
[variableList, filterText],
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
// nodeId → user-facing display name for the active canvas, used to label
|
|
52
|
-
// node-output variables in the dropdown. Falls back to the node definition's
|
|
53
|
-
// human label, never the schema-internal `type`. Resolved lazily when the
|
|
54
|
-
// dropdown opens (labels can't change while it's open) so typing in an
|
|
55
|
-
// expression doesn't subscribe this input to every node mutation.
|
|
56
|
-
const activeCanvasId = useEditorStore((s) => s.activeCanvasId);
|
|
57
|
-
const nodeLabelById = useMemo(() => {
|
|
58
|
-
const map: Record<string, string> = {};
|
|
59
|
-
if (!showDropdown) return map;
|
|
60
|
-
for (const n of getOrCreateCanvasStore(activeCanvasId).getState().nodes) {
|
|
61
|
-
map[n.id] = n.data.label ?? NodeRegistry.getByType(n.data.type)?.label ?? n.data.type;
|
|
62
|
-
}
|
|
63
|
-
return map;
|
|
64
|
-
}, [showDropdown, activeCanvasId]);
|
|
65
|
-
|
|
66
|
-
// Find a variable by name from variableList
|
|
67
|
-
const findVariableByName = useCallback(
|
|
68
|
-
(name: string): Variable | undefined => {
|
|
69
|
-
return variableList.find((v) => v.name === name);
|
|
70
|
-
},
|
|
71
|
-
[variableList],
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
// Parse display expression to API expression format
|
|
75
|
-
// Converts ${name} syntax to ${} placeholders with references array
|
|
76
|
-
const parseToApiExpression = useCallback(
|
|
77
|
-
(displayExpr: string): Expression => {
|
|
78
|
-
const references: Reference[] = [];
|
|
79
|
-
const regex = /\$\{([^}]+)\}/g;
|
|
80
|
-
let match;
|
|
81
|
-
let expressionWithPlaceholders = displayExpr;
|
|
82
|
-
|
|
83
|
-
// First pass: collect all variable names and their positions
|
|
84
|
-
const matches: { name: string; start: number; end: number }[] = [];
|
|
85
|
-
while ((match = regex.exec(displayExpr)) !== null) {
|
|
86
|
-
matches.push({ name: match[1] ?? "", start: match.index, end: match.index + match[0].length });
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Build expression with empty placeholders and collect references
|
|
90
|
-
let offset = 0;
|
|
91
|
-
for (const m of matches) {
|
|
92
|
-
const availableVar = findVariableByName(m.name);
|
|
93
|
-
if (availableVar) {
|
|
94
|
-
if (availableVar.kind === "node") {
|
|
95
|
-
references.push({ srcId: availableVar.nodeId, varId: availableVar.outputId });
|
|
96
|
-
} else if (availableVar.kind === "fnarg") {
|
|
97
|
-
references.push({ srcId: "fnarg", varId: availableVar.uid });
|
|
98
|
-
} else {
|
|
99
|
-
references.push({ srcId: "declared", varId: availableVar.uid });
|
|
100
|
-
}
|
|
101
|
-
} else {
|
|
102
|
-
// Variable not found - use invalid reference
|
|
103
|
-
references.push({ srcId: "", varId: "" });
|
|
104
|
-
}
|
|
105
|
-
// Replace ${name} with ${}
|
|
106
|
-
const before = expressionWithPlaceholders.slice(0, m.start + offset);
|
|
107
|
-
const after = expressionWithPlaceholders.slice(m.end + offset);
|
|
108
|
-
expressionWithPlaceholders = before + "${}" + after;
|
|
109
|
-
// Adjust offset: original was ${name} (3 + name.length), new is ${} (3)
|
|
110
|
-
offset -= m.name.length;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return { expression: expressionWithPlaceholders, references, dataType: expressionType };
|
|
114
|
-
},
|
|
115
|
-
[findVariableByName, expressionType],
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
// Resolve expression display by filling ${} placeholders with variable names
|
|
119
|
-
const resolveExpressionDisplay = useCallback((expr: ResolvedExpr | undefined): string => {
|
|
120
|
-
if (!expr) return "";
|
|
121
|
-
|
|
122
|
-
let result = expr.expression;
|
|
123
|
-
let varIndex = 0;
|
|
124
|
-
|
|
125
|
-
// Replace each ${} with the corresponding variable's name
|
|
126
|
-
result = result.replace(/\$\{\}/g, () => {
|
|
127
|
-
if (varIndex < expr.variables.length) {
|
|
128
|
-
const variable = expr.variables[varIndex];
|
|
129
|
-
varIndex++;
|
|
130
|
-
const name = variable?.name ?? "unknown";
|
|
131
|
-
return `\${${name}}`;
|
|
132
|
-
}
|
|
133
|
-
return "${}"; // No variable for this placeholder
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
return result;
|
|
137
|
-
}, []);
|
|
138
|
-
|
|
139
|
-
// Only sync from external value when NOT actively editing
|
|
140
|
-
useEffect(() => {
|
|
141
|
-
if (isEditingRef.current) return;
|
|
142
|
-
const resolved = resolveExpressionDisplay(value);
|
|
143
|
-
if (resolved !== inputValue) {
|
|
144
|
-
setInputValue(resolved);
|
|
145
|
-
}
|
|
146
|
-
}, [value, resolveExpressionDisplay, inputValue]);
|
|
147
|
-
|
|
148
|
-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
149
|
-
isEditingRef.current = true;
|
|
150
|
-
const newValue = e.target.value;
|
|
151
|
-
const cursorPos = e.target.selectionStart ?? 0;
|
|
152
|
-
setInputValue(newValue);
|
|
153
|
-
|
|
154
|
-
// Check if user just typed $ or is mid-variable reference
|
|
155
|
-
const textBeforeCursor = newValue.slice(0, cursorPos);
|
|
156
|
-
const dollarIndex = textBeforeCursor.lastIndexOf("$");
|
|
157
|
-
|
|
158
|
-
if (dollarIndex !== -1) {
|
|
159
|
-
const textAfterDollar = textBeforeCursor.slice(dollarIndex + 1);
|
|
160
|
-
// Skip if this $ is part of an already-closed ${...} reference
|
|
161
|
-
const isClosedRef = textAfterDollar.startsWith("{") && textAfterDollar.includes("}");
|
|
162
|
-
if (
|
|
163
|
-
!isClosedRef &&
|
|
164
|
-
(textAfterDollar === "" || textAfterDollar.startsWith("{") || /^[a-zA-Z_]/.test(textAfterDollar))
|
|
165
|
-
) {
|
|
166
|
-
const filter = textAfterDollar.replace(/^\{?/, "");
|
|
167
|
-
setFilterText(filter);
|
|
168
|
-
setDropdownPosition(dollarIndex);
|
|
169
|
-
setShowDropdown(true);
|
|
170
|
-
setSelectedIndex(0);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
setShowDropdown(false);
|
|
176
|
-
onChange(parseToApiExpression(newValue));
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
// Handle blur - mark editing as finished and commit final value
|
|
180
|
-
const handleBlur = () => {
|
|
181
|
-
isEditingRef.current = false;
|
|
182
|
-
onChange(parseToApiExpression(inputValue));
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const selectVariable = (variable: Variable) => {
|
|
186
|
-
const beforeDollar = inputValue.slice(0, dropdownPosition);
|
|
187
|
-
const afterCursor = inputValue.slice(inputRef.current?.selectionStart ?? inputValue.length);
|
|
188
|
-
|
|
189
|
-
// Remove any partial variable name after $
|
|
190
|
-
const cleanAfter = afterCursor.replace(/^[{]?[a-zA-Z_]*[}]?/, "");
|
|
191
|
-
|
|
192
|
-
const newValue = `${beforeDollar}\${${variable.name}}${cleanAfter}`;
|
|
193
|
-
setInputValue(newValue);
|
|
194
|
-
setShowDropdown(false);
|
|
195
|
-
onChange(parseToApiExpression(newValue));
|
|
196
|
-
|
|
197
|
-
// Focus back to input
|
|
198
|
-
setTimeout(() => {
|
|
199
|
-
inputRef.current?.focus();
|
|
200
|
-
const newCursorPos = beforeDollar.length + variable.name.length + 3;
|
|
201
|
-
inputRef.current?.setSelectionRange(newCursorPos, newCursorPos);
|
|
202
|
-
}, 0);
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
// Handle keyboard navigation in dropdown
|
|
206
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
207
|
-
if (!showDropdown) return;
|
|
208
|
-
|
|
209
|
-
switch (e.key) {
|
|
210
|
-
case "ArrowDown":
|
|
211
|
-
e.preventDefault();
|
|
212
|
-
setSelectedIndex((prev) => Math.min(prev + 1, filteredVariables.length - 1));
|
|
213
|
-
break;
|
|
214
|
-
case "ArrowUp":
|
|
215
|
-
e.preventDefault();
|
|
216
|
-
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
217
|
-
break;
|
|
218
|
-
case "Enter":
|
|
219
|
-
case "Tab":
|
|
220
|
-
e.preventDefault();
|
|
221
|
-
if (filteredVariables[selectedIndex]) {
|
|
222
|
-
selectVariable(filteredVariables[selectedIndex]);
|
|
223
|
-
}
|
|
224
|
-
break;
|
|
225
|
-
case "Escape":
|
|
226
|
-
setShowDropdown(false);
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
// Close dropdown when clicking outside
|
|
232
|
-
useEffect(() => {
|
|
233
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
234
|
-
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
235
|
-
setShowDropdown(false);
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
239
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
240
|
-
}, []);
|
|
241
|
-
|
|
242
|
-
return (
|
|
243
|
-
<div ref={containerRef}>
|
|
244
|
-
<div className="relative">
|
|
245
|
-
<input
|
|
246
|
-
ref={inputRef}
|
|
247
|
-
type="text"
|
|
248
|
-
value={inputValue}
|
|
249
|
-
onChange={handleInputChange}
|
|
250
|
-
onKeyDown={handleKeyDown}
|
|
251
|
-
onBlur={handleBlur}
|
|
252
|
-
placeholder={resolvedPlaceholder}
|
|
253
|
-
className="w-full h-9 px-3 py-1 text-sm bg-field border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
|
254
|
-
/>
|
|
255
|
-
|
|
256
|
-
{/* Variable dropdown */}
|
|
257
|
-
{showDropdown && filteredVariables.length > 0 && (
|
|
258
|
-
<div className="absolute z-50 mt-1 w-full bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-auto">
|
|
259
|
-
{filteredVariables.map((variable, index) => (
|
|
260
|
-
<button
|
|
261
|
-
key={varKey(variable)}
|
|
262
|
-
onClick={() => selectVariable(variable)}
|
|
263
|
-
className={cn(
|
|
264
|
-
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-accent",
|
|
265
|
-
index === selectedIndex && "bg-accent",
|
|
266
|
-
)}
|
|
267
|
-
>
|
|
268
|
-
<div className="flex flex-col">
|
|
269
|
-
<span className="font-mono">
|
|
270
|
-
${"{"}
|
|
271
|
-
{variable.name}
|
|
272
|
-
{"}"}
|
|
273
|
-
</span>
|
|
274
|
-
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
|
275
|
-
{variable.kind === "node"
|
|
276
|
-
? t("fromNode", { node: nodeLabelById[variable.nodeId] ?? variable.nodeId })
|
|
277
|
-
: variable.kind === "declared"
|
|
278
|
-
? t("globalVariable")
|
|
279
|
-
: t("fnarg")}
|
|
280
|
-
</span>
|
|
281
|
-
</div>
|
|
282
|
-
<span className="text-xs text-muted-foreground">{variable.dataType}</span>
|
|
283
|
-
</button>
|
|
284
|
-
))}
|
|
285
|
-
</div>
|
|
286
|
-
)}
|
|
287
|
-
|
|
288
|
-
{/* Type indicator */}
|
|
289
|
-
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
|
|
290
|
-
<span className="text-xs text-muted-foreground">→ {expressionType}</span>
|
|
291
|
-
</div>
|
|
292
|
-
</div>
|
|
293
|
-
</div>
|
|
294
|
-
);
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
export default ExpressionInput;
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
import { NodeRegistry } from "@foresthubai/workflow-core/node";
|
|
4
|
+
import type { DataType, Expression, Reference } from "@foresthubai/workflow-core";
|
|
5
|
+
import { ResolvedExpr, resolveExpression } from "@foresthubai/workflow-core/expression";
|
|
6
|
+
import { varKey, type Variable } from "@foresthubai/workflow-core/variable";
|
|
7
|
+
import { useEditorStore } from "../stores/editorStore";
|
|
8
|
+
import { getOrCreateCanvasStore } from "../stores/canvasStore";
|
|
9
|
+
import { cn } from "../cn";
|
|
10
|
+
|
|
11
|
+
interface ExpressionInputProps {
|
|
12
|
+
value: Expression;
|
|
13
|
+
onChange: (value: Expression) => void;
|
|
14
|
+
expressionType: DataType;
|
|
15
|
+
availableVariables: Record<string, Variable>;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ExpressionInput = ({
|
|
20
|
+
value: apiValue,
|
|
21
|
+
onChange,
|
|
22
|
+
expressionType,
|
|
23
|
+
availableVariables,
|
|
24
|
+
placeholder,
|
|
25
|
+
}: ExpressionInputProps) => {
|
|
26
|
+
const { t } = useTranslation();
|
|
27
|
+
const resolvedPlaceholder = placeholder ?? "${var1}";
|
|
28
|
+
// Convert to resolved expression for internal use
|
|
29
|
+
const value = useMemo((): ResolvedExpr => {
|
|
30
|
+
return resolveExpression(apiValue, availableVariables);
|
|
31
|
+
}, [apiValue, availableVariables]);
|
|
32
|
+
|
|
33
|
+
// Convert Record to array for iteration
|
|
34
|
+
const variableList = useMemo(() => Object.values(availableVariables), [availableVariables]);
|
|
35
|
+
|
|
36
|
+
const [inputValue, setInputValue] = useState(value?.expression ?? "");
|
|
37
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
38
|
+
const [dropdownPosition, setDropdownPosition] = useState(0);
|
|
39
|
+
const [filterText, setFilterText] = useState("");
|
|
40
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
41
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const isEditingRef = useRef(false);
|
|
44
|
+
|
|
45
|
+
// Filter variables based on what user typed after $
|
|
46
|
+
const filteredVariables = useMemo(
|
|
47
|
+
() => variableList.filter((v) => v.name.toLowerCase().includes(filterText.toLowerCase())),
|
|
48
|
+
[variableList, filterText],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// nodeId → user-facing display name for the active canvas, used to label
|
|
52
|
+
// node-output variables in the dropdown. Falls back to the node definition's
|
|
53
|
+
// human label, never the schema-internal `type`. Resolved lazily when the
|
|
54
|
+
// dropdown opens (labels can't change while it's open) so typing in an
|
|
55
|
+
// expression doesn't subscribe this input to every node mutation.
|
|
56
|
+
const activeCanvasId = useEditorStore((s) => s.activeCanvasId);
|
|
57
|
+
const nodeLabelById = useMemo(() => {
|
|
58
|
+
const map: Record<string, string> = {};
|
|
59
|
+
if (!showDropdown) return map;
|
|
60
|
+
for (const n of getOrCreateCanvasStore(activeCanvasId).getState().nodes) {
|
|
61
|
+
map[n.id] = n.data.label ?? NodeRegistry.getByType(n.data.type)?.label ?? n.data.type;
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}, [showDropdown, activeCanvasId]);
|
|
65
|
+
|
|
66
|
+
// Find a variable by name from variableList
|
|
67
|
+
const findVariableByName = useCallback(
|
|
68
|
+
(name: string): Variable | undefined => {
|
|
69
|
+
return variableList.find((v) => v.name === name);
|
|
70
|
+
},
|
|
71
|
+
[variableList],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Parse display expression to API expression format
|
|
75
|
+
// Converts ${name} syntax to ${} placeholders with references array
|
|
76
|
+
const parseToApiExpression = useCallback(
|
|
77
|
+
(displayExpr: string): Expression => {
|
|
78
|
+
const references: Reference[] = [];
|
|
79
|
+
const regex = /\$\{([^}]+)\}/g;
|
|
80
|
+
let match;
|
|
81
|
+
let expressionWithPlaceholders = displayExpr;
|
|
82
|
+
|
|
83
|
+
// First pass: collect all variable names and their positions
|
|
84
|
+
const matches: { name: string; start: number; end: number }[] = [];
|
|
85
|
+
while ((match = regex.exec(displayExpr)) !== null) {
|
|
86
|
+
matches.push({ name: match[1] ?? "", start: match.index, end: match.index + match[0].length });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build expression with empty placeholders and collect references
|
|
90
|
+
let offset = 0;
|
|
91
|
+
for (const m of matches) {
|
|
92
|
+
const availableVar = findVariableByName(m.name);
|
|
93
|
+
if (availableVar) {
|
|
94
|
+
if (availableVar.kind === "node") {
|
|
95
|
+
references.push({ srcId: availableVar.nodeId, varId: availableVar.outputId });
|
|
96
|
+
} else if (availableVar.kind === "fnarg") {
|
|
97
|
+
references.push({ srcId: "fnarg", varId: availableVar.uid });
|
|
98
|
+
} else {
|
|
99
|
+
references.push({ srcId: "declared", varId: availableVar.uid });
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// Variable not found - use invalid reference
|
|
103
|
+
references.push({ srcId: "", varId: "" });
|
|
104
|
+
}
|
|
105
|
+
// Replace ${name} with ${}
|
|
106
|
+
const before = expressionWithPlaceholders.slice(0, m.start + offset);
|
|
107
|
+
const after = expressionWithPlaceholders.slice(m.end + offset);
|
|
108
|
+
expressionWithPlaceholders = before + "${}" + after;
|
|
109
|
+
// Adjust offset: original was ${name} (3 + name.length), new is ${} (3)
|
|
110
|
+
offset -= m.name.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { expression: expressionWithPlaceholders, references, dataType: expressionType };
|
|
114
|
+
},
|
|
115
|
+
[findVariableByName, expressionType],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Resolve expression display by filling ${} placeholders with variable names
|
|
119
|
+
const resolveExpressionDisplay = useCallback((expr: ResolvedExpr | undefined): string => {
|
|
120
|
+
if (!expr) return "";
|
|
121
|
+
|
|
122
|
+
let result = expr.expression;
|
|
123
|
+
let varIndex = 0;
|
|
124
|
+
|
|
125
|
+
// Replace each ${} with the corresponding variable's name
|
|
126
|
+
result = result.replace(/\$\{\}/g, () => {
|
|
127
|
+
if (varIndex < expr.variables.length) {
|
|
128
|
+
const variable = expr.variables[varIndex];
|
|
129
|
+
varIndex++;
|
|
130
|
+
const name = variable?.name ?? "unknown";
|
|
131
|
+
return `\${${name}}`;
|
|
132
|
+
}
|
|
133
|
+
return "${}"; // No variable for this placeholder
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
// Only sync from external value when NOT actively editing
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (isEditingRef.current) return;
|
|
142
|
+
const resolved = resolveExpressionDisplay(value);
|
|
143
|
+
if (resolved !== inputValue) {
|
|
144
|
+
setInputValue(resolved);
|
|
145
|
+
}
|
|
146
|
+
}, [value, resolveExpressionDisplay, inputValue]);
|
|
147
|
+
|
|
148
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
149
|
+
isEditingRef.current = true;
|
|
150
|
+
const newValue = e.target.value;
|
|
151
|
+
const cursorPos = e.target.selectionStart ?? 0;
|
|
152
|
+
setInputValue(newValue);
|
|
153
|
+
|
|
154
|
+
// Check if user just typed $ or is mid-variable reference
|
|
155
|
+
const textBeforeCursor = newValue.slice(0, cursorPos);
|
|
156
|
+
const dollarIndex = textBeforeCursor.lastIndexOf("$");
|
|
157
|
+
|
|
158
|
+
if (dollarIndex !== -1) {
|
|
159
|
+
const textAfterDollar = textBeforeCursor.slice(dollarIndex + 1);
|
|
160
|
+
// Skip if this $ is part of an already-closed ${...} reference
|
|
161
|
+
const isClosedRef = textAfterDollar.startsWith("{") && textAfterDollar.includes("}");
|
|
162
|
+
if (
|
|
163
|
+
!isClosedRef &&
|
|
164
|
+
(textAfterDollar === "" || textAfterDollar.startsWith("{") || /^[a-zA-Z_]/.test(textAfterDollar))
|
|
165
|
+
) {
|
|
166
|
+
const filter = textAfterDollar.replace(/^\{?/, "");
|
|
167
|
+
setFilterText(filter);
|
|
168
|
+
setDropdownPosition(dollarIndex);
|
|
169
|
+
setShowDropdown(true);
|
|
170
|
+
setSelectedIndex(0);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setShowDropdown(false);
|
|
176
|
+
onChange(parseToApiExpression(newValue));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Handle blur - mark editing as finished and commit final value
|
|
180
|
+
const handleBlur = () => {
|
|
181
|
+
isEditingRef.current = false;
|
|
182
|
+
onChange(parseToApiExpression(inputValue));
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const selectVariable = (variable: Variable) => {
|
|
186
|
+
const beforeDollar = inputValue.slice(0, dropdownPosition);
|
|
187
|
+
const afterCursor = inputValue.slice(inputRef.current?.selectionStart ?? inputValue.length);
|
|
188
|
+
|
|
189
|
+
// Remove any partial variable name after $
|
|
190
|
+
const cleanAfter = afterCursor.replace(/^[{]?[a-zA-Z_]*[}]?/, "");
|
|
191
|
+
|
|
192
|
+
const newValue = `${beforeDollar}\${${variable.name}}${cleanAfter}`;
|
|
193
|
+
setInputValue(newValue);
|
|
194
|
+
setShowDropdown(false);
|
|
195
|
+
onChange(parseToApiExpression(newValue));
|
|
196
|
+
|
|
197
|
+
// Focus back to input
|
|
198
|
+
setTimeout(() => {
|
|
199
|
+
inputRef.current?.focus();
|
|
200
|
+
const newCursorPos = beforeDollar.length + variable.name.length + 3;
|
|
201
|
+
inputRef.current?.setSelectionRange(newCursorPos, newCursorPos);
|
|
202
|
+
}, 0);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Handle keyboard navigation in dropdown
|
|
206
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
207
|
+
if (!showDropdown) return;
|
|
208
|
+
|
|
209
|
+
switch (e.key) {
|
|
210
|
+
case "ArrowDown":
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
setSelectedIndex((prev) => Math.min(prev + 1, filteredVariables.length - 1));
|
|
213
|
+
break;
|
|
214
|
+
case "ArrowUp":
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
217
|
+
break;
|
|
218
|
+
case "Enter":
|
|
219
|
+
case "Tab":
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
if (filteredVariables[selectedIndex]) {
|
|
222
|
+
selectVariable(filteredVariables[selectedIndex]);
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case "Escape":
|
|
226
|
+
setShowDropdown(false);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Close dropdown when clicking outside
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
234
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
235
|
+
setShowDropdown(false);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
239
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div ref={containerRef}>
|
|
244
|
+
<div className="relative">
|
|
245
|
+
<input
|
|
246
|
+
ref={inputRef}
|
|
247
|
+
type="text"
|
|
248
|
+
value={inputValue}
|
|
249
|
+
onChange={handleInputChange}
|
|
250
|
+
onKeyDown={handleKeyDown}
|
|
251
|
+
onBlur={handleBlur}
|
|
252
|
+
placeholder={resolvedPlaceholder}
|
|
253
|
+
className="w-full h-9 px-3 py-1 text-sm bg-field border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
|
254
|
+
/>
|
|
255
|
+
|
|
256
|
+
{/* Variable dropdown */}
|
|
257
|
+
{showDropdown && filteredVariables.length > 0 && (
|
|
258
|
+
<div className="absolute z-50 mt-1 w-full bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-auto">
|
|
259
|
+
{filteredVariables.map((variable, index) => (
|
|
260
|
+
<button
|
|
261
|
+
key={varKey(variable)}
|
|
262
|
+
onClick={() => selectVariable(variable)}
|
|
263
|
+
className={cn(
|
|
264
|
+
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-accent",
|
|
265
|
+
index === selectedIndex && "bg-accent",
|
|
266
|
+
)}
|
|
267
|
+
>
|
|
268
|
+
<div className="flex flex-col">
|
|
269
|
+
<span className="font-mono">
|
|
270
|
+
${"{"}
|
|
271
|
+
{variable.name}
|
|
272
|
+
{"}"}
|
|
273
|
+
</span>
|
|
274
|
+
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
|
275
|
+
{variable.kind === "node"
|
|
276
|
+
? t("fromNode", { node: nodeLabelById[variable.nodeId] ?? variable.nodeId })
|
|
277
|
+
: variable.kind === "declared"
|
|
278
|
+
? t("globalVariable")
|
|
279
|
+
: t("fnarg")}
|
|
280
|
+
</span>
|
|
281
|
+
</div>
|
|
282
|
+
<span className="text-xs text-muted-foreground">{variable.dataType}</span>
|
|
283
|
+
</button>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{/* Type indicator */}
|
|
289
|
+
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
|
|
290
|
+
<span className="text-xs text-muted-foreground">→ {expressionType}</span>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
export default ExpressionInput;
|