@runtypelabs/react-flow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,544 @@
1
+ import type { FlowStep, RuntypeNode, RuntypeEdge, RuntypeNodeData } from '../types'
2
+ import type { FlowStepType } from '@travrse/shared'
3
+
4
+ // ============================================================================
5
+ // Constants
6
+ // ============================================================================
7
+
8
+ const NODE_WIDTH = 280
9
+ const NODE_HEIGHT = 200 // Increased for better card visibility
10
+ const NODE_SPACING_X = 350 // Horizontal space between main steps
11
+ const NODE_SPACING_Y = 80 // Vertical space between branch steps
12
+ const BRANCH_OFFSET_X = 350 // Horizontal offset for branches to the right of conditional
13
+ const BRANCH_OFFSET_Y = -80 // Vertical offset for true branch (above center)
14
+ const FALSE_BRANCH_GAP = 100 // Gap between true and false branches
15
+
16
+ // ============================================================================
17
+ // Flow Steps to React Flow Nodes
18
+ // ============================================================================
19
+
20
+ interface FlowStepsToNodesOptions {
21
+ onChange?: (stepId: string, updates: Partial<FlowStep>) => void
22
+ onDelete?: (stepId: string) => void
23
+ startPosition?: { x: number; y: number }
24
+ /** Internal: prefix for nested step IDs */
25
+ idPrefix?: string
26
+ /** Internal: parent conditional ID */
27
+ parentId?: string
28
+ }
29
+
30
+ /**
31
+ * Convert Runtype FlowStep array to React Flow Node array
32
+ * Handles nested conditional branches with vertical stacking below conditional
33
+ */
34
+ export function flowStepsToNodes(
35
+ steps: FlowStep[],
36
+ options?: FlowStepsToNodesOptions
37
+ ): RuntypeNode[] {
38
+ const {
39
+ onChange,
40
+ onDelete,
41
+ startPosition = { x: 50, y: 200 },
42
+ idPrefix = '',
43
+ parentId,
44
+ } = options ?? {}
45
+
46
+ // Sort steps by order
47
+ const sortedSteps = [...steps].sort((a, b) => a.order - b.order)
48
+
49
+ const nodes: RuntypeNode[] = []
50
+ let currentX = startPosition.x
51
+ let maxBranchY = startPosition.y // Track the maximum Y extent for branches
52
+
53
+ for (const step of sortedSteps) {
54
+ const nodeId = idPrefix ? `${idPrefix}${step.id}` : step.id
55
+
56
+ const nodeData: RuntypeNodeData = {
57
+ step,
58
+ label: step.name || getDefaultStepName(step.type),
59
+ onChange,
60
+ onDelete,
61
+ }
62
+
63
+ const node: RuntypeNode = {
64
+ id: nodeId,
65
+ type: step.type,
66
+ position: { x: currentX, y: startPosition.y },
67
+ data: nodeData,
68
+ draggable: true,
69
+ selectable: true,
70
+ }
71
+
72
+ if (parentId) {
73
+ node.parentId = parentId
74
+ }
75
+
76
+ nodes.push(node)
77
+
78
+ // DEBUG: Log node positions
79
+ console.log(`[flowStepsToNodes] Placed "${step.name}" (${step.type}) at x=${currentX}`)
80
+
81
+ // Handle conditional branches - position to the right with true above, false below
82
+ if (step.type === 'conditional' && step.config) {
83
+ const config = step.config as { trueSteps?: FlowStep[]; falseSteps?: FlowStep[] }
84
+ const branchX = currentX + BRANCH_OFFSET_X
85
+
86
+ // Calculate branch lengths (horizontal extent)
87
+ const trueBranchLength = config.trueSteps?.length || 0
88
+ const falseBranchLength = config.falseSteps?.length || 0
89
+ const maxBranchLength = Math.max(trueBranchLength, falseBranchLength)
90
+
91
+ // Calculate true branch height (vertical extent for stacking)
92
+ const trueBranchHeight = trueBranchLength * (NODE_HEIGHT + NODE_SPACING_Y)
93
+
94
+ // True branch nodes (positioned to the right, above center)
95
+ if (config.trueSteps && config.trueSteps.length > 0) {
96
+ const trueBranchY = startPosition.y + BRANCH_OFFSET_Y
97
+ const trueBranchNodes = flowStepsToNodes(config.trueSteps, {
98
+ onChange,
99
+ onDelete,
100
+ startPosition: {
101
+ x: branchX,
102
+ y: trueBranchY
103
+ },
104
+ idPrefix: `${nodeId}-true-`,
105
+ parentId: nodeId,
106
+ })
107
+
108
+ nodes.push(...trueBranchNodes)
109
+ maxBranchY = Math.max(maxBranchY, trueBranchY + trueBranchHeight)
110
+ }
111
+
112
+ // False branch nodes (positioned to the right, below center)
113
+ if (config.falseSteps && config.falseSteps.length > 0) {
114
+ // Position false branch below true branch (or at center + gap if no true branch)
115
+ const falseBranchY = trueBranchLength > 0
116
+ ? startPosition.y + BRANCH_OFFSET_Y + trueBranchHeight + FALSE_BRANCH_GAP
117
+ : startPosition.y + NODE_HEIGHT + NODE_SPACING_Y
118
+
119
+ const falseBranchNodes = flowStepsToNodes(config.falseSteps, {
120
+ onChange,
121
+ onDelete,
122
+ startPosition: {
123
+ x: branchX,
124
+ y: falseBranchY
125
+ },
126
+ idPrefix: `${nodeId}-false-`,
127
+ parentId: nodeId,
128
+ })
129
+
130
+ nodes.push(...falseBranchNodes)
131
+ const falseBranchHeight = config.falseSteps.length * (NODE_HEIGHT + NODE_SPACING_Y)
132
+ maxBranchY = Math.max(maxBranchY, falseBranchY + falseBranchHeight)
133
+ }
134
+
135
+ // Skip past the conditional AND all its branch steps
136
+ // Branches start at branchX, so we need to account for:
137
+ // - The offset from conditional to first branch step (BRANCH_OFFSET_X)
138
+ // - All the branch steps (maxBranchLength * (NODE_WIDTH + NODE_SPACING_X))
139
+ if (maxBranchLength > 0) {
140
+ const advance = BRANCH_OFFSET_X + (maxBranchLength * (NODE_WIDTH + NODE_SPACING_X))
141
+ console.log(`[flowStepsToNodes] Conditional "${step.name}" has ${maxBranchLength} branch steps, advancing currentX by ${advance}`)
142
+ currentX += advance
143
+ console.log(`[flowStepsToNodes] After conditional, currentX = ${currentX}`)
144
+ } else {
145
+ currentX += NODE_WIDTH + NODE_SPACING_X
146
+ }
147
+ } else {
148
+ // Move to the next X position for horizontal layout (non-conditional steps)
149
+ currentX += NODE_WIDTH + NODE_SPACING_X
150
+ }
151
+ }
152
+
153
+ return nodes
154
+ }
155
+
156
+ // ============================================================================
157
+ // React Flow Nodes to Flow Steps
158
+ // ============================================================================
159
+
160
+ /**
161
+ * Convert React Flow Node array back to Runtype FlowStep array
162
+ */
163
+ export function nodesToFlowSteps(nodes: RuntypeNode[]): FlowStep[] {
164
+ // Filter out branch nodes (handled within conditional steps)
165
+ const topLevelNodes = nodes.filter(n => !n.parentId && !n.id.includes('-true-') && !n.id.includes('-false-'))
166
+
167
+ // Sort by X position to determine order (horizontal layout)
168
+ const sortedNodes = [...topLevelNodes].sort((a, b) => a.position.x - b.position.x)
169
+
170
+ return sortedNodes.map((node, index) => {
171
+ const step = node.data.step
172
+
173
+ // Handle conditional steps - extract nested steps
174
+ if (step.type === 'conditional') {
175
+ const trueSteps = extractBranchSteps(nodes, node.id, 'true')
176
+ const falseSteps = extractBranchSteps(nodes, node.id, 'false')
177
+
178
+ return {
179
+ ...step,
180
+ order: index,
181
+ config: {
182
+ ...step.config,
183
+ trueSteps,
184
+ falseSteps,
185
+ },
186
+ }
187
+ }
188
+
189
+ return {
190
+ ...step,
191
+ order: index,
192
+ }
193
+ })
194
+ }
195
+
196
+ /**
197
+ * Extract branch steps from conditional node
198
+ */
199
+ function extractBranchSteps(nodes: RuntypeNode[], parentId: string, branch: 'true' | 'false'): FlowStep[] {
200
+ // Match nodes with the branch prefix pattern
201
+ const branchPrefix = `${parentId}-${branch}-`
202
+ const branchNodes = nodes.filter(n => n.id.startsWith(branchPrefix))
203
+
204
+ // Sort by X position (horizontal layout within branches)
205
+ const sortedBranchNodes = [...branchNodes].sort((a, b) => a.position.x - b.position.x)
206
+
207
+ return sortedBranchNodes.map((node, index) => ({
208
+ ...node.data.step,
209
+ order: index,
210
+ }))
211
+ }
212
+
213
+ // ============================================================================
214
+ // Create Edges from Nodes
215
+ // ============================================================================
216
+
217
+ /**
218
+ * Create edges connecting nodes in sequence
219
+ */
220
+ export function createEdgesFromNodes(nodes: RuntypeNode[]): RuntypeEdge[] {
221
+ const edges: RuntypeEdge[] = []
222
+
223
+ // Helper to check if a node is a branch node
224
+ const isBranchNode = (n: RuntypeNode) => n.parentId || n.id.includes('-true-') || n.id.includes('-false-')
225
+
226
+ // Get top-level nodes sorted by X position (horizontal layout)
227
+ const topLevelNodes = nodes
228
+ .filter(n => !isBranchNode(n))
229
+ .sort((a, b) => a.position.x - b.position.x)
230
+
231
+ // Create sequential edges for top-level nodes
232
+ // Skip conditionals - their branches connect to the next step instead
233
+ for (let i = 0; i < topLevelNodes.length - 1; i++) {
234
+ const sourceNode = topLevelNodes[i]
235
+ const targetNode = topLevelNodes[i + 1]
236
+
237
+ // Skip edge from conditional - branches will connect to next step
238
+ if (sourceNode.data.step.type === 'conditional') {
239
+ continue
240
+ }
241
+
242
+ edges.push({
243
+ id: `edge-${sourceNode.id}-${targetNode.id}`,
244
+ source: sourceNode.id,
245
+ target: targetNode.id,
246
+ sourceHandle: 'output',
247
+ type: 'smoothstep',
248
+ animated: false,
249
+ data: { stepOrder: i },
250
+ })
251
+ }
252
+
253
+ // Create edges for conditional branches
254
+ const conditionalNodes = nodes.filter(n => n.data.step.type === 'conditional' && !isBranchNode(n))
255
+
256
+ for (const conditionalNode of conditionalNodes) {
257
+ const conditionalId = conditionalNode.id
258
+ const conditionalIndex = topLevelNodes.findIndex(n => n.id === conditionalId)
259
+ const nextMainStep = conditionalIndex < topLevelNodes.length - 1
260
+ ? topLevelNodes[conditionalIndex + 1]
261
+ : null
262
+
263
+ // Find true branch nodes (contain -true- after the conditional ID)
264
+ // Sort by X position since branches flow horizontally
265
+ const trueBranchNodes = nodes
266
+ .filter(n => n.id.startsWith(`${conditionalId}-true-`))
267
+ .sort((a, b) => a.position.x - b.position.x)
268
+
269
+ // Find false branch nodes (contain -false- after the conditional ID)
270
+ // Sort by X position since branches flow horizontally
271
+ const falseBranchNodes = nodes
272
+ .filter(n => n.id.startsWith(`${conditionalId}-false-`))
273
+ .sort((a, b) => a.position.x - b.position.x)
274
+
275
+ // Connect conditional to first true branch node (branch goes right)
276
+ if (trueBranchNodes.length > 0) {
277
+ edges.push({
278
+ id: `edge-${conditionalId}-to-true-branch`,
279
+ source: conditionalId,
280
+ target: trueBranchNodes[0].id,
281
+ sourceHandle: 'true',
282
+ type: 'smoothstep',
283
+ animated: false,
284
+ label: 'True',
285
+ labelStyle: { fill: '#22c55e', fontWeight: 600, fontSize: 11 },
286
+ labelBgStyle: { fill: '#f0fdf4', fillOpacity: 0.9 },
287
+ labelBgPadding: [4, 6] as [number, number],
288
+ labelBgBorderRadius: 4,
289
+ style: { stroke: '#22c55e', strokeWidth: 2 },
290
+ })
291
+
292
+ // Connect true branch nodes sequentially (horizontal connections)
293
+ for (let i = 0; i < trueBranchNodes.length - 1; i++) {
294
+ edges.push({
295
+ id: `edge-true-${trueBranchNodes[i].id}-${trueBranchNodes[i + 1].id}`,
296
+ source: trueBranchNodes[i].id,
297
+ target: trueBranchNodes[i + 1].id,
298
+ sourceHandle: 'output',
299
+ type: 'smoothstep',
300
+ animated: false,
301
+ style: { stroke: '#22c55e', strokeWidth: 1.5 },
302
+ })
303
+ }
304
+
305
+ // Connect last true branch node to next main step (convergence)
306
+ if (nextMainStep) {
307
+ const lastTrueNode = trueBranchNodes[trueBranchNodes.length - 1]
308
+ edges.push({
309
+ id: `edge-true-${lastTrueNode.id}-to-${nextMainStep.id}`,
310
+ source: lastTrueNode.id,
311
+ target: nextMainStep.id,
312
+ sourceHandle: 'output',
313
+ type: 'smoothstep',
314
+ animated: false,
315
+ style: { stroke: '#22c55e', strokeWidth: 1.5 },
316
+ })
317
+ }
318
+ } else if (nextMainStep) {
319
+ // No true branch steps - connect conditional directly to next step via true handle
320
+ edges.push({
321
+ id: `edge-${conditionalId}-true-to-${nextMainStep.id}`,
322
+ source: conditionalId,
323
+ target: nextMainStep.id,
324
+ sourceHandle: 'true',
325
+ type: 'smoothstep',
326
+ animated: false,
327
+ label: 'True',
328
+ labelStyle: { fill: '#22c55e', fontWeight: 600, fontSize: 11 },
329
+ labelBgStyle: { fill: '#f0fdf4', fillOpacity: 0.9 },
330
+ labelBgPadding: [4, 6] as [number, number],
331
+ labelBgBorderRadius: 4,
332
+ style: { stroke: '#22c55e', strokeWidth: 2 },
333
+ })
334
+ }
335
+
336
+ // Connect conditional to first false branch node (branch goes right, below true)
337
+ if (falseBranchNodes.length > 0) {
338
+ edges.push({
339
+ id: `edge-${conditionalId}-to-false-branch`,
340
+ source: conditionalId,
341
+ target: falseBranchNodes[0].id,
342
+ sourceHandle: 'false',
343
+ type: 'smoothstep',
344
+ animated: false,
345
+ label: 'False',
346
+ labelStyle: { fill: '#ef4444', fontWeight: 600, fontSize: 11 },
347
+ labelBgStyle: { fill: '#fef2f2', fillOpacity: 0.9 },
348
+ labelBgPadding: [4, 6] as [number, number],
349
+ labelBgBorderRadius: 4,
350
+ style: { stroke: '#ef4444', strokeWidth: 2 },
351
+ })
352
+
353
+ // Connect false branch nodes sequentially (horizontal connections)
354
+ for (let i = 0; i < falseBranchNodes.length - 1; i++) {
355
+ edges.push({
356
+ id: `edge-false-${falseBranchNodes[i].id}-${falseBranchNodes[i + 1].id}`,
357
+ source: falseBranchNodes[i].id,
358
+ target: falseBranchNodes[i + 1].id,
359
+ sourceHandle: 'output',
360
+ type: 'smoothstep',
361
+ animated: false,
362
+ style: { stroke: '#ef4444', strokeWidth: 1.5 },
363
+ })
364
+ }
365
+
366
+ // Connect last false branch node to next main step (convergence)
367
+ if (nextMainStep) {
368
+ const lastFalseNode = falseBranchNodes[falseBranchNodes.length - 1]
369
+ edges.push({
370
+ id: `edge-false-${lastFalseNode.id}-to-${nextMainStep.id}`,
371
+ source: lastFalseNode.id,
372
+ target: nextMainStep.id,
373
+ sourceHandle: 'output',
374
+ type: 'smoothstep',
375
+ animated: false,
376
+ style: { stroke: '#ef4444', strokeWidth: 1.5 },
377
+ })
378
+ }
379
+ } else if (nextMainStep) {
380
+ // No false branch steps - connect conditional directly to next step via false handle
381
+ edges.push({
382
+ id: `edge-${conditionalId}-false-to-${nextMainStep.id}`,
383
+ source: conditionalId,
384
+ target: nextMainStep.id,
385
+ sourceHandle: 'false',
386
+ type: 'smoothstep',
387
+ animated: false,
388
+ label: 'False',
389
+ labelStyle: { fill: '#ef4444', fontWeight: 600, fontSize: 11 },
390
+ labelBgStyle: { fill: '#fef2f2', fillOpacity: 0.9 },
391
+ labelBgPadding: [4, 6] as [number, number],
392
+ labelBgBorderRadius: 4,
393
+ style: { stroke: '#ef4444', strokeWidth: 2 },
394
+ })
395
+ }
396
+
397
+ // Handle case where conditional has no branches - connect directly to next step
398
+ if (trueBranchNodes.length === 0 && falseBranchNodes.length === 0 && nextMainStep) {
399
+ edges.push({
400
+ id: `edge-${conditionalId}-to-${nextMainStep.id}`,
401
+ source: conditionalId,
402
+ target: nextMainStep.id,
403
+ sourceHandle: 'output',
404
+ type: 'smoothstep',
405
+ animated: false,
406
+ })
407
+ }
408
+ }
409
+
410
+ return edges
411
+ }
412
+
413
+ // ============================================================================
414
+ // Utility Functions
415
+ // ============================================================================
416
+
417
+ /**
418
+ * Get default name for a step type
419
+ */
420
+ export function getDefaultStepName(type: FlowStepType): string {
421
+ const names: Record<FlowStepType, string> = {
422
+ 'prompt': 'AI Prompt',
423
+ 'fetch-url': 'Fetch URL',
424
+ 'retrieve-record': 'Retrieve Record',
425
+ 'fetch-github': 'Fetch GitHub',
426
+ 'api-call': 'API Call',
427
+ 'transform-data': 'Transform Data',
428
+ 'conditional': 'Conditional',
429
+ 'set-variable': 'Set Variable',
430
+ 'upsert-record': 'Upsert Record',
431
+ 'send-email': 'Send Email',
432
+ 'send-text': 'Send Text',
433
+ 'send-event': 'Send Event',
434
+ 'send-stream': 'Send Stream',
435
+ 'update-record': 'Update Record',
436
+ 'search': 'Search',
437
+ 'generate-embedding': 'Generate Embedding',
438
+ 'vector-search': 'Vector Search',
439
+ 'tool-call': 'Tool Call',
440
+ 'wait-until': 'Wait Until',
441
+ }
442
+
443
+ return names[type] || type
444
+ }
445
+
446
+ /**
447
+ * Create a new flow step with default configuration
448
+ */
449
+ export function createDefaultStep(type: FlowStepType, order: number = 0): FlowStep {
450
+ const id = `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
451
+
452
+ const baseStep = {
453
+ id,
454
+ type,
455
+ name: getDefaultStepName(type),
456
+ order,
457
+ enabled: true,
458
+ }
459
+
460
+ switch (type) {
461
+ case 'prompt':
462
+ return {
463
+ ...baseStep,
464
+ config: {
465
+ mode: 'instruction',
466
+ model: '',
467
+ userPrompt: '',
468
+ responseFormat: 'text',
469
+ outputVariable: `${type}_result`,
470
+ },
471
+ }
472
+
473
+ case 'fetch-url':
474
+ return {
475
+ ...baseStep,
476
+ config: {
477
+ http: {
478
+ url: '',
479
+ method: 'GET',
480
+ },
481
+ responseType: 'json',
482
+ outputVariable: 'fetch_result',
483
+ },
484
+ }
485
+
486
+ case 'transform-data':
487
+ return {
488
+ ...baseStep,
489
+ config: {
490
+ script: '// Transform your data here\nreturn { result: input }',
491
+ outputVariable: 'transform_result',
492
+ },
493
+ }
494
+
495
+ case 'conditional':
496
+ return {
497
+ ...baseStep,
498
+ config: {
499
+ condition: 'true',
500
+ trueSteps: [],
501
+ falseSteps: [],
502
+ },
503
+ }
504
+
505
+ case 'send-email':
506
+ return {
507
+ ...baseStep,
508
+ config: {
509
+ from: 'no-reply@messages.runtype.com',
510
+ to: '',
511
+ subject: '',
512
+ html: '',
513
+ outputVariable: 'email_result',
514
+ },
515
+ }
516
+
517
+ default:
518
+ return {
519
+ ...baseStep,
520
+ config: {
521
+ outputVariable: `${type.replace(/-/g, '_')}_result`,
522
+ },
523
+ }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Generate a unique step ID
529
+ */
530
+ export function generateStepId(prefix: string = 'step'): string {
531
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
532
+ }
533
+
534
+ /**
535
+ * Clone a step with a new ID
536
+ */
537
+ export function cloneStep(step: FlowStep): FlowStep {
538
+ return {
539
+ ...step,
540
+ id: generateStepId(step.type),
541
+ config: JSON.parse(JSON.stringify(step.config)),
542
+ }
543
+ }
544
+