@flowuent-org/diagramming-core 1.4.2 → 1.4.4
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/apps/diagramming/src/AutomationDiagramData.ts +108 -18
- package/package.json +1 -1
- package/packages/diagrams/src/lib/components/automation/AutomationApiNode.tsx +6 -29
- package/packages/diagrams/src/lib/components/automation/AutomationFormattingNode.tsx +11 -74
- package/packages/diagrams/src/lib/components/automation/AutomationLoopNode.tsx +105 -0
- package/packages/diagrams/src/lib/components/automation/AutomationNodeCard.tsx +25 -0
- package/packages/diagrams/src/lib/components/automation/AutomationNodeConfigDisplay.tsx +132 -0
- package/packages/diagrams/src/lib/components/automation/index.ts +6 -0
- package/packages/diagrams/src/lib/icons/icons.tsx +10 -0
- package/packages/diagrams/src/lib/icons/index.ts +1 -0
- package/packages/diagrams/src/lib/types/automation-node-data-types.ts +32 -1
- package/packages/diagrams/src/lib/types/node-types.ts +2 -0
- package/packages/diagrams/src/lib/utils/AutomationExecutionEngine.ts +268 -41
- package/packages/diagrams/src/lib/utils/automation-flow-processor.ts +70 -0
- package/packages/diagrams/src/lib/utils/validateAutomationLoopNode.ts +104 -0
|
@@ -113,15 +113,13 @@ export const automationDefaultNodes = [
|
|
|
113
113
|
// Configuration cards for dynamic display
|
|
114
114
|
configurationCards: [
|
|
115
115
|
{
|
|
116
|
-
|
|
116
|
+
type: 'model',
|
|
117
117
|
label: 'GPT-5 Thinking mini · v1',
|
|
118
|
-
iconColor: '#ffffff',
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
icon: '🗄️',
|
|
122
|
-
label: 'KB: CNN_Homepage_Titles (18 items)',
|
|
123
|
-
iconColor: '#10b981',
|
|
124
118
|
},
|
|
119
|
+
// {
|
|
120
|
+
// type: 'knowledgeBase',
|
|
121
|
+
// label: 'KB: CNN_Homepage_Titles (18 items)',
|
|
122
|
+
// },
|
|
125
123
|
],
|
|
126
124
|
// AI Suggestions count and data
|
|
127
125
|
aiSuggestionsCount: 2,
|
|
@@ -244,20 +242,18 @@ export const automationDefaultNodes = [
|
|
|
244
242
|
ignored: 0,
|
|
245
243
|
},
|
|
246
244
|
// AI Reasoning data
|
|
247
|
-
aiReasoning: {
|
|
248
|
-
|
|
249
|
-
},
|
|
245
|
+
// aiReasoning: {
|
|
246
|
+
// text: 'Analyzed the selected headline for key components and promises',
|
|
247
|
+
// },
|
|
250
248
|
// Configuration cards for dynamic display
|
|
251
249
|
configurationCards: [
|
|
252
250
|
{
|
|
253
|
-
|
|
251
|
+
type: 'model',
|
|
254
252
|
label: 'GPT-5 Thinking mini · v1',
|
|
255
|
-
iconColor: '#ffffff',
|
|
256
253
|
},
|
|
257
254
|
{
|
|
258
|
-
|
|
255
|
+
type: 'knowledgeBase',
|
|
259
256
|
label: 'KB: CNN_Articles (18 items)',
|
|
260
|
-
iconColor: '#10b981',
|
|
261
257
|
},
|
|
262
258
|
],
|
|
263
259
|
// AI Suggestions count
|
|
@@ -276,6 +272,42 @@ export const automationDefaultNodes = [
|
|
|
276
272
|
height: 150,
|
|
277
273
|
measured: { width: 336, height: 150 },
|
|
278
274
|
},
|
|
275
|
+
// =====================================
|
|
276
|
+
// Loop Node
|
|
277
|
+
// =====================================
|
|
278
|
+
{
|
|
279
|
+
id: 'loop-node',
|
|
280
|
+
type: 'AutomationLoopNode',
|
|
281
|
+
position: { x: 1100, y: 200 },
|
|
282
|
+
data: {
|
|
283
|
+
label: 'For Each Item',
|
|
284
|
+
description: 'Iterate over array items and run body steps for each',
|
|
285
|
+
headerText: 'For-each loop over array',
|
|
286
|
+
status: 'Ready',
|
|
287
|
+
parameters: {
|
|
288
|
+
arraySource: { type: 'previousStep' },
|
|
289
|
+
maxIterations: 100,
|
|
290
|
+
currentItemVariable: 'currentItem',
|
|
291
|
+
loopIndexVariable: 'loopIndex',
|
|
292
|
+
},
|
|
293
|
+
formData: {
|
|
294
|
+
nodeId: 'loop-node',
|
|
295
|
+
title: 'For Each Item',
|
|
296
|
+
type: 'loop',
|
|
297
|
+
arraySource: { type: 'previousStep' },
|
|
298
|
+
maxIterations: 100,
|
|
299
|
+
currentItemVariable: 'currentItem',
|
|
300
|
+
loopIndexVariable: 'loopIndex',
|
|
301
|
+
},
|
|
302
|
+
lastRun: 'Never',
|
|
303
|
+
backgroundColor: '#181C25',
|
|
304
|
+
textColor: '#ffffff',
|
|
305
|
+
borderColor: '#1e293b',
|
|
306
|
+
},
|
|
307
|
+
width: 336,
|
|
308
|
+
height: 150,
|
|
309
|
+
measured: { width: 336, height: 150 },
|
|
310
|
+
},
|
|
279
311
|
{
|
|
280
312
|
id: 'end-node',
|
|
281
313
|
type: 'AutomationEndNode',
|
|
@@ -695,11 +727,11 @@ export const automationDefaultEdges = [
|
|
|
695
727
|
},
|
|
696
728
|
},
|
|
697
729
|
{
|
|
698
|
-
id: 'edge-formatting-to-
|
|
730
|
+
id: 'edge-formatting-to-loop',
|
|
699
731
|
source: 'data-formatting-node',
|
|
700
|
-
target: '
|
|
701
|
-
sourceHandle: 'right',
|
|
702
|
-
targetHandle: 'left',
|
|
732
|
+
target: 'loop-node',
|
|
733
|
+
sourceHandle: 'right-source',
|
|
734
|
+
targetHandle: 'left-target',
|
|
703
735
|
data: {
|
|
704
736
|
label: '',
|
|
705
737
|
type: 'flow',
|
|
@@ -713,6 +745,64 @@ export const automationDefaultEdges = [
|
|
|
713
745
|
color: '#ffffff',
|
|
714
746
|
},
|
|
715
747
|
},
|
|
748
|
+
{
|
|
749
|
+
id: 'edge-loop-to-slack',
|
|
750
|
+
source: 'loop-node',
|
|
751
|
+
target: 'slack-node',
|
|
752
|
+
sourceHandle: 'loop',
|
|
753
|
+
targetHandle: 'left-target',
|
|
754
|
+
data: {
|
|
755
|
+
label: 'Loop body',
|
|
756
|
+
type: 'flow',
|
|
757
|
+
},
|
|
758
|
+
style: {
|
|
759
|
+
stroke: '#86EFAC',
|
|
760
|
+
strokeWidth: 2,
|
|
761
|
+
},
|
|
762
|
+
markerEnd: {
|
|
763
|
+
type: MarkerType.ArrowClosed,
|
|
764
|
+
color: '#86EFAC',
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
id: 'edge-slack-back-to-loop',
|
|
769
|
+
source: 'slack-node',
|
|
770
|
+
target: 'loop-node',
|
|
771
|
+
sourceHandle: 'right-source',
|
|
772
|
+
targetHandle: 'left-target',
|
|
773
|
+
data: {
|
|
774
|
+
label: 'Back to loop',
|
|
775
|
+
type: 'flow',
|
|
776
|
+
},
|
|
777
|
+
style: {
|
|
778
|
+
stroke: '#86EFAC',
|
|
779
|
+
strokeWidth: 2,
|
|
780
|
+
strokeDasharray: '5 5',
|
|
781
|
+
},
|
|
782
|
+
markerEnd: {
|
|
783
|
+
type: MarkerType.ArrowClosed,
|
|
784
|
+
color: '#86EFAC',
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
id: 'edge-loop-done-to-end',
|
|
789
|
+
source: 'loop-node',
|
|
790
|
+
target: 'end-node',
|
|
791
|
+
sourceHandle: 'done',
|
|
792
|
+
targetHandle: 'left-target',
|
|
793
|
+
data: {
|
|
794
|
+
label: 'Done',
|
|
795
|
+
type: 'flow',
|
|
796
|
+
},
|
|
797
|
+
style: {
|
|
798
|
+
stroke: '#9CA3AF',
|
|
799
|
+
strokeWidth: 2,
|
|
800
|
+
},
|
|
801
|
+
markerEnd: {
|
|
802
|
+
type: MarkerType.ArrowClosed,
|
|
803
|
+
color: '#9CA3AF',
|
|
804
|
+
},
|
|
805
|
+
},
|
|
716
806
|
// =====================================
|
|
717
807
|
// Slack Node Edge
|
|
718
808
|
// =====================================
|
package/package.json
CHANGED
|
@@ -24,6 +24,8 @@ import { useSearch } from '../../contexts/SearchContext';
|
|
|
24
24
|
import { getStatusColor } from './statusColors';
|
|
25
25
|
import { ApiNodeIcon } from '../../icons';
|
|
26
26
|
import { AutomationNodeHeaderSection } from './AutomationNodeHeader';
|
|
27
|
+
import { AutomationConfigurationCardsSection } from './AutomationNodeConfigDisplay';
|
|
28
|
+
import { AutomationConfigurationCard } from '../../types/automation-node-data-types';
|
|
27
29
|
|
|
28
30
|
interface AutomationApiNodeProps {
|
|
29
31
|
data: {
|
|
@@ -49,11 +51,7 @@ interface AutomationApiNodeProps {
|
|
|
49
51
|
partial: number;
|
|
50
52
|
ignored: number;
|
|
51
53
|
};
|
|
52
|
-
configurationCards?:
|
|
53
|
-
icon: string;
|
|
54
|
-
label: string;
|
|
55
|
-
iconColor: string;
|
|
56
|
-
}>;
|
|
54
|
+
configurationCards?: AutomationConfigurationCard[];
|
|
57
55
|
aiSuggestionsCount?: number; // Number of AI suggestions available
|
|
58
56
|
aiSuggestions?: AISuggestion[]; // AI suggestions data
|
|
59
57
|
[key: string]: any;
|
|
@@ -476,30 +474,9 @@ export const AutomationApiNode: React.FC<AutomationApiNodeProps> = ({ data, sele
|
|
|
476
474
|
</>
|
|
477
475
|
)}
|
|
478
476
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
backgroundColor: '#1F2937',
|
|
483
|
-
borderRadius: '6px',
|
|
484
|
-
padding: '8px 12px',
|
|
485
|
-
mb: 1,
|
|
486
|
-
display: 'flex',
|
|
487
|
-
alignItems: 'center',
|
|
488
|
-
gap: 1,
|
|
489
|
-
border: '1px solid #374151'
|
|
490
|
-
}}>
|
|
491
|
-
<Box sx={{ color: card.iconColor || '#ffffff', fontSize: '16px' }}>
|
|
492
|
-
{card.icon}
|
|
493
|
-
</Box>
|
|
494
|
-
<Typography variant="body2" sx={{
|
|
495
|
-
color: '#ffffff',
|
|
496
|
-
fontSize: '11px',
|
|
497
|
-
fontWeight: 500
|
|
498
|
-
}}>
|
|
499
|
-
{card.label}
|
|
500
|
-
</Typography>
|
|
501
|
-
</Box>
|
|
502
|
-
))}
|
|
477
|
+
<AutomationConfigurationCardsSection
|
|
478
|
+
configurationCards={data.formData?.configurationCards}
|
|
479
|
+
/>
|
|
503
480
|
|
|
504
481
|
{/* Last Run Info */}
|
|
505
482
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}>
|
|
@@ -24,6 +24,11 @@ import { useSearch } from '../../contexts/SearchContext';
|
|
|
24
24
|
import { getStatusColor } from './statusColors';
|
|
25
25
|
import { ArticleAnalyzerIcon, FormattingNodeIcon } from '../../icons';
|
|
26
26
|
import { AutomationNodeHeaderSection } from './AutomationNodeHeader';
|
|
27
|
+
import {
|
|
28
|
+
AutomationAIReasoningSection,
|
|
29
|
+
AutomationConfigurationCardsSection,
|
|
30
|
+
} from './AutomationNodeConfigDisplay';
|
|
31
|
+
import { AutomationConfigurationCard, AutomationAIReasoning } from '../../types/automation-node-data-types';
|
|
27
32
|
|
|
28
33
|
interface AutomationFormattingNodeProps {
|
|
29
34
|
data: {
|
|
@@ -52,14 +57,8 @@ interface AutomationFormattingNodeProps {
|
|
|
52
57
|
partial: number;
|
|
53
58
|
ignored: number;
|
|
54
59
|
};
|
|
55
|
-
aiReasoning?:
|
|
56
|
-
|
|
57
|
-
};
|
|
58
|
-
configurationCards?: Array<{
|
|
59
|
-
icon: string;
|
|
60
|
-
label: string;
|
|
61
|
-
iconColor: string;
|
|
62
|
-
}>;
|
|
60
|
+
aiReasoning?: AutomationAIReasoning;
|
|
61
|
+
configurationCards?: AutomationConfigurationCard[];
|
|
63
62
|
aiSuggestionsCount?: number; // Number of AI suggestions available
|
|
64
63
|
[key: string]: any;
|
|
65
64
|
}; // Include formData for configuration
|
|
@@ -394,48 +393,7 @@ export const AutomationFormattingNode: React.FC<AutomationFormattingNodeProps> =
|
|
|
394
393
|
</Box>
|
|
395
394
|
</Box>
|
|
396
395
|
|
|
397
|
-
{
|
|
398
|
-
<Box sx={{
|
|
399
|
-
backgroundColor: '#1F2937',
|
|
400
|
-
borderRadius: '8px',
|
|
401
|
-
padding: '12px',
|
|
402
|
-
mb: 2,
|
|
403
|
-
border: '1px solid #374151'
|
|
404
|
-
}}>
|
|
405
|
-
{/* AI Reasoning Header */}
|
|
406
|
-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
407
|
-
<Box sx={{ color: '#34D399', fontSize: '16px' }}>
|
|
408
|
-
🧠
|
|
409
|
-
</Box>
|
|
410
|
-
<Typography variant="body2" sx={{
|
|
411
|
-
color: '#34D399',
|
|
412
|
-
fontSize: '12px',
|
|
413
|
-
fontWeight: 600
|
|
414
|
-
}}>
|
|
415
|
-
{t('automation.formattingNode.aiReasoning')}
|
|
416
|
-
</Typography>
|
|
417
|
-
</Box>
|
|
418
|
-
|
|
419
|
-
{/* AI Reasoning Description */}
|
|
420
|
-
<Box sx={{
|
|
421
|
-
backgroundColor: 'transparent',
|
|
422
|
-
borderRadius: '4px',
|
|
423
|
-
padding: '8px',
|
|
424
|
-
border: '1px solid #4B5563',
|
|
425
|
-
minHeight: '40px',
|
|
426
|
-
display: 'flex',
|
|
427
|
-
alignItems: 'center'
|
|
428
|
-
}}>
|
|
429
|
-
<Typography variant="body2" sx={{
|
|
430
|
-
color: '#9CA3AF',
|
|
431
|
-
fontSize: '12px',
|
|
432
|
-
lineHeight: 1.4,
|
|
433
|
-
margin: 0
|
|
434
|
-
}}>
|
|
435
|
-
{data.formData?.aiReasoning?.text || t('automation.formattingNode.aiReasoningDefault')}
|
|
436
|
-
</Typography>
|
|
437
|
-
</Box>
|
|
438
|
-
</Box>
|
|
396
|
+
<AutomationAIReasoningSection aiReasoning={data.formData?.aiReasoning} />
|
|
439
397
|
|
|
440
398
|
{data.status === 'Completed' && (data.formData?.executionResult || data.formData?.schemaMatch) && (
|
|
441
399
|
<>
|
|
@@ -520,30 +478,9 @@ export const AutomationFormattingNode: React.FC<AutomationFormattingNodeProps> =
|
|
|
520
478
|
</>
|
|
521
479
|
)}
|
|
522
480
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
backgroundColor: '#1F2937',
|
|
527
|
-
borderRadius: '6px',
|
|
528
|
-
padding: '8px 12px',
|
|
529
|
-
mb: 1,
|
|
530
|
-
display: 'flex',
|
|
531
|
-
alignItems: 'center',
|
|
532
|
-
gap: 1,
|
|
533
|
-
border: '1px solid #374151'
|
|
534
|
-
}}>
|
|
535
|
-
<Box sx={{ color: card.iconColor || '#ffffff', fontSize: '16px' }}>
|
|
536
|
-
{card.icon}
|
|
537
|
-
</Box>
|
|
538
|
-
<Typography variant="body2" sx={{
|
|
539
|
-
color: '#ffffff',
|
|
540
|
-
fontSize: '11px',
|
|
541
|
-
fontWeight: 500
|
|
542
|
-
}}>
|
|
543
|
-
{card.label}
|
|
544
|
-
</Typography>
|
|
545
|
-
</Box>
|
|
546
|
-
))}
|
|
481
|
+
<AutomationConfigurationCardsSection
|
|
482
|
+
configurationCards={data.formData?.configurationCards}
|
|
483
|
+
/>
|
|
547
484
|
|
|
548
485
|
{/* Last Run Info */}
|
|
549
486
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Typography, Chip } from '@mui/material';
|
|
3
|
+
import { LoopNodeIcon } from '../../icons';
|
|
4
|
+
import {
|
|
5
|
+
AutomationNodeCard,
|
|
6
|
+
AutomationNodeCardData,
|
|
7
|
+
} from './AutomationNodeCard';
|
|
8
|
+
import { AutomationLoopNodeForm } from '../../types/automation-node-data-types';
|
|
9
|
+
|
|
10
|
+
export interface AutomationLoopNodeData extends AutomationNodeCardData {
|
|
11
|
+
parameters?: {
|
|
12
|
+
arraySource?: AutomationLoopNodeForm['arraySource'];
|
|
13
|
+
maxIterations?: number;
|
|
14
|
+
currentItemVariable?: string;
|
|
15
|
+
loopIndexVariable?: string;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AutomationLoopNodeProps {
|
|
21
|
+
data: AutomationLoopNodeData;
|
|
22
|
+
selected?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const AutomationLoopNode: React.FC<AutomationLoopNodeProps> = ({
|
|
26
|
+
data,
|
|
27
|
+
selected,
|
|
28
|
+
}) => {
|
|
29
|
+
const formData = (data.formData ?? {}) as Partial<AutomationLoopNodeForm>;
|
|
30
|
+
const arraySource =
|
|
31
|
+
data.parameters?.arraySource ?? formData.arraySource;
|
|
32
|
+
const maxIterations =
|
|
33
|
+
data.parameters?.maxIterations ?? formData.maxIterations ?? 100;
|
|
34
|
+
const currentItemVariable =
|
|
35
|
+
data.parameters?.currentItemVariable ??
|
|
36
|
+
formData.currentItemVariable ??
|
|
37
|
+
'currentItem';
|
|
38
|
+
const loopIndexVariable =
|
|
39
|
+
data.parameters?.loopIndexVariable ??
|
|
40
|
+
formData.loopIndexVariable ??
|
|
41
|
+
'loopIndex';
|
|
42
|
+
|
|
43
|
+
const sourceLabel =
|
|
44
|
+
arraySource?.type === 'variable'
|
|
45
|
+
? arraySource.variableName ?? 'Unnamed variable'
|
|
46
|
+
: arraySource?.type === 'previousStep'
|
|
47
|
+
? 'Previous step'
|
|
48
|
+
: 'Not configured';
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<AutomationNodeCard
|
|
52
|
+
data={data}
|
|
53
|
+
selected={selected}
|
|
54
|
+
icon={<LoopNodeIcon />}
|
|
55
|
+
aiAssistantTitle="Loop Node"
|
|
56
|
+
loopHandles
|
|
57
|
+
descriptionContent={
|
|
58
|
+
<Box sx={{ width: '100%' }}>
|
|
59
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
|
60
|
+
<Chip
|
|
61
|
+
size="small"
|
|
62
|
+
label={sourceLabel.toUpperCase()}
|
|
63
|
+
sx={{
|
|
64
|
+
height: 20,
|
|
65
|
+
fontSize: '10px',
|
|
66
|
+
fontWeight: 600,
|
|
67
|
+
color: '#86EFAC',
|
|
68
|
+
bgcolor: 'rgba(134, 239, 172, 0.12)',
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
<Chip
|
|
72
|
+
size="small"
|
|
73
|
+
label={`MAX ${maxIterations}`}
|
|
74
|
+
sx={{
|
|
75
|
+
height: 20,
|
|
76
|
+
fontSize: '10px',
|
|
77
|
+
fontWeight: 600,
|
|
78
|
+
color: '#9CA3AF',
|
|
79
|
+
bgcolor: 'rgba(156, 163, 175, 0.12)',
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
</Box>
|
|
83
|
+
<Typography
|
|
84
|
+
variant="body2"
|
|
85
|
+
sx={{ color: '#9CA3AF', fontSize: '12px', lineHeight: 1.4, mb: 0.5 }}
|
|
86
|
+
>
|
|
87
|
+
{arraySource
|
|
88
|
+
? `For each item → ${currentItemVariable}, index → ${loopIndexVariable}`
|
|
89
|
+
: data.description}
|
|
90
|
+
</Typography>
|
|
91
|
+
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5, px: 0.5 }}>
|
|
92
|
+
<Typography variant="caption" sx={{ color: '#86EFAC', fontSize: '10px', fontWeight: 600 }}>
|
|
93
|
+
LOOP →
|
|
94
|
+
</Typography>
|
|
95
|
+
<Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: '10px', fontWeight: 600 }}>
|
|
96
|
+
DONE →
|
|
97
|
+
</Typography>
|
|
98
|
+
</Box>
|
|
99
|
+
</Box>
|
|
100
|
+
}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export default AutomationLoopNode;
|
|
@@ -30,6 +30,7 @@ export interface AutomationNodeCardProps {
|
|
|
30
30
|
headerText?: string;
|
|
31
31
|
aiAssistantTitle?: string;
|
|
32
32
|
branchHandles?: boolean;
|
|
33
|
+
loopHandles?: boolean;
|
|
33
34
|
descriptionContent?: React.ReactNode;
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -64,6 +65,7 @@ export const AutomationNodeCard: React.FC<AutomationNodeCardProps> = ({
|
|
|
64
65
|
headerText,
|
|
65
66
|
aiAssistantTitle,
|
|
66
67
|
branchHandles = false,
|
|
68
|
+
loopHandles = false,
|
|
67
69
|
descriptionContent,
|
|
68
70
|
}) => {
|
|
69
71
|
const { t } = useTranslation();
|
|
@@ -248,6 +250,29 @@ export const AutomationNodeCard: React.FC<AutomationNodeCardProps> = ({
|
|
|
248
250
|
style={handleTargetStyle('right', selected)}
|
|
249
251
|
/>
|
|
250
252
|
</>
|
|
253
|
+
) : loopHandles ? (
|
|
254
|
+
<>
|
|
255
|
+
<Handle
|
|
256
|
+
type="source"
|
|
257
|
+
position={Position.Right}
|
|
258
|
+
id="loop"
|
|
259
|
+
className="connection-handle"
|
|
260
|
+
style={{ ...handleSourceStyle(selected), right: '-8px', top: '35%' }}
|
|
261
|
+
/>
|
|
262
|
+
<Handle
|
|
263
|
+
type="source"
|
|
264
|
+
position={Position.Right}
|
|
265
|
+
id="done"
|
|
266
|
+
className="connection-handle"
|
|
267
|
+
style={{ ...handleSourceStyle(selected), right: '-8px', top: '65%' }}
|
|
268
|
+
/>
|
|
269
|
+
<Handle
|
|
270
|
+
type="target"
|
|
271
|
+
position={Position.Right}
|
|
272
|
+
id="right-target"
|
|
273
|
+
style={handleTargetStyle('right', selected)}
|
|
274
|
+
/>
|
|
275
|
+
</>
|
|
251
276
|
) : (
|
|
252
277
|
<>
|
|
253
278
|
<Handle
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Typography } from '@mui/material';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import {
|
|
5
|
+
AutomationAIReasoning,
|
|
6
|
+
AutomationConfigurationCard,
|
|
7
|
+
AutomationConfigurationCardType,
|
|
8
|
+
} from '../../types/automation-node-data-types';
|
|
9
|
+
|
|
10
|
+
const CONFIGURATION_CARD_ICONS: Record<
|
|
11
|
+
AutomationConfigurationCardType,
|
|
12
|
+
{ icon: string; defaultColor: string }
|
|
13
|
+
> = {
|
|
14
|
+
model: { icon: '⚙️', defaultColor: '#ffffff' },
|
|
15
|
+
knowledgeBase: { icon: '🗄️', defaultColor: '#10b981' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface AutomationAIReasoningSectionProps {
|
|
19
|
+
aiReasoning?: AutomationAIReasoning;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const AutomationAIReasoningSection: React.FC<
|
|
23
|
+
AutomationAIReasoningSectionProps
|
|
24
|
+
> = ({ aiReasoning }) => {
|
|
25
|
+
const { t } = useTranslation();
|
|
26
|
+
const text = aiReasoning?.text?.trim();
|
|
27
|
+
|
|
28
|
+
if (!text) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Box
|
|
34
|
+
sx={{
|
|
35
|
+
backgroundColor: '#1F2937',
|
|
36
|
+
borderRadius: '8px',
|
|
37
|
+
padding: '12px',
|
|
38
|
+
mb: 2,
|
|
39
|
+
border: '1px solid #374151',
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
43
|
+
<Box sx={{ color: '#34D399', fontSize: '16px' }}>🧠</Box>
|
|
44
|
+
<Typography
|
|
45
|
+
variant="body2"
|
|
46
|
+
sx={{ color: '#34D399', fontSize: '12px', fontWeight: 600 }}
|
|
47
|
+
>
|
|
48
|
+
{t('automation.formattingNode.aiReasoning')}
|
|
49
|
+
</Typography>
|
|
50
|
+
</Box>
|
|
51
|
+
|
|
52
|
+
<Box
|
|
53
|
+
sx={{
|
|
54
|
+
backgroundColor: 'transparent',
|
|
55
|
+
borderRadius: '4px',
|
|
56
|
+
padding: '8px',
|
|
57
|
+
border: '1px solid #4B5563',
|
|
58
|
+
minHeight: '40px',
|
|
59
|
+
display: 'flex',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<Typography
|
|
64
|
+
variant="body2"
|
|
65
|
+
sx={{
|
|
66
|
+
color: '#9CA3AF',
|
|
67
|
+
fontSize: '12px',
|
|
68
|
+
lineHeight: 1.4,
|
|
69
|
+
margin: 0,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{text}
|
|
73
|
+
</Typography>
|
|
74
|
+
</Box>
|
|
75
|
+
</Box>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface AutomationConfigurationCardsSectionProps {
|
|
80
|
+
configurationCards?: AutomationConfigurationCard[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const AutomationConfigurationCardsSection: React.FC<
|
|
84
|
+
AutomationConfigurationCardsSectionProps
|
|
85
|
+
> = ({ configurationCards }) => {
|
|
86
|
+
const configuredCards = (configurationCards ?? []).filter(
|
|
87
|
+
(card) => card.type && card.label?.trim(),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (configuredCards.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
{configuredCards.map((card, index) => {
|
|
97
|
+
const iconConfig = CONFIGURATION_CARD_ICONS[card.type];
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Box
|
|
101
|
+
key={`${card.type}-${index}`}
|
|
102
|
+
sx={{
|
|
103
|
+
backgroundColor: '#1F2937',
|
|
104
|
+
borderRadius: '6px',
|
|
105
|
+
padding: '8px 12px',
|
|
106
|
+
mb: 1,
|
|
107
|
+
display: 'flex',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
gap: 1,
|
|
110
|
+
border: '1px solid #374151',
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<Box
|
|
114
|
+
sx={{
|
|
115
|
+
color: card.iconColor || iconConfig.defaultColor,
|
|
116
|
+
fontSize: '16px',
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{iconConfig.icon}
|
|
120
|
+
</Box>
|
|
121
|
+
<Typography
|
|
122
|
+
variant="body2"
|
|
123
|
+
sx={{ color: '#ffffff', fontSize: '11px', fontWeight: 500 }}
|
|
124
|
+
>
|
|
125
|
+
{card.label}
|
|
126
|
+
</Typography>
|
|
127
|
+
</Box>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
@@ -51,6 +51,12 @@ export type {
|
|
|
51
51
|
AutomationDecisionNodeData,
|
|
52
52
|
AutomationDecisionNodeProps,
|
|
53
53
|
} from './AutomationDecisionNode';
|
|
54
|
+
// Loop / for-each node
|
|
55
|
+
export { AutomationLoopNode } from './AutomationLoopNode';
|
|
56
|
+
export type {
|
|
57
|
+
AutomationLoopNodeData,
|
|
58
|
+
AutomationLoopNodeProps,
|
|
59
|
+
} from './AutomationLoopNode';
|
|
54
60
|
// SMS node
|
|
55
61
|
export { AutomationSmsNode } from './AutomationSmsNode';
|
|
56
62
|
export type {
|
|
@@ -299,6 +299,16 @@ export const DecisionNodeIcon: React.FC<SvgIconProps> = ({ size, color, style })
|
|
|
299
299
|
</svg>
|
|
300
300
|
);
|
|
301
301
|
|
|
302
|
+
export const LoopNodeIcon: React.FC<SvgIconProps> = ({ size, color, style }) => (
|
|
303
|
+
<svg width="35" height="35" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" style={style}>
|
|
304
|
+
<path d="M0 14C0 6.26801 6.26801 0 14 0C21.732 0 28 6.26801 28 14C28 21.732 21.732 28 14 28C6.26801 28 0 21.732 0 14Z" fill="#1E3A8A"/>
|
|
305
|
+
<path d="M17.5 9.5H11.5C10.3954 9.5 9.5 10.3954 9.5 11.5V13.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
|
|
306
|
+
<path d="M10.5 18.5H16.5C17.6046 18.5 18.5 17.6046 18.5 16.5V14.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
|
|
307
|
+
<path d="M9.5 13.5L7.5 11.5L9.5 9.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
|
|
308
|
+
<path d="M18.5 14.5L20.5 16.5L18.5 18.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
|
|
309
|
+
</svg>
|
|
310
|
+
);
|
|
311
|
+
|
|
302
312
|
export const SmsNodeIcon: React.FC<SvgIconProps> = ({ size, color, style }) => (
|
|
303
313
|
<svg width="35" height="35" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" style={style}>
|
|
304
314
|
<path d="M0 14C0 6.26801 6.26801 0 14 0C21.732 0 28 6.26801 28 14C28 21.732 21.732 28 14 28C6.26801 28 0 21.732 0 14Z" fill="#1E3A8A"/>
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
// Automation Node Data Types
|
|
2
|
+
export type AutomationConfigurationCardType = 'model' | 'knowledgeBase';
|
|
3
|
+
|
|
4
|
+
export interface AutomationConfigurationCard {
|
|
5
|
+
type: AutomationConfigurationCardType;
|
|
6
|
+
label: string;
|
|
7
|
+
iconColor?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AutomationAIReasoning {
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
2
14
|
export interface AutomationNodeFormData {
|
|
3
15
|
nodeId: string;
|
|
4
16
|
title: string;
|
|
5
17
|
headerText?: string;
|
|
6
|
-
type: 'start' | 'api' | 'formatting' | 'end' | 'navigation' | 'decision' | 'sms' | 'voice-call' | 'workflow';
|
|
18
|
+
type: 'start' | 'api' | 'formatting' | 'end' | 'navigation' | 'decision' | 'loop' | 'sms' | 'voice-call' | 'workflow';
|
|
7
19
|
isPinned?: boolean;
|
|
8
20
|
isBlock?: boolean;
|
|
9
21
|
blocks?: Array<{
|
|
@@ -26,6 +38,13 @@ export interface AutomationNodeFormData {
|
|
|
26
38
|
value: string | number | boolean;
|
|
27
39
|
}>;
|
|
28
40
|
};
|
|
41
|
+
aiReasoning?: AutomationAIReasoning;
|
|
42
|
+
configurationCards?: AutomationConfigurationCard[];
|
|
43
|
+
schemaMatch?: {
|
|
44
|
+
matched: number;
|
|
45
|
+
partial: number;
|
|
46
|
+
ignored: number;
|
|
47
|
+
};
|
|
29
48
|
// Execution result storage
|
|
30
49
|
executionResult?: {
|
|
31
50
|
success: boolean;
|
|
@@ -203,6 +222,17 @@ export interface AutomationDecisionNodeForm extends AutomationNodeFormData {
|
|
|
203
222
|
defaultBranch?: string;
|
|
204
223
|
}
|
|
205
224
|
|
|
225
|
+
export interface AutomationLoopNodeForm extends AutomationNodeFormData {
|
|
226
|
+
type: 'loop';
|
|
227
|
+
arraySource: {
|
|
228
|
+
type: 'previousStep' | 'variable';
|
|
229
|
+
variableName?: string;
|
|
230
|
+
};
|
|
231
|
+
maxIterations?: number;
|
|
232
|
+
currentItemVariable?: string;
|
|
233
|
+
loopIndexVariable?: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
206
236
|
export interface AutomationSmsNodeForm extends AutomationNodeFormData {
|
|
207
237
|
type: 'sms';
|
|
208
238
|
recipientPhone: string;
|
|
@@ -256,6 +286,7 @@ export type AutomationNodeForm =
|
|
|
256
286
|
| AutomationEndNodeForm
|
|
257
287
|
| AutomationNavigationNodeForm
|
|
258
288
|
| AutomationDecisionNodeForm
|
|
289
|
+
| AutomationLoopNodeForm
|
|
259
290
|
| AutomationSmsNodeForm
|
|
260
291
|
| AutomationVoiceCallNodeForm
|
|
261
292
|
| AutomationWorkflowNodeForm;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
AutomationInteractionNode,
|
|
16
16
|
AutomationMonitoringNode,
|
|
17
17
|
AutomationDecisionNode,
|
|
18
|
+
AutomationLoopNode,
|
|
18
19
|
AutomationSmsNode,
|
|
19
20
|
AutomationVoiceCallNode,
|
|
20
21
|
AutomationWorkflowNode,
|
|
@@ -36,6 +37,7 @@ const nodeTypes = {
|
|
|
36
37
|
AutomationInteractionNode,
|
|
37
38
|
AutomationMonitoringNode,
|
|
38
39
|
AutomationDecisionNode,
|
|
40
|
+
AutomationLoopNode,
|
|
39
41
|
AutomationSmsNode,
|
|
40
42
|
AutomationVoiceCallNode,
|
|
41
43
|
AutomationWorkflowNode,
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
AutomationApiNodeForm,
|
|
6
6
|
AutomationFormattingNodeForm,
|
|
7
7
|
AutomationEndNodeForm,
|
|
8
|
+
AutomationLoopNodeForm,
|
|
8
9
|
} from '../types/automation-node-data-types';
|
|
10
|
+
import { assertAutomationLoopNodeValid } from './validateAutomationLoopNode';
|
|
9
11
|
|
|
10
12
|
export interface AutomationContext {
|
|
11
13
|
runId: string;
|
|
@@ -128,6 +130,192 @@ export class AutomationExecutionEngine {
|
|
|
128
130
|
return this.nodes.filter((node) => nextNodeIds.includes(node.id));
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
private getOutgoingEdge(
|
|
134
|
+
nodeId: string,
|
|
135
|
+
sourceHandle: string,
|
|
136
|
+
): Edge | undefined {
|
|
137
|
+
return this.edges.find(
|
|
138
|
+
(edge) => edge.source === nodeId && edge.sourceHandle === sourceHandle,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private getTargetNode(edge: Edge): Node | undefined {
|
|
143
|
+
return this.nodes.find((node) => node.id === edge.target);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private getFirstOutgoingEdge(nodeId: string): Edge | undefined {
|
|
147
|
+
return this.edges.find((edge) => edge.source === nodeId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private resolveArraySource(
|
|
151
|
+
formData: AutomationLoopNodeForm,
|
|
152
|
+
inputData: string | number | boolean | object | null,
|
|
153
|
+
): unknown[] {
|
|
154
|
+
if (formData.arraySource.type === 'variable') {
|
|
155
|
+
const variableName = formData.arraySource.variableName;
|
|
156
|
+
if (!variableName) {
|
|
157
|
+
throw new Error('Loop node variable name is not configured');
|
|
158
|
+
}
|
|
159
|
+
const value = this.context.variables[variableName];
|
|
160
|
+
if (!Array.isArray(value)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Loop variable "${variableName}" must be an array, got ${typeof value}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (inputData === null || inputData === undefined) {
|
|
169
|
+
throw new Error('Loop node previous step output is empty');
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(inputData)) {
|
|
172
|
+
return inputData;
|
|
173
|
+
}
|
|
174
|
+
return [inputData];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async runNodeWithStatus(
|
|
178
|
+
node: Node,
|
|
179
|
+
inputData?: string | number | boolean | object | null,
|
|
180
|
+
): Promise<string | number | boolean | object | null> {
|
|
181
|
+
this.log(
|
|
182
|
+
'info',
|
|
183
|
+
node.id,
|
|
184
|
+
String(node.type || 'node'),
|
|
185
|
+
'Executing node...',
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
(node.data as Record<string, unknown>).status = 'Running';
|
|
189
|
+
if (this.onNodeUpdate) {
|
|
190
|
+
this.onNodeUpdate(node.id, { ...node.data });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const result = await this.executeNode(node, inputData);
|
|
195
|
+
(node.data as Record<string, unknown>).status = 'Completed';
|
|
196
|
+
(node.data as Record<string, unknown>).lastRun = new Date().toISOString();
|
|
197
|
+
if (this.onNodeUpdate) {
|
|
198
|
+
this.onNodeUpdate(node.id, { ...node.data });
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
(node.data as Record<string, unknown>).status = 'Error';
|
|
203
|
+
(node.data as Record<string, unknown>).lastRun = new Date().toISOString();
|
|
204
|
+
if (this.onNodeUpdate) {
|
|
205
|
+
this.onNodeUpdate(node.id, { ...node.data });
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async executeBodySubgraph(
|
|
212
|
+
startNode: Node,
|
|
213
|
+
loopNodeId: string,
|
|
214
|
+
inputData: string | number | boolean | object | null,
|
|
215
|
+
): Promise<string | number | boolean | object | null> {
|
|
216
|
+
let currentNode: Node | null = startNode;
|
|
217
|
+
let currentData = inputData;
|
|
218
|
+
const visitedInBody = new Set<string>();
|
|
219
|
+
|
|
220
|
+
while (currentNode && currentNode.id !== loopNodeId) {
|
|
221
|
+
if (visitedInBody.has(currentNode.id)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Infinite loop detected in loop body at node ${currentNode.id}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
visitedInBody.add(currentNode.id);
|
|
227
|
+
|
|
228
|
+
currentData = await this.runNodeWithStatus(currentNode, currentData);
|
|
229
|
+
|
|
230
|
+
const nextEdge = this.getFirstOutgoingEdge(currentNode.id);
|
|
231
|
+
if (!nextEdge) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Loop body node ${currentNode.id} has no outgoing edge back to the loop node`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const nextNode = this.getTargetNode(nextEdge);
|
|
238
|
+
if (!nextNode) {
|
|
239
|
+
throw new Error(`Loop body target node not found for edge ${nextEdge.id}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
currentNode = nextNode;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return currentData;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async executeLoopNode(
|
|
249
|
+
node: Node,
|
|
250
|
+
inputData: string | number | boolean | object | null,
|
|
251
|
+
): Promise<{ doneNextNode: Node | null; result: object }> {
|
|
252
|
+
assertAutomationLoopNodeValid(node, this.edges);
|
|
253
|
+
|
|
254
|
+
const formData = (node.data.formData || node.data) as AutomationLoopNodeForm;
|
|
255
|
+
const maxIterations = formData.maxIterations ?? 100;
|
|
256
|
+
const currentItemVariable = formData.currentItemVariable ?? 'currentItem';
|
|
257
|
+
const loopIndexVariable = formData.loopIndexVariable ?? 'loopIndex';
|
|
258
|
+
|
|
259
|
+
const array = this.resolveArraySource(formData, inputData);
|
|
260
|
+
const iterationCount = Math.min(array.length, maxIterations);
|
|
261
|
+
|
|
262
|
+
this.log(
|
|
263
|
+
'info',
|
|
264
|
+
node.id,
|
|
265
|
+
'AutomationLoopNode',
|
|
266
|
+
`Starting loop over ${iterationCount} item(s)`,
|
|
267
|
+
{ totalItems: array.length, maxIterations },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const loopEdge = this.getOutgoingEdge(node.id, 'loop');
|
|
271
|
+
if (!loopEdge) {
|
|
272
|
+
throw new Error('Loop node is missing the "loop" handle edge');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const bodyStartNode = this.getTargetNode(loopEdge);
|
|
276
|
+
if (!bodyStartNode) {
|
|
277
|
+
throw new Error('Loop body entry node not found');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < iterationCount; i++) {
|
|
281
|
+
this.context.variables[currentItemVariable] = array[i] as
|
|
282
|
+
| string
|
|
283
|
+
| number
|
|
284
|
+
| boolean
|
|
285
|
+
| object
|
|
286
|
+
| null;
|
|
287
|
+
this.context.variables[loopIndexVariable] = i;
|
|
288
|
+
|
|
289
|
+
this.log(
|
|
290
|
+
'info',
|
|
291
|
+
node.id,
|
|
292
|
+
'AutomationLoopNode',
|
|
293
|
+
`Loop iteration ${i + 1}/${iterationCount}`,
|
|
294
|
+
{ [currentItemVariable]: array[i], [loopIndexVariable]: i },
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
await this.executeBodySubgraph(bodyStartNode, node.id, array[i] as
|
|
298
|
+
| string
|
|
299
|
+
| number
|
|
300
|
+
| boolean
|
|
301
|
+
| object
|
|
302
|
+
| null);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const result = {
|
|
306
|
+
iterationsRun: iterationCount,
|
|
307
|
+
totalItems: array.length,
|
|
308
|
+
maxIterations,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
this.storeExecutionResult(node.id, result, true);
|
|
312
|
+
|
|
313
|
+
const doneEdge = this.getOutgoingEdge(node.id, 'done');
|
|
314
|
+
const doneNextNode = doneEdge ? this.getTargetNode(doneEdge) ?? null : null;
|
|
315
|
+
|
|
316
|
+
return { doneNextNode, result };
|
|
317
|
+
}
|
|
318
|
+
|
|
131
319
|
private async executeStartNode(
|
|
132
320
|
node: Node,
|
|
133
321
|
): Promise<{ runId: string; startTime: string }> {
|
|
@@ -522,6 +710,21 @@ export class AutomationExecutionEngine {
|
|
|
522
710
|
return inputData;
|
|
523
711
|
}
|
|
524
712
|
|
|
713
|
+
private async executeStubNode(
|
|
714
|
+
node: Node,
|
|
715
|
+
inputData: string | number | boolean | object | null,
|
|
716
|
+
): Promise<string | number | boolean | object | null> {
|
|
717
|
+
this.log(
|
|
718
|
+
'info',
|
|
719
|
+
node.id,
|
|
720
|
+
String(node.type || 'node'),
|
|
721
|
+
'Node executed (stub — full runtime not yet implemented)',
|
|
722
|
+
inputData,
|
|
723
|
+
);
|
|
724
|
+
this.storeExecutionResult(node.id, inputData, true);
|
|
725
|
+
return inputData;
|
|
726
|
+
}
|
|
727
|
+
|
|
525
728
|
private async executeNode(
|
|
526
729
|
node: Node,
|
|
527
730
|
inputData?: string | number | boolean | object | null,
|
|
@@ -537,7 +740,14 @@ export class AutomationExecutionEngine {
|
|
|
537
740
|
return await this.executeFormattingNode(node, inputData || null);
|
|
538
741
|
case 'AutomationEndNode':
|
|
539
742
|
return await this.executeEndNode(node, inputData || null);
|
|
743
|
+
case 'AutomationLoopNode':
|
|
744
|
+
throw new Error(
|
|
745
|
+
'Loop nodes must be executed via executeLoopNode from the workflow runner',
|
|
746
|
+
);
|
|
540
747
|
default:
|
|
748
|
+
if (typeof nodeType === 'string' && nodeType.startsWith('Automation')) {
|
|
749
|
+
return await this.executeStubNode(node, inputData || null);
|
|
750
|
+
}
|
|
541
751
|
throw new Error(`Unknown node type: ${nodeType}`);
|
|
542
752
|
}
|
|
543
753
|
}
|
|
@@ -560,60 +770,77 @@ export class AutomationExecutionEngine {
|
|
|
560
770
|
const visitedNodes = new Set<string>();
|
|
561
771
|
|
|
562
772
|
while (currentNode) {
|
|
563
|
-
if (
|
|
773
|
+
if (
|
|
774
|
+
currentNode.type !== 'AutomationLoopNode' &&
|
|
775
|
+
visitedNodes.has(currentNode.id)
|
|
776
|
+
) {
|
|
564
777
|
throw new Error(
|
|
565
778
|
`Circular dependency detected: node ${currentNode.id} already visited`,
|
|
566
779
|
);
|
|
567
780
|
}
|
|
568
781
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
// Announce node execution start
|
|
572
|
-
this.log(
|
|
573
|
-
'info',
|
|
574
|
-
currentNode.id,
|
|
575
|
-
String(currentNode.type || 'node'),
|
|
576
|
-
'Executing node...',
|
|
577
|
-
);
|
|
578
|
-
|
|
579
|
-
// Update node status to running
|
|
580
|
-
(currentNode.data as Record<string, unknown>).status = 'Running';
|
|
581
|
-
if (this.onNodeUpdate) {
|
|
582
|
-
// Create new object reference so React detects the change
|
|
583
|
-
this.onNodeUpdate(currentNode.id, { ...currentNode.data });
|
|
782
|
+
if (currentNode.type !== 'AutomationLoopNode') {
|
|
783
|
+
visitedNodes.add(currentNode.id);
|
|
584
784
|
}
|
|
585
785
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
(currentNode.data as Record<string, unknown>).status = 'Completed';
|
|
589
|
-
(currentNode.data as Record<string, unknown>).lastRun =
|
|
590
|
-
new Date().toISOString();
|
|
786
|
+
if (currentNode.type === 'AutomationLoopNode') {
|
|
787
|
+
(currentNode.data as Record<string, unknown>).status = 'Running';
|
|
591
788
|
if (this.onNodeUpdate) {
|
|
592
|
-
// Create new object reference so React detects the change
|
|
593
789
|
this.onNodeUpdate(currentNode.id, { ...currentNode.data });
|
|
594
790
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
const { doneNextNode, result } = await this.executeLoopNode(
|
|
794
|
+
currentNode,
|
|
795
|
+
currentData,
|
|
796
|
+
);
|
|
797
|
+
currentData = result;
|
|
798
|
+
(currentNode.data as Record<string, unknown>).status = 'Completed';
|
|
799
|
+
(currentNode.data as Record<string, unknown>).lastRun =
|
|
800
|
+
new Date().toISOString();
|
|
801
|
+
if (this.onNodeUpdate) {
|
|
802
|
+
this.onNodeUpdate(currentNode.id, { ...currentNode.data });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (doneNextNode) {
|
|
806
|
+
this.log(
|
|
807
|
+
'info',
|
|
808
|
+
doneNextNode.id,
|
|
809
|
+
String(doneNextNode.type || 'node'),
|
|
810
|
+
'Moving to done path after loop',
|
|
811
|
+
);
|
|
812
|
+
currentNode = doneNextNode;
|
|
813
|
+
} else {
|
|
814
|
+
currentNode = null;
|
|
815
|
+
}
|
|
816
|
+
} catch (error) {
|
|
817
|
+
(currentNode.data as Record<string, unknown>).status = 'Error';
|
|
818
|
+
(currentNode.data as Record<string, unknown>).lastRun =
|
|
819
|
+
new Date().toISOString();
|
|
820
|
+
if (this.onNodeUpdate) {
|
|
821
|
+
this.onNodeUpdate(currentNode.id, { ...currentNode.data });
|
|
822
|
+
}
|
|
823
|
+
throw error;
|
|
602
824
|
}
|
|
603
|
-
|
|
825
|
+
continue;
|
|
604
826
|
}
|
|
605
827
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
this.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
828
|
+
currentData = await this.runNodeWithStatus(currentNode, currentData);
|
|
829
|
+
|
|
830
|
+
const nextEdge = this.getFirstOutgoingEdge(currentNode.id);
|
|
831
|
+
if (nextEdge) {
|
|
832
|
+
const next = this.getTargetNode(nextEdge);
|
|
833
|
+
if (next) {
|
|
834
|
+
this.log(
|
|
835
|
+
'info',
|
|
836
|
+
next.id,
|
|
837
|
+
String(next.type || 'node'),
|
|
838
|
+
'Moving to next node',
|
|
839
|
+
);
|
|
840
|
+
currentNode = next;
|
|
841
|
+
} else {
|
|
842
|
+
currentNode = null;
|
|
843
|
+
}
|
|
617
844
|
} else {
|
|
618
845
|
this.log('info', 'workflow', 'engine', 'No next node. Ending.');
|
|
619
846
|
currentNode = null;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
AutomationApiNodeForm,
|
|
6
6
|
AutomationFormattingNodeForm,
|
|
7
7
|
AutomationEndNodeForm,
|
|
8
|
+
AutomationLoopNodeForm,
|
|
8
9
|
} from '../types/automation-node-data-types';
|
|
9
10
|
import { Node, Edge } from '@xyflow/react';
|
|
10
11
|
|
|
@@ -165,6 +166,22 @@ export class AutomationFlowProcessor {
|
|
|
165
166
|
case 'end':
|
|
166
167
|
// End node doesn't create new variables
|
|
167
168
|
break;
|
|
169
|
+
|
|
170
|
+
case 'loop': {
|
|
171
|
+
const currentItemVar = formData.currentItemVariable ?? 'currentItem';
|
|
172
|
+
const loopIndexVar = formData.loopIndexVariable ?? 'loopIndex';
|
|
173
|
+
for (const varName of [currentItemVar, loopIndexVar]) {
|
|
174
|
+
if (!variables.find((v) => v.name === varName)) {
|
|
175
|
+
variables.push({
|
|
176
|
+
name: varName,
|
|
177
|
+
type: varName === loopIndexVar ? 'number' : 'object',
|
|
178
|
+
defaultValue: null,
|
|
179
|
+
description: `Loop iteration variable from ${formData.title}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
168
185
|
}
|
|
169
186
|
}
|
|
170
187
|
|
|
@@ -252,6 +269,12 @@ module.exports = { executeAutomationWorkflow };
|
|
|
252
269
|
case 'end':
|
|
253
270
|
code += this.generateEndNodeCode(formData as AutomationEndNodeForm);
|
|
254
271
|
break;
|
|
272
|
+
case 'loop':
|
|
273
|
+
code += this.generateLoopNodeCode(
|
|
274
|
+
formData as AutomationLoopNodeForm,
|
|
275
|
+
this.edges,
|
|
276
|
+
);
|
|
277
|
+
break;
|
|
255
278
|
}
|
|
256
279
|
|
|
257
280
|
return code;
|
|
@@ -539,6 +562,53 @@ module.exports = { executeAutomationWorkflow };
|
|
|
539
562
|
`;
|
|
540
563
|
}
|
|
541
564
|
|
|
565
|
+
private generateLoopNodeCode(
|
|
566
|
+
formData: AutomationLoopNodeForm,
|
|
567
|
+
edges: Edge[],
|
|
568
|
+
): string {
|
|
569
|
+
const maxIterations = formData.maxIterations ?? 100;
|
|
570
|
+
const currentItemVariable = formData.currentItemVariable ?? 'currentItem';
|
|
571
|
+
const loopIndexVariable = formData.loopIndexVariable ?? 'loopIndex';
|
|
572
|
+
|
|
573
|
+
const arrayExpr =
|
|
574
|
+
formData.arraySource.type === 'variable'
|
|
575
|
+
? `context.variables['${formData.arraySource.variableName}']`
|
|
576
|
+
: '/* previous step output */ context.variables.__previousStepOutput';
|
|
577
|
+
|
|
578
|
+
const loopEdge = edges.find(
|
|
579
|
+
(edge) => edge.source === formData.nodeId && edge.sourceHandle === 'loop',
|
|
580
|
+
);
|
|
581
|
+
const doneEdge = edges.find(
|
|
582
|
+
(edge) => edge.source === formData.nodeId && edge.sourceHandle === 'done',
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const bodyComment = loopEdge
|
|
586
|
+
? `// Body starts at node: ${loopEdge.target}`
|
|
587
|
+
: '// Body entry not connected';
|
|
588
|
+
const doneComment = doneEdge
|
|
589
|
+
? `// Continue on done path at node: ${doneEdge.target}`
|
|
590
|
+
: '// Done path not connected';
|
|
591
|
+
|
|
592
|
+
return ` // Loop Node: ${formData.title}
|
|
593
|
+
context.logs.push({
|
|
594
|
+
timestamp: new Date().toISOString(),
|
|
595
|
+
level: 'info',
|
|
596
|
+
nodeId: '${formData.nodeId}',
|
|
597
|
+
message: 'Starting for-each loop',
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const __loopArray = Array.isArray(${arrayExpr}) ? ${arrayExpr} : [${arrayExpr}];
|
|
601
|
+
const __maxIter = ${maxIterations};
|
|
602
|
+
for (let __i = 0; __i < Math.min(__loopArray.length, __maxIter); __i++) {
|
|
603
|
+
context.variables['${currentItemVariable}'] = __loopArray[__i];
|
|
604
|
+
context.variables['${loopIndexVariable}'] = __i;
|
|
605
|
+
${bodyComment}
|
|
606
|
+
// Execute loop body nodes here (back-edge returns to loop node)
|
|
607
|
+
}
|
|
608
|
+
${doneComment}
|
|
609
|
+
`;
|
|
610
|
+
}
|
|
611
|
+
|
|
542
612
|
/**
|
|
543
613
|
* Export workflow data as JSON
|
|
544
614
|
*/
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Edge, Node } from '@xyflow/react';
|
|
2
|
+
import { AutomationLoopNodeForm } from '../types/automation-node-data-types';
|
|
3
|
+
|
|
4
|
+
export interface LoopNodeValidationResult {
|
|
5
|
+
valid: boolean;
|
|
6
|
+
errors: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getFormData(node: Node): Partial<AutomationLoopNodeForm> {
|
|
10
|
+
return (node.data?.formData ?? node.data ?? {}) as Partial<AutomationLoopNodeForm>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getOutgoingEdges(nodeId: string, edges: Edge[], sourceHandle?: string): Edge[] {
|
|
14
|
+
return edges.filter(
|
|
15
|
+
(edge) =>
|
|
16
|
+
edge.source === nodeId &&
|
|
17
|
+
(sourceHandle === undefined || edge.sourceHandle === sourceHandle),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getReachableBodyNodeIds(
|
|
22
|
+
startNodeId: string,
|
|
23
|
+
loopNodeId: string,
|
|
24
|
+
edges: Edge[],
|
|
25
|
+
): Set<string> {
|
|
26
|
+
const reachable = new Set<string>();
|
|
27
|
+
const queue = [startNodeId];
|
|
28
|
+
|
|
29
|
+
while (queue.length > 0) {
|
|
30
|
+
const currentId = queue.shift()!;
|
|
31
|
+
if (currentId === loopNodeId || reachable.has(currentId)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
reachable.add(currentId);
|
|
35
|
+
|
|
36
|
+
const outgoing = edges.filter((edge) => edge.source === currentId);
|
|
37
|
+
for (const edge of outgoing) {
|
|
38
|
+
if (edge.target !== loopNodeId && !reachable.has(edge.target)) {
|
|
39
|
+
queue.push(edge.target);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return reachable;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function validateAutomationLoopNode(
|
|
48
|
+
node: Node,
|
|
49
|
+
edges: Edge[],
|
|
50
|
+
): LoopNodeValidationResult {
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
const formData = getFormData(node);
|
|
53
|
+
const nodeId = node.id;
|
|
54
|
+
|
|
55
|
+
if (!formData.arraySource?.type) {
|
|
56
|
+
errors.push('Array data source is not configured');
|
|
57
|
+
} else if (
|
|
58
|
+
formData.arraySource.type === 'variable' &&
|
|
59
|
+
!formData.arraySource.variableName?.trim()
|
|
60
|
+
) {
|
|
61
|
+
errors.push('Variable name is required when array source type is "variable"');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const loopEdges = getOutgoingEdges(nodeId, edges, 'loop');
|
|
65
|
+
const doneEdges = getOutgoingEdges(nodeId, edges, 'done');
|
|
66
|
+
|
|
67
|
+
if (loopEdges.length === 0) {
|
|
68
|
+
errors.push('Loop node must have exactly one outgoing edge on the "loop" handle');
|
|
69
|
+
} else if (loopEdges.length > 1) {
|
|
70
|
+
errors.push('Loop node must have only one outgoing edge on the "loop" handle');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (doneEdges.length === 0) {
|
|
74
|
+
errors.push('Loop node must have exactly one outgoing edge on the "done" handle');
|
|
75
|
+
} else if (doneEdges.length > 1) {
|
|
76
|
+
errors.push('Loop node must have only one outgoing edge on the "done" handle');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (loopEdges.length === 1) {
|
|
80
|
+
const bodyStartId = loopEdges[0].target;
|
|
81
|
+
const bodyNodeIds = getReachableBodyNodeIds(bodyStartId, nodeId, edges);
|
|
82
|
+
const hasBackEdge = edges.some(
|
|
83
|
+
(edge) => edge.target === nodeId && bodyNodeIds.has(edge.source),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (!hasBackEdge) {
|
|
87
|
+
errors.push(
|
|
88
|
+
'Loop body must connect back to the Loop node to advance iterations',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
valid: errors.length === 0,
|
|
95
|
+
errors,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function assertAutomationLoopNodeValid(node: Node, edges: Edge[]): void {
|
|
100
|
+
const result = validateAutomationLoopNode(node, edges);
|
|
101
|
+
if (!result.valid) {
|
|
102
|
+
throw new Error(result.errors.join('; '));
|
|
103
|
+
}
|
|
104
|
+
}
|