Skip to content

Commit b2093f9

Browse files
ai assistant, zod nodes schemas, docs updates, global store for auth fields, general code refactoring
- added AI Assistant - breaking changes to nodes structures - now nodes use Zod for schemas and validations - now the workflow imports will lead to more specific errors - docs updates - new SMS Node (PRO) - bug fixes - added a global store for auth fields of nodes of the same types - tools rendering refactoring - added to AI Processing nodes new props: thinking, temperature - general code refactoring
1 parent b19e737 commit b2093f9

154 files changed

Lines changed: 4864 additions & 839 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dist-ssr
1313
*.tsbuildinfo
1414

1515
# Editor directories and files
16+
.continue/*
1617
.vscode/*
1718
!.vscode/extensions.json
1819
.idea

bun.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/bun.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"remark-gfm": "^4.0.0",
3939
"uuid": "^11.1.0",
4040
"zod": "^3.25.64",
41-
"zod-to-json-schema": "^3.25.2"
41+
"zod-to-json-schema": "^3.25.2",
42+
"zustand": "^5.0.12"
4243
},
4344
"devDependencies": {
4445
"@eslint/js": "^9.25.0",

client/src/components/App/App.tsx

Lines changed: 165 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
************************************************************************/
66

77
import {ReactFlow, Background, Controls, MiniMap, BackgroundVariant, useReactFlow, ReactFlowProvider} from '@xyflow/react';
8+
import {flushSync} from 'react-dom';
89
import '@xyflow/react/dist/style.css';
910
import './App.scss';
1011
import {useWorkflow} from '../../hooks/useWorkflow';
@@ -18,11 +19,17 @@ import {toolRegistry} from '../nodes/ToolNode/tools/toolRegistry.gen';
1819
import {nodeRegistry} from '../nodes/nodeRegistry.gen';
1920
import {NODE_TYPE as TOOL_NODE_TYPE} from '../nodes/ToolNode/constants';
2021
import {useFullscreen} from '../../hooks/useFullscreen';
22+
import {assertValidWorkflowEdges} from './utils/workflowUtils';
2123
import {getDefaultUserConfigValues} from '../../types/ollama.types';
22-
import {Chip} from '@mui/material';
24+
import {z} from 'zod';
25+
import {Chip, Backdrop, CircularProgress} from '@mui/material';
2326
import {ConfirmDialog} from '../ConfirmDialog';
27+
import {ErrorBoundary} from '../ErrorBoundary/ErrorBoundary';
2428

2529

30+
// Separator for error paths in Zod validation messages
31+
const ZOD_PATH_SEPARATOR = '→';
32+
2633
const getId = () => uuidv4();
2734

2835
function deleteByPath (obj: Record<string, any>, path: string): void {
@@ -75,6 +82,23 @@ const descriptorMap = Object.fromEntries(
7582
nodeRegistry.map(desc => [desc.type, desc])
7683
);
7784

85+
const NodeSchema = z.object({
86+
id: z.string().min(1, '"id" must be a non-empty string'),
87+
type: z.string().min(1, '"type" must be a non-empty string'),
88+
data: z.record(z.unknown()),
89+
position: z.object({x: z.number(), y: z.number()}, {required_error: '"position" with {x, y} is required'}),
90+
}).passthrough();
91+
92+
const EdgeSchema = z.object({
93+
source: z.string().min(1, '"source" must be a non-empty string'),
94+
target: z.string().min(1, '"target" must be a non-empty string'),
95+
}).passthrough();
96+
97+
const WorkflowSchema = z.object({
98+
nodes: z.array(NodeSchema),
99+
edges: z.array(EdgeSchema).optional().default([]),
100+
});
101+
78102
function AppFlow () {
79103
const {
80104
nodes,
@@ -90,16 +114,11 @@ function AppFlow () {
90114
} = useWorkflow();
91115
const {enqueueSnackbar} = useSnackbar();
92116
const [pendingWorkflow, setPendingWorkflow] = useState<{nodes: any[], edges: any[]} | null>(null);
117+
const [isLoadingWorkflow, setIsLoadingWorkflow] = useState(false);
93118

94119
useFullscreen();
95120

96-
const handleSave = () => {
97-
if (nodes.length === 0 && edges.length === 0) {
98-
enqueueSnackbar('Nothing to save.', {variant: 'info'});
99-
100-
return;
101-
}
102-
121+
const handleGetWorkflowJson = useCallback((): string => {
103122
const sanitizedNodes = nodes.map(node => {
104123
const nodeData = JSON.parse(JSON.stringify(node.data));
105124
const toSanitize = Array.isArray(nodeData.toSanitize) ? nodeData.toSanitize : [];
@@ -116,7 +135,17 @@ function AppFlow () {
116135
};
117136
});
118137

119-
const data = JSON.stringify({nodes: sanitizedNodes, edges}, null, 4);
138+
return JSON.stringify({nodes: sanitizedNodes, edges}, null, 2);
139+
}, [nodes, edges]);
140+
141+
const handleSave = () => {
142+
if (nodes.length === 0 && edges.length === 0) {
143+
enqueueSnackbar('Nothing to save.', {variant: 'info'});
144+
145+
return;
146+
}
147+
148+
const data = handleGetWorkflowJson();
120149
const blob = new Blob([data], {type: "application/json"});
121150
const url = URL.createObjectURL(blob);
122151
const a = document.createElement("a");
@@ -135,24 +164,32 @@ function AppFlow () {
135164
setEdges([]);
136165
};
137166

138-
const handleLoad = (event: React.ChangeEvent<HTMLInputElement>) => {
139-
const file = event.target.files?.[0];
140-
141-
if (!file) return;
167+
const handleLoadWorkflowFromJson = useCallback((jsonString: string, onError?: (error: string) => void) => {
168+
flushSync(() => setIsLoadingWorkflow(true));
142169

143-
const reader = new FileReader();
144-
145-
reader.onload = (e) => {
170+
setTimeout(() => {
146171
try {
147-
const data = JSON.parse(e.target?.result as string);
172+
const raw = JSON.parse(jsonString);
173+
174+
// Auto-assign positions for nodes missing them (models often omit position)
175+
if (Array.isArray(raw.nodes)) {
176+
raw.nodes = raw.nodes.map((node: any, idx: number) => {
177+
const x = typeof node.position?.x === 'number' ? node.position.x : idx * 300;
178+
const y = typeof node.position?.y === 'number' ? node.position.y : 0;
148179

149-
if (!data.nodes || !Array.isArray(data.nodes)) {
150-
throw new Error("missing or invalid 'nodes' array.");
180+
return {...node, position: {x, y}};
181+
});
151182
}
152183

153-
const hydratedNodes = data.nodes.map((node: any) => {
154-
if (!node.type || !node.data) {
155-
throw new Error("missing or invalid <node>.data or <node>.type");
184+
const {nodes: parsedNodes, edges: parsedEdges} = WorkflowSchema.parse(raw);
185+
186+
assertValidWorkflowEdges(parsedNodes, parsedEdges);
187+
188+
const hydratedNodes = parsedNodes.map((node: any, idx: number) => {
189+
if (!descriptorMap[node.type]) {
190+
const valid = Object.keys(descriptorMap).join(', ');
191+
192+
throw new Error(`unknown node type "${node.type}". Valid types are: ${valid}`);
156193
}
157194

158195
const descriptor = descriptorMap[node.type];
@@ -202,12 +239,27 @@ function AppFlow () {
202239
};
203240
}
204241

205-
descriptor?.assertion(updatedNode.data);
242+
try {
243+
descriptor?.assertion(updatedNode.data);
244+
} catch (assertionError) {
245+
if (assertionError instanceof z.ZodError) {
246+
247+
const fields = assertionError.errors.map(e => {
248+
const path = ['data', ...e.path].join(ZOD_PATH_SEPARATOR);
249+
250+
return `${path}: ${e.message}`;
251+
}).join('; ');
252+
253+
throw new Error(`nodes[${idx}] (type "${node.type}"): ${fields}`);
254+
}
255+
256+
throw assertionError;
257+
}
206258

207259
return updatedNode;
208260
});
209261

210-
const {remappedNodes, remappedEdges} = remapNodeAndEdgeIds(hydratedNodes, data.edges || []);
262+
const {remappedNodes, remappedEdges} = remapNodeAndEdgeIds(hydratedNodes, parsedEdges);
211263

212264
if (nodes.length > 0) {
213265
setPendingWorkflow({nodes: remappedNodes, edges: remappedEdges});
@@ -216,38 +268,91 @@ function AppFlow () {
216268
setEdges(remappedEdges);
217269
}
218270
} catch (error) {
219-
enqueueSnackbar('Failed to load workflow: ' + (error instanceof Error ? error.message : String(error)), {variant: 'error'});
271+
let rawMessage: string;
272+
273+
if (error instanceof z.ZodError) {
274+
rawMessage = error.errors.map(e => {
275+
const path = e.path.join(ZOD_PATH_SEPARATOR);
276+
277+
return path ? `${path}: ${e.message}` : e.message;
278+
}).join('; ');
279+
} else {
280+
rawMessage = error instanceof Error ? error.message : String(error);
281+
}
282+
283+
const message = 'Failed to load workflow: ' + rawMessage;
284+
285+
enqueueSnackbar(message, {variant: 'error'});
286+
onError?.(message);
220287
} finally {
221-
event.target.value = '';
288+
setIsLoadingWorkflow(false);
222289
}
290+
}, 100);
291+
}, [nodes, setNodes, setEdges, enqueueSnackbar]);
292+
293+
const handleLoad = (event: React.ChangeEvent<HTMLInputElement>) => {
294+
const file = event.target.files?.[0];
295+
296+
if (!file) return;
297+
298+
const reader = new FileReader();
299+
300+
reader.onload = (e) => {
301+
handleLoadWorkflowFromJson(e.target?.result as string);
302+
event.target.value = '';
223303
};
224304
reader.readAsText(file);
225305
};
226306

227307
const handleMergeWorkflow = () => {
228308
if (!pendingWorkflow) return;
229309

230-
const maxY = Math.max(...nodes.map(n => n.position.y + (n.measured?.height ?? 40)));
231-
const minX = Math.min(...nodes.map(n => n.position.x));
232-
const yOffset = maxY + 100;
233-
const pendingMinY = Math.min(...pendingWorkflow.nodes.map((n: any) => n.position.y));
234-
const pendingMinX = Math.min(...pendingWorkflow.nodes.map((n: any) => n.position.x));
235-
const shiftedNodes = pendingWorkflow.nodes.map((node: any) => ({
236-
...node,
237-
position: {
238-
x: node.position.x - pendingMinX + minX,
239-
y: node.position.y + yOffset - pendingMinY}
240-
}));
241-
242-
setNodes([...nodes, ...shiftedNodes]);
243-
setEdges([...edges, ...pendingWorkflow.edges]);
310+
const pending = pendingWorkflow;
311+
312+
flushSync(() => {
313+
setPendingWorkflow(null);
314+
setIsLoadingWorkflow(true);
315+
});
316+
317+
setTimeout(() => {
318+
try {
319+
const maxY = Math.max(...nodes.map(n => n.position.y + (n.measured?.height ?? 40)));
320+
const minX = Math.min(...nodes.map(n => n.position.x));
321+
const yOffset = maxY + 100;
322+
const pendingMinY = Math.min(...pending.nodes.map((n: any) => n.position.y));
323+
const pendingMinX = Math.min(...pending.nodes.map((n: any) => n.position.x));
324+
const shiftedNodes = pending.nodes.map((node: any) => ({
325+
...node,
326+
position: {
327+
x: node.position.x - pendingMinX + minX,
328+
y: node.position.y + yOffset - pendingMinY
329+
}
330+
}));
331+
332+
setNodes([...nodes, ...shiftedNodes]);
333+
setEdges([...edges, ...pending.edges]);
334+
} finally {
335+
setIsLoadingWorkflow(false);
336+
}
337+
}, 100);
244338
};
245339

246340
const handleReplaceWorkflow = () => {
247341
if (!pendingWorkflow) return;
248342

249-
setNodes(pendingWorkflow.nodes);
250-
setEdges(pendingWorkflow.edges);
343+
flushSync(() => {
344+
setPendingWorkflow(null);
345+
setIsLoadingWorkflow(true);
346+
});
347+
348+
setTimeout(() => {
349+
try {
350+
setNodes(pendingWorkflow.nodes);
351+
setEdges(pendingWorkflow.edges);
352+
} finally {
353+
setIsLoadingWorkflow(false);
354+
}
355+
}, 100);
251356
};
252357

253358
const onDragOver = useCallback((event: React.DragEvent) => {
@@ -278,7 +383,10 @@ function AppFlow () {
278383

279384
return (
280385
<>
281-
<Dock onSave={handleSave} onLoad={handleLoad} onClear={handleClear} />
386+
<Backdrop open={isLoadingWorkflow} sx={{zIndex: 9999, color: '#fff'}}>
387+
<CircularProgress color="inherit" />
388+
</Backdrop>
389+
<Dock onSave={handleSave} onLoad={handleLoad} onClear={handleClear} onLoadWorkflow={handleLoadWorkflowFromJson} getWorkflowJson={handleGetWorkflowJson} />
282390
<ConfirmDialog
283391
open={pendingWorkflow !== null}
284392
onClose={() => setPendingWorkflow(null)}
@@ -321,6 +429,19 @@ function AppFlow () {
321429
);
322430
}
323431

432+
function AppFlowWithBoundary () {
433+
const {enqueueSnackbar} = useSnackbar();
434+
435+
return (
436+
<ErrorBoundary onError={(error) => enqueueSnackbar(
437+
'An unexpected error occurred: ' + error.message,
438+
{variant: 'error'}
439+
)}>
440+
<AppFlow />
441+
</ErrorBoundary>
442+
);
443+
}
444+
324445
export function App () {
325446
return (
326447
<div style={{width: '100vw', height: '100vh', position: 'relative'}}>
@@ -330,7 +451,7 @@ export function App () {
330451
className="version-tag"
331452
/>
332453
<ReactFlowProvider>
333-
<AppFlow />
454+
<AppFlowWithBoundary />
334455
</ReactFlowProvider>
335456
</div>
336457
);

0 commit comments

Comments
 (0)