55 ************************************************************************/
66
77import { ReactFlow , Background , Controls , MiniMap , BackgroundVariant , useReactFlow , ReactFlowProvider } from '@xyflow/react' ;
8+ import { flushSync } from 'react-dom' ;
89import '@xyflow/react/dist/style.css' ;
910import './App.scss' ;
1011import { useWorkflow } from '../../hooks/useWorkflow' ;
@@ -18,11 +19,17 @@ import {toolRegistry} from '../nodes/ToolNode/tools/toolRegistry.gen';
1819import { nodeRegistry } from '../nodes/nodeRegistry.gen' ;
1920import { NODE_TYPE as TOOL_NODE_TYPE } from '../nodes/ToolNode/constants' ;
2021import { useFullscreen } from '../../hooks/useFullscreen' ;
22+ import { assertValidWorkflowEdges } from './utils/workflowUtils' ;
2123import { getDefaultUserConfigValues } from '../../types/ollama.types' ;
22- import { Chip } from '@mui/material' ;
24+ import { z } from 'zod' ;
25+ import { Chip , Backdrop , CircularProgress } from '@mui/material' ;
2326import { 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+
2633const getId = ( ) => uuidv4 ( ) ;
2734
2835function 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+
78102function 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+
324445export 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