@llamaindex/workflow-debugger 0.2.0 → 0.2.2

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.
@@ -1,720 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
2
- import {
3
- ReactFlow,
4
- Node,
5
- Edge,
6
- addEdge,
7
- useNodesState,
8
- useEdgesState,
9
- Connection,
10
- Background,
11
- BackgroundVariant,
12
- NodeTypes,
13
- Handle,
14
- Position,
15
- MarkerType,
16
- } from "@xyflow/react";
17
- import "@xyflow/react/dist/style.css";
18
- import { useWorkflowsClient } from "@llamaindex/ui";
19
- import { getWorkflowsByNameRepresentation } from "@llamaindex/workflows-client";
20
-
21
- // Node type renderers are created inside the component so they can react to theme
22
-
23
- interface WorkflowEventItem {
24
- type: string;
25
- data: unknown;
26
- }
27
-
28
- interface WorkflowVisualizationProps {
29
- workflowName: string | null;
30
- className?: string;
31
- events?: WorkflowEventItem[];
32
- onNodeClick?: (nodeId: string) => void;
33
- highlightedNodeId?: string | null;
34
- selectedNodeId?: string | null;
35
- isComplete?: boolean;
36
- }
37
-
38
- interface WorkflowGraphNode {
39
- id: string;
40
- label: string;
41
- node_type: "step" | "event" | "external";
42
- title?: string;
43
- event_type?: string;
44
- }
45
-
46
- interface WorkflowGraphEdge {
47
- source: string;
48
- target: string;
49
- }
50
-
51
- interface WorkflowGraphData {
52
- nodes: WorkflowGraphNode[];
53
- edges: WorkflowGraphEdge[];
54
- }
55
-
56
- export function WorkflowVisualization({
57
- workflowName,
58
- className = "w-full h-[600px]",
59
- events,
60
- onNodeClick,
61
- highlightedNodeId,
62
- selectedNodeId,
63
- isComplete = false,
64
- }: WorkflowVisualizationProps) {
65
- const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
66
- const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
67
- const [loading, setLoading] = useState(false);
68
- const [error, setError] = useState<string | null>(null);
69
- const [lastProcessedEventIndex, setLastProcessedEventIndex] = useState(-1);
70
- const [isDark, setIsDark] = useState<boolean>(() => {
71
- if (typeof window === "undefined") return false;
72
- try {
73
- if (document.documentElement.classList.contains("dark")) return true;
74
- return (
75
- window.matchMedia &&
76
- window.matchMedia("(prefers-color-scheme: dark)").matches
77
- );
78
- } catch {
79
- return false;
80
- }
81
- });
82
- const nodeTypes: NodeTypes = useMemo(() => {
83
- return {
84
- step: ({ data, id }) => {
85
- const isHighlighted = highlightedNodeId === id;
86
- const isSelected = selectedNodeId === id;
87
-
88
- // Always include transition for smooth fade-out
89
- let style: React.CSSProperties = {
90
- transition: "all 1s ease-out",
91
- };
92
-
93
- // If highlight color is set and not null, apply highlight styles
94
- if (data.highlightColor && data.highlightColor !== null) {
95
- style.backgroundColor = isDark ? undefined : "#CCFBEF";
96
- style.borderColor = data.highlightColor;
97
- style.boxShadow = `0 0 0 3px ${data.highlightColor}66`;
98
- } else if (data.highlightColor === null) {
99
- // Explicitly fade back to default styles
100
- style.backgroundColor = isDark ? "#0b1220" : "#E6EEFF";
101
- style.borderColor = isDark ? "#274690" : "#7EA6FF";
102
- style.boxShadow = "0 0 0 0px transparent";
103
- }
104
-
105
- if (isSelected) {
106
- style = {
107
- backgroundColor: isDark ? "#1e3a8a" : "#DBEAFE",
108
- borderColor: "#3B82F6",
109
- boxShadow: "0 0 0 4px #3B82F6",
110
- transition: "all 1s ease-out",
111
- };
112
- } else if (isHighlighted) {
113
- style = {
114
- ...style,
115
- borderColor: "#10B981",
116
- boxShadow: "0 0 0 3px #10B98166",
117
- };
118
- }
119
- return (
120
- <div
121
- className={
122
- "px-4 py-2 shadow-md rounded-md min-w-[160px] cursor-pointer transition-all " +
123
- // light
124
- "bg-[#E6EEFF] border-2 border-[#7EA6FF] " +
125
- // dark
126
- "dark:bg-[#0b1220] dark:border-[#274690] " +
127
- "hover:shadow-lg hover:scale-105"
128
- }
129
- style={style}
130
- onClick={() => onNodeClick?.(id)}
131
- >
132
- <Handle type="target" position={Position.Left} />
133
- <div className="flex items-center justify-between gap-2">
134
- <div className="font-bold text-[#1E3A8A] text-sm dark:text-[#e5edff]">
135
- {data.label}
136
- </div>
137
- {data.workerCount > 0 && (
138
- <div className="flex items-center justify-center min-w-[20px] h-[20px] px-1.5 rounded-full bg-[#10B981] text-white text-[10px] font-bold">
139
- {data.workerCount}
140
- </div>
141
- )}
142
- </div>
143
- {data.title && (
144
- <div className="text-xs text-[#1D4ED8] mt-1 dark:text-[#9db4ff]">
145
- {data.title}
146
- </div>
147
- )}
148
- {(data.lastInputEvent || data.lastOutputEvent) && (
149
- <div className="text-[10px] text-gray-700 dark:text-gray-300 mt-1">
150
- {data.lastInputEvent && (
151
- <div>
152
- <span className="font-semibold">in:</span>{" "}
153
- {data.lastInputEvent}
154
- </div>
155
- )}
156
- {data.lastOutputEvent && (
157
- <div>
158
- <span className="font-semibold">out:</span>{" "}
159
- {data.lastOutputEvent}
160
- </div>
161
- )}
162
- </div>
163
- )}
164
- <Handle type="source" position={Position.Right} />
165
- </div>
166
- );
167
- },
168
- event: ({ data, id }) => {
169
- const isHighlighted = highlightedNodeId === id;
170
- const isSelected = selectedNodeId === id;
171
-
172
- // Always include transition for smooth fade-out
173
- let style: React.CSSProperties = {
174
- transition: "all 1s ease-out",
175
- };
176
-
177
- // If highlight color is set and not null, apply highlight styles
178
- if (data.highlightColor && data.highlightColor !== null) {
179
- style.backgroundColor = isDark ? undefined : "#FFE8B5";
180
- style.borderColor = data.highlightColor;
181
- style.boxShadow = `0 0 0 3px ${data.highlightColor}66`;
182
- } else if (data.highlightColor === null) {
183
- // Explicitly fade back to default styles
184
- style.backgroundColor = isDark ? "#241a06" : "#FFF3BF";
185
- style.borderColor = isDark ? "#F59E0B" : "#FFD166";
186
- style.boxShadow = "0 0 0 0px transparent";
187
- }
188
-
189
- if (isSelected) {
190
- style = {
191
- backgroundColor: isDark ? "#1e3a8a" : "#DBEAFE",
192
- borderColor: "#3B82F6",
193
- boxShadow: "0 0 0 4px #3B82F6",
194
- transition: "all 1s ease-out",
195
- };
196
- } else if (isHighlighted) {
197
- style = {
198
- ...style,
199
- borderColor: "#10B981",
200
- boxShadow: "0 0 0 3px #10B98166",
201
- };
202
- }
203
- return (
204
- <div
205
- className={
206
- "px-3 py-2 shadow-md rounded-full min-w-[140px] text-center cursor-pointer transition-all " +
207
- "bg-[#FFF3BF] border-2 border-[#FFD166] " +
208
- "dark:bg-[#241a06] dark:border-[#F59E0B] " +
209
- "hover:shadow-lg hover:scale-105"
210
- }
211
- style={style}
212
- onClick={() => onNodeClick?.(id)}
213
- >
214
- <Handle type="target" position={Position.Left} />
215
- <div className="font-bold text-[#92400E] text-sm dark:text-[#fde68a]">
216
- {data.label}
217
- </div>
218
- {data.event_type && (
219
- <div className="text-xs text-[#B45309] mt-1 dark:text-[#fbbf24]">
220
- {data.event_type}
221
- </div>
222
- )}
223
- <Handle type="source" position={Position.Right} />
224
- </div>
225
- );
226
- },
227
- external: ({ data, id }) => {
228
- const isHighlighted = highlightedNodeId === id;
229
- const isSelected = selectedNodeId === id;
230
-
231
- let style = data.highlightColor
232
- ? {
233
- backgroundColor: isDark ? undefined : "#FFE5D4",
234
- borderColor: data.highlightColor,
235
- boxShadow: `0 0 0 3px ${data.highlightColor}66`,
236
- }
237
- : undefined;
238
-
239
- if (isSelected) {
240
- style = {
241
- backgroundColor: isDark ? "#1e3a8a" : "#DBEAFE",
242
- borderColor: "#3B82F6",
243
- boxShadow: "0 0 0 4px #3B82F6",
244
- };
245
- } else if (isHighlighted) {
246
- style = {
247
- backgroundColor: style?.backgroundColor,
248
- borderColor: "#10B981",
249
- boxShadow: "0 0 0 3px #10B98166",
250
- };
251
- }
252
- return (
253
- <div
254
- className={
255
- "px-3 py-2 shadow-md rounded-lg min-w-[140px] text-center cursor-pointer transition-all " +
256
- "bg-[#FFEDD5] border-2 border-[#FB923C] " +
257
- "dark:bg-[#1f0f08] dark:border-[#ea7a3a] " +
258
- "hover:shadow-lg hover:scale-105"
259
- }
260
- style={style}
261
- onClick={() => onNodeClick?.(id)}
262
- >
263
- <Handle type="target" position={Position.Left} />
264
- <div className="font-bold text-[#7C2D12] text-sm dark:text-[#fed7aa]">
265
- {data.label}
266
- </div>
267
- {data.title && (
268
- <div className="text-xs text-[#C2410C] mt-1 dark:text-[#fdba74]">
269
- {data.title}
270
- </div>
271
- )}
272
- <Handle type="source" position={Position.Right} />
273
- </div>
274
- );
275
- },
276
- } as NodeTypes;
277
- }, [isDark, highlightedNodeId, selectedNodeId, onNodeClick]);
278
-
279
- useEffect(() => {
280
- if (typeof window === "undefined" || !window.matchMedia) return;
281
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
282
- const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
283
- if (mq.addEventListener) mq.addEventListener("change", handler);
284
- else mq.addListener(handler);
285
- return () => {
286
- if (mq.removeEventListener) mq.removeEventListener("change", handler);
287
- else mq.removeListener(handler);
288
- };
289
- }, []);
290
-
291
- const workflowsClient = useWorkflowsClient();
292
-
293
- const onConnect = useCallback(
294
- (params: Connection) => setEdges((eds) => addEdge(params, eds)),
295
- [setEdges],
296
- );
297
-
298
- // Simple left-to-right DAG layout (no external deps)
299
- const layoutGraph = useCallback(
300
- (graphData: WorkflowGraphData): { nodes: Node[]; edges: Edge[] } => {
301
- const outEdges = new Map<string, string[]>();
302
- const inDegree = new Map<string, number>();
303
-
304
- graphData.nodes.forEach((n) => {
305
- outEdges.set(n.id, []);
306
- inDegree.set(n.id, 0);
307
- });
308
-
309
- graphData.edges.forEach(({ source, target }) => {
310
- outEdges.get(source)?.push(target);
311
- inDegree.set(target, (inDegree.get(target) || 0) + 1);
312
- });
313
-
314
- // Kahn topological layering
315
- const queue: string[] = [];
316
- inDegree.forEach((deg, id) => {
317
- if (deg === 0) queue.push(id);
318
- });
319
-
320
- const layer = new Map<string, number>();
321
- queue.forEach((id) => layer.set(id, 0));
322
-
323
- while (queue.length) {
324
- const id = queue.shift() as string;
325
- const currentLayer = layer.get(id) ?? 0;
326
- for (const nxt of outEdges.get(id) || []) {
327
- const nextLayer = Math.max(currentLayer + 1, layer.get(nxt) ?? 0);
328
- layer.set(nxt, nextLayer);
329
- inDegree.set(nxt, (inDegree.get(nxt) || 0) - 1);
330
- if ((inDegree.get(nxt) || 0) === 0) queue.push(nxt);
331
- }
332
- }
333
-
334
- // Group nodes by layer
335
- const layerToNodes: Record<number, string[]> = {};
336
- graphData.nodes.forEach((n) => {
337
- const l = layer.get(n.id) ?? 0;
338
- if (!layerToNodes[l]) layerToNodes[l] = [];
339
- layerToNodes[l].push(n.id);
340
- });
341
-
342
- const horizontalGap = 260;
343
- const verticalGap = 110;
344
-
345
- const positionedNodes: Node[] = graphData.nodes.map((node) => {
346
- const l = layer.get(node.id) ?? 0;
347
- const siblings = layerToNodes[l];
348
- const index = siblings.indexOf(node.id);
349
- const x = l * horizontalGap;
350
- const y = index * verticalGap;
351
- return {
352
- id: node.id,
353
- type: node.node_type,
354
- position: { x, y },
355
- data: {
356
- label: node.label,
357
- title: node.title,
358
- event_type: node.event_type,
359
- type: node.node_type,
360
- },
361
- } as Node;
362
- });
363
-
364
- const positionedEdges: Edge[] = graphData.edges.map((edge, index) => ({
365
- id: `edge-${index}`,
366
- source: edge.source,
367
- target: edge.target,
368
- type: "smoothstep",
369
- animated: false,
370
- style: {
371
- stroke: "var(--wf-edge-stroke)",
372
- strokeWidth: 2,
373
- },
374
- markerEnd: {
375
- type: MarkerType.ArrowClosed,
376
- color: "var(--wf-edge-stroke)",
377
- },
378
- }));
379
-
380
- return { nodes: positionedNodes, edges: positionedEdges };
381
- },
382
- [],
383
- );
384
-
385
- const fetchWorkflowVisualization = useCallback(async () => {
386
- if (!workflowName) {
387
- setNodes([]);
388
- setEdges([]);
389
- setError(null);
390
- return;
391
- }
392
-
393
- setLoading(true);
394
- setError(null);
395
-
396
- try {
397
- const { data, error } = await getWorkflowsByNameRepresentation({
398
- client: workflowsClient,
399
- path: { name: workflowName },
400
- });
401
- if (error) {
402
- throw new Error(String(error));
403
- }
404
- const graphData = (data as { graph?: WorkflowGraphData })?.graph as
405
- | WorkflowGraphData
406
- | undefined;
407
- if (!graphData) {
408
- throw new Error("No graph data received");
409
- }
410
-
411
- const laidOut = layoutGraph(graphData);
412
- setNodes(laidOut.nodes);
413
- setEdges(laidOut.edges);
414
- } catch (err) {
415
- console.error("Error fetching workflow visualization:", err);
416
- setError(
417
- err instanceof Error
418
- ? err.message
419
- : "Failed to load workflow visualization",
420
- );
421
- setNodes([]);
422
- setEdges([]);
423
- } finally {
424
- setLoading(false);
425
- }
426
- }, [workflowName, workflowsClient, layoutGraph, setNodes, setEdges]);
427
-
428
- useEffect(() => {
429
- fetchWorkflowVisualization();
430
- }, [fetchWorkflowVisualization]);
431
-
432
- // React to streaming events to highlight nodes and update last input/output
433
- useEffect(() => {
434
- if (!events || events.length === 0) {
435
- setLastProcessedEventIndex(-1);
436
- // Clear all highlights when events are cleared (new run)
437
- setNodes((prev) =>
438
- prev.map((n) => {
439
- const newData: Record<string, unknown> = { ...n.data };
440
- delete newData.highlightColor;
441
- delete newData.activeWorkers;
442
- delete newData.workerCount;
443
- delete newData.fadeTimestamp;
444
- return { ...n, data: newData } as Node;
445
- }),
446
- );
447
- return;
448
- }
449
-
450
- // Process only new events since last time (DO THIS BEFORE isComplete check!)
451
- const newEvents = events.slice(lastProcessedEventIndex + 1);
452
-
453
- // If no new events but workflow is complete, still trigger fade-out
454
- if (newEvents.length === 0) {
455
- return;
456
- }
457
-
458
- setLastProcessedEventIndex(events.length - 1);
459
-
460
- const getSimpleName = (raw: unknown): string => {
461
- if (!raw) return "";
462
- try {
463
- const str = typeof raw === "string" ? raw : String(raw);
464
- const cleaned = str
465
- .replace("<class", "")
466
- .replace(">", "")
467
- .replaceAll("'", "");
468
- const parts = cleaned.split(".");
469
- const lastPart = parts.at(-1) || "";
470
- return lastPart.trim();
471
- } catch {
472
- return "";
473
- }
474
- };
475
-
476
- const stateColor = (stepState?: string): string | null => {
477
- switch (stepState) {
478
- case "preparing":
479
- return "#3B82F6"; // blue
480
- case "in_progress":
481
- return "#F59E0B"; // amber
482
- case "running":
483
- return "#10B981"; // emerald
484
- case "not_running":
485
- case "not_in_progress":
486
- case "exited":
487
- return null;
488
- default:
489
- return "#10B981"; // default active
490
- }
491
- };
492
-
493
- // Process each new event
494
- for (const event of newEvents) {
495
- const type = event?.type ?? "";
496
- const payload =
497
- (event?.data as Record<string, unknown> | undefined) ?? {};
498
-
499
- // Handle StepStateChanged events
500
- // Note: event.type is the short name like "StepStateChanged", not the qualified name
501
- if (
502
- type === "StepStateChanged" ||
503
- type === "workflows.events.StepStateChanged"
504
- ) {
505
- const stepName = String((payload as { name?: unknown })?.name || "");
506
- const stepState = String(
507
- (payload as { step_state?: unknown })?.step_state || "",
508
- );
509
- const workerId = String(
510
- (payload as { worker_id?: unknown })?.worker_id || "",
511
- );
512
- const color = stateColor(stepState);
513
-
514
- setNodes((prev) =>
515
- prev.map((n) => {
516
- const newData: Record<string, unknown> = { ...n.data };
517
-
518
- if (n.id === stepName) {
519
- // Track active workers for this step
520
- const activeWorkers = new Set<string>(
521
- (newData.activeWorkers as string[]) || [],
522
- );
523
-
524
- // Update worker set based on state
525
- if (
526
- stepState === "preparing" ||
527
- stepState === "in_progress" ||
528
- stepState === "running"
529
- ) {
530
- activeWorkers.add(workerId);
531
- } else if (
532
- stepState === "not_running" ||
533
- stepState === "not_in_progress" ||
534
- stepState === "exited"
535
- ) {
536
- activeWorkers.delete(workerId);
537
- }
538
-
539
- newData.activeWorkers = Array.from(activeWorkers);
540
- newData.workerCount = activeWorkers.size;
541
-
542
- // Determine highlight color based on active workers
543
- if (activeWorkers.size > 0) {
544
- // Use the color for the current state, or default to running color
545
- newData.highlightColor = color || "#10B981";
546
- newData.fadeTimestamp = Date.now();
547
- } else if (
548
- stepState === "exited" ||
549
- stepState === "not_running"
550
- ) {
551
- // When step exits/completes, keep the last highlight color and trigger fade-out
552
- // Only fade if there was a highlight to begin with
553
- if (newData.highlightColor) {
554
- const fadeTimestamp = Date.now();
555
- newData.fadeTimestamp = fadeTimestamp;
556
-
557
- // Keep the highlight visible for 1s, then fade out
558
- setTimeout(() => {
559
- setNodes((prev) =>
560
- prev.map((n) => {
561
- if (
562
- n.id === stepName &&
563
- n.data.fadeTimestamp === fadeTimestamp
564
- ) {
565
- const newData: Record<string, unknown> = {
566
- ...n.data,
567
- };
568
- newData.highlightColor = null;
569
-
570
- return { ...n, data: newData } as Node;
571
- }
572
- return n;
573
- }),
574
- );
575
- }, 1000);
576
- }
577
- }
578
-
579
- newData.lastInputEvent = getSimpleName(
580
- (payload as { input_event_name?: unknown })?.input_event_name,
581
- );
582
- newData.lastOutputEvent = getSimpleName(
583
- (payload as { output_event_name?: unknown })?.output_event_name,
584
- );
585
- newData.status = stepState;
586
- return { ...n, data: newData } as Node;
587
- }
588
-
589
- // Keep other nodes unchanged (don't clear their highlights)
590
- return n;
591
- }),
592
- );
593
- } else if (
594
- type &&
595
- !type.includes("workflows.events.EventsQueueChanged")
596
- ) {
597
- // Handle user-defined events (like ProgressEvent)
598
- const eventName = getSimpleName(
599
- type.replace("__main__.", "").replace("workflows.events.", ""),
600
- );
601
-
602
- setNodes((prev) =>
603
- prev.map((n) => {
604
- if (n.id === eventName && n.type === "event") {
605
- const newData: Record<string, unknown> = { ...n.data };
606
- newData.highlightColor = "#10B981";
607
- newData.fadeTimestamp = Date.now();
608
- return { ...n, data: newData } as Node;
609
- }
610
- return n;
611
- }),
612
- );
613
-
614
- // Clear the event highlight after 1 second
615
- // Don't add to cleanup array - let it run independently
616
- setTimeout(() => {
617
- setNodes((prev) =>
618
- prev.map((n) => {
619
- if (n.id === eventName && n.type === "event") {
620
- const newData: Record<string, unknown> = { ...n.data };
621
- newData.highlightColor = null;
622
- newData.fadeTimestamp = null;
623
- return { ...n, data: newData } as Node;
624
- }
625
- return n;
626
- }),
627
- );
628
- }, 1000);
629
- }
630
- }
631
- }, [events, lastProcessedEventIndex, setNodes, isComplete]);
632
-
633
- if (loading) {
634
- return (
635
- <div
636
- className={`${className} flex items-center justify-center bg-card border border-border rounded-lg`}
637
- >
638
- <div className="text-center">
639
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
640
- <p className="text-muted-foreground">
641
- Loading workflow visualization...
642
- </p>
643
- </div>
644
- </div>
645
- );
646
- }
647
-
648
- if (error) {
649
- return (
650
- <div
651
- className={`${className} flex items-center justify-center bg-destructive/10 border border-destructive/30 rounded-lg`}
652
- >
653
- <div className="text-center p-4">
654
- <p className="text-destructive font-medium">
655
- Error loading visualization
656
- </p>
657
- <p className="text-sm mt-1 text-destructive">{error}</p>
658
- <button
659
- onClick={fetchWorkflowVisualization}
660
- className="mt-3 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 transition-colors"
661
- >
662
- Retry
663
- </button>
664
- </div>
665
- </div>
666
- );
667
- }
668
-
669
- if (!workflowName) {
670
- return (
671
- <div
672
- className={`${className} flex items-center justify-center bg-card border border-border rounded-lg`}
673
- >
674
- <p className="text-muted-foreground">
675
- Select a workflow to view its visualization
676
- </p>
677
- </div>
678
- );
679
- }
680
-
681
- if (nodes.length === 0) {
682
- return (
683
- <div
684
- className={`${className} flex items-center justify-center bg-card border border-border rounded-lg`}
685
- >
686
- <p className="text-muted-foreground">No workflow data available</p>
687
- </div>
688
- );
689
- }
690
-
691
- return (
692
- <div
693
- className={`${className} border border-border rounded-lg overflow-hidden`}
694
- >
695
- <ReactFlow
696
- nodes={nodes}
697
- edges={edges}
698
- onNodesChange={onNodesChange}
699
- onEdgesChange={onEdgesChange}
700
- onConnect={onConnect}
701
- nodeTypes={nodeTypes}
702
- defaultEdgeOptions={{
703
- markerEnd: {
704
- type: MarkerType.ArrowClosed,
705
- color: "var(--wf-edge-stroke)",
706
- },
707
- }}
708
- fitView
709
- attributionPosition="bottom-left"
710
- >
711
- <Background
712
- variant={BackgroundVariant.Dots}
713
- gap={12}
714
- size={1}
715
- color={isDark ? "#374151" : "#e5e7eb"}
716
- />
717
- </ReactFlow>
718
- </div>
719
- );
720
- }