elsabro 2.3.0 → 3.7.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.
- package/README.md +668 -20
- package/bin/install.js +0 -0
- package/flows/development-flow.json +452 -0
- package/flows/quick-flow.json +118 -0
- package/package.json +3 -2
- package/references/SYSTEM_INDEX.md +379 -5
- package/references/agent-marketplace.md +2274 -0
- package/references/agent-protocol.md +1126 -0
- package/references/ai-code-suggestions.md +2413 -0
- package/references/checkpointing.md +595 -0
- package/references/collaboration-patterns.md +851 -0
- package/references/collaborative-sessions.md +1081 -0
- package/references/configuration-management.md +1810 -0
- package/references/cost-tracking.md +1095 -0
- package/references/enterprise-sso.md +2001 -0
- package/references/error-contracts-v2.md +968 -0
- package/references/event-driven.md +1031 -0
- package/references/flow-orchestration.md +940 -0
- package/references/flow-visualization.md +1557 -0
- package/references/ide-integrations.md +3513 -0
- package/references/interrupt-system.md +681 -0
- package/references/kubernetes-deployment.md +3099 -0
- package/references/memory-system.md +683 -0
- package/references/mobile-companion.md +3236 -0
- package/references/multi-llm-providers.md +2494 -0
- package/references/multi-project-memory.md +1182 -0
- package/references/observability.md +793 -0
- package/references/output-schemas.md +858 -0
- package/references/performance-profiler.md +955 -0
- package/references/plugin-system.md +1526 -0
- package/references/prompt-management.md +292 -0
- package/references/sandbox-execution.md +303 -0
- package/references/security-system.md +1253 -0
- package/references/streaming.md +696 -0
- package/references/testing-framework.md +1151 -0
- package/references/time-travel.md +802 -0
- package/references/tool-registry.md +886 -0
- package/references/voice-commands.md +3296 -0
- package/templates/agent-marketplace-config.json +220 -0
- package/templates/agent-protocol-config.json +136 -0
- package/templates/ai-suggestions-config.json +100 -0
- package/templates/checkpoint-state.json +61 -0
- package/templates/collaboration-config.json +157 -0
- package/templates/collaborative-sessions-config.json +153 -0
- package/templates/configuration-config.json +245 -0
- package/templates/cost-tracking-config.json +148 -0
- package/templates/enterprise-sso-config.json +438 -0
- package/templates/events-config.json +148 -0
- package/templates/flow-visualization-config.json +196 -0
- package/templates/ide-integrations-config.json +442 -0
- package/templates/kubernetes-config.json +764 -0
- package/templates/memory-state.json +84 -0
- package/templates/mobile-companion-config.json +600 -0
- package/templates/multi-llm-config.json +544 -0
- package/templates/multi-project-memory-config.json +145 -0
- package/templates/observability-config.json +109 -0
- package/templates/performance-profiler-config.json +125 -0
- package/templates/plugin-config.json +170 -0
- package/templates/prompt-management-config.json +86 -0
- package/templates/sandbox-config.json +185 -0
- package/templates/schemas-config.json +65 -0
- package/templates/security-config.json +120 -0
- package/templates/streaming-config.json +72 -0
- package/templates/testing-config.json +81 -0
- package/templates/timetravel-config.json +62 -0
- package/templates/tool-registry-config.json +109 -0
- package/templates/voice-commands-config.json +658 -0
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
# ELSABRO Flow Visualization System
|
|
2
|
+
|
|
3
|
+
> Sistema de visualización y edición visual de flows con graph rendering interactivo.
|
|
4
|
+
|
|
5
|
+
## Arquitectura General
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Flow Visualization System │
|
|
10
|
+
├─────────────────────────────────────────────────────────────────────────┤
|
|
11
|
+
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
12
|
+
│ │ FlowVisualizer │ │ GraphRenderer │ │ NodeEditor │ │
|
|
13
|
+
│ │ ───────────── │ │ ───────────── │ │ ───────────── │ │
|
|
14
|
+
│ │ • Parse flows │ │ • Layout algo │ │ • Node config │ │
|
|
15
|
+
│ │ • State sync │ │ • ASCII render │ │ • Validation │ │
|
|
16
|
+
│ │ • Export/Import │ │ • SVG export │ │ • Templates │ │
|
|
17
|
+
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
|
18
|
+
│ │ │
|
|
19
|
+
│ ┌───────────────────────────┴───────────────────────────────┐ │
|
|
20
|
+
│ │ ConnectionManager │ │
|
|
21
|
+
│ │ • Edge routing • Validation • Auto-connect • Cycles │ │
|
|
22
|
+
│ └────────────────────────────────────────────────────────────┘ │
|
|
23
|
+
│ │ │
|
|
24
|
+
│ ┌───────────────────────────┴───────────────────────────────┐ │
|
|
25
|
+
│ │ InteractiveCanvas │ │
|
|
26
|
+
│ │ • Pan/Zoom • Selection • Drag & Drop • Keyboard nav │ │
|
|
27
|
+
│ └────────────────────────────────────────────────────────────┘ │
|
|
28
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. FlowVisualizer
|
|
34
|
+
|
|
35
|
+
### Propósito
|
|
36
|
+
Componente principal que coordina la visualización y edición de flows.
|
|
37
|
+
|
|
38
|
+
### Interfaz
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
interface FlowVisualizerOptions {
|
|
42
|
+
flow: Flow;
|
|
43
|
+
mode: 'view' | 'edit';
|
|
44
|
+
renderer: 'ascii' | 'svg' | 'html';
|
|
45
|
+
theme: VisualizerTheme;
|
|
46
|
+
layout: LayoutAlgorithm;
|
|
47
|
+
showMinimap?: boolean;
|
|
48
|
+
showToolbar?: boolean;
|
|
49
|
+
readonly?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface VisualizerTheme {
|
|
53
|
+
background: string;
|
|
54
|
+
nodeColors: Record<NodeType, string>;
|
|
55
|
+
edgeColor: string;
|
|
56
|
+
selectedColor: string;
|
|
57
|
+
errorColor: string;
|
|
58
|
+
font: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type LayoutAlgorithm =
|
|
62
|
+
| 'dagre' // Directed acyclic graph (default)
|
|
63
|
+
| 'elk' // Eclipse Layout Kernel
|
|
64
|
+
| 'force' // Force-directed
|
|
65
|
+
| 'tree' // Hierarchical tree
|
|
66
|
+
| 'radial' // Radial layout
|
|
67
|
+
| 'manual'; // User-positioned
|
|
68
|
+
|
|
69
|
+
interface FlowVisualizer {
|
|
70
|
+
// Core
|
|
71
|
+
render(): string | HTMLElement;
|
|
72
|
+
update(flow: Flow): void;
|
|
73
|
+
refresh(): void;
|
|
74
|
+
|
|
75
|
+
// Navigation
|
|
76
|
+
fitToView(): void;
|
|
77
|
+
zoomIn(): void;
|
|
78
|
+
zoomOut(): void;
|
|
79
|
+
zoomTo(level: number): void;
|
|
80
|
+
panTo(nodeId: string): void;
|
|
81
|
+
center(): void;
|
|
82
|
+
|
|
83
|
+
// Selection
|
|
84
|
+
select(nodeId: string): void;
|
|
85
|
+
selectMultiple(nodeIds: string[]): void;
|
|
86
|
+
clearSelection(): void;
|
|
87
|
+
getSelected(): string[];
|
|
88
|
+
|
|
89
|
+
// Editing (when mode='edit')
|
|
90
|
+
addNode(type: NodeType, position?: Position): string;
|
|
91
|
+
removeNode(nodeId: string): void;
|
|
92
|
+
updateNode(nodeId: string, config: Partial<NodeConfig>): void;
|
|
93
|
+
connect(sourceId: string, targetId: string, label?: string): void;
|
|
94
|
+
disconnect(sourceId: string, targetId: string): void;
|
|
95
|
+
|
|
96
|
+
// Export/Import
|
|
97
|
+
exportFlow(): Flow;
|
|
98
|
+
exportImage(format: 'svg' | 'png'): Promise<Blob>;
|
|
99
|
+
exportASCII(): string;
|
|
100
|
+
importFlow(flow: Flow): void;
|
|
101
|
+
|
|
102
|
+
// Events
|
|
103
|
+
on(event: VisualizerEvent, callback: Function): () => void;
|
|
104
|
+
off(event: VisualizerEvent, callback: Function): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type VisualizerEvent =
|
|
108
|
+
| 'node:click'
|
|
109
|
+
| 'node:dblclick'
|
|
110
|
+
| 'node:select'
|
|
111
|
+
| 'node:move'
|
|
112
|
+
| 'edge:click'
|
|
113
|
+
| 'edge:select'
|
|
114
|
+
| 'canvas:click'
|
|
115
|
+
| 'flow:change'
|
|
116
|
+
| 'zoom:change'
|
|
117
|
+
| 'selection:change';
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Implementación
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
class FlowVisualizerImpl implements FlowVisualizer {
|
|
124
|
+
private flow: Flow;
|
|
125
|
+
private options: FlowVisualizerOptions;
|
|
126
|
+
private renderer: GraphRenderer;
|
|
127
|
+
private layout: LayoutEngine;
|
|
128
|
+
private selection: Set<string> = new Set();
|
|
129
|
+
private eventEmitter: EventEmitter = new EventEmitter();
|
|
130
|
+
private zoom: number = 1;
|
|
131
|
+
private pan: Position = { x: 0, y: 0 };
|
|
132
|
+
|
|
133
|
+
constructor(options: FlowVisualizerOptions) {
|
|
134
|
+
this.options = {
|
|
135
|
+
showMinimap: true,
|
|
136
|
+
showToolbar: true,
|
|
137
|
+
readonly: false,
|
|
138
|
+
...options
|
|
139
|
+
};
|
|
140
|
+
this.flow = options.flow;
|
|
141
|
+
this.renderer = this.createRenderer(options.renderer);
|
|
142
|
+
this.layout = this.createLayout(options.layout);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private createRenderer(type: string): GraphRenderer {
|
|
146
|
+
switch (type) {
|
|
147
|
+
case 'ascii':
|
|
148
|
+
return new ASCIIRenderer(this.options.theme);
|
|
149
|
+
case 'svg':
|
|
150
|
+
return new SVGRenderer(this.options.theme);
|
|
151
|
+
case 'html':
|
|
152
|
+
return new HTMLRenderer(this.options.theme);
|
|
153
|
+
default:
|
|
154
|
+
return new ASCIIRenderer(this.options.theme);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private createLayout(algorithm: LayoutAlgorithm): LayoutEngine {
|
|
159
|
+
switch (algorithm) {
|
|
160
|
+
case 'dagre':
|
|
161
|
+
return new DagreLayout();
|
|
162
|
+
case 'elk':
|
|
163
|
+
return new ELKLayout();
|
|
164
|
+
case 'force':
|
|
165
|
+
return new ForceDirectedLayout();
|
|
166
|
+
case 'tree':
|
|
167
|
+
return new TreeLayout();
|
|
168
|
+
case 'radial':
|
|
169
|
+
return new RadialLayout();
|
|
170
|
+
case 'manual':
|
|
171
|
+
return new ManualLayout();
|
|
172
|
+
default:
|
|
173
|
+
return new DagreLayout();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
render(): string | HTMLElement {
|
|
178
|
+
// Calculate layout positions
|
|
179
|
+
const positions = this.layout.calculate(this.flow);
|
|
180
|
+
|
|
181
|
+
// Apply positions to flow
|
|
182
|
+
const layoutedFlow = this.applyPositions(this.flow, positions);
|
|
183
|
+
|
|
184
|
+
// Render with current state
|
|
185
|
+
return this.renderer.render(layoutedFlow, {
|
|
186
|
+
zoom: this.zoom,
|
|
187
|
+
pan: this.pan,
|
|
188
|
+
selection: this.selection,
|
|
189
|
+
showMinimap: this.options.showMinimap,
|
|
190
|
+
showToolbar: this.options.showToolbar,
|
|
191
|
+
mode: this.options.mode
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
update(flow: Flow): void {
|
|
196
|
+
this.flow = flow;
|
|
197
|
+
this.eventEmitter.emit('flow:change', flow);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Selection
|
|
201
|
+
select(nodeId: string): void {
|
|
202
|
+
this.selection.clear();
|
|
203
|
+
this.selection.add(nodeId);
|
|
204
|
+
this.eventEmitter.emit('selection:change', Array.from(this.selection));
|
|
205
|
+
this.eventEmitter.emit('node:select', { nodeId });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
selectMultiple(nodeIds: string[]): void {
|
|
209
|
+
this.selection = new Set(nodeIds);
|
|
210
|
+
this.eventEmitter.emit('selection:change', nodeIds);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
clearSelection(): void {
|
|
214
|
+
this.selection.clear();
|
|
215
|
+
this.eventEmitter.emit('selection:change', []);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getSelected(): string[] {
|
|
219
|
+
return Array.from(this.selection);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Editing
|
|
223
|
+
addNode(type: NodeType, position?: Position): string {
|
|
224
|
+
if (this.options.readonly) {
|
|
225
|
+
throw new Error('Cannot edit in readonly mode');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const nodeId = `node_${Date.now()}`;
|
|
229
|
+
const newNode: FlowNode = {
|
|
230
|
+
id: nodeId,
|
|
231
|
+
type,
|
|
232
|
+
config: this.getDefaultConfig(type),
|
|
233
|
+
position: position || this.getNextPosition()
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
this.flow.nodes.push(newNode);
|
|
237
|
+
this.eventEmitter.emit('flow:change', this.flow);
|
|
238
|
+
return nodeId;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
removeNode(nodeId: string): void {
|
|
242
|
+
if (this.options.readonly) {
|
|
243
|
+
throw new Error('Cannot edit in readonly mode');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Remove node
|
|
247
|
+
this.flow.nodes = this.flow.nodes.filter(n => n.id !== nodeId);
|
|
248
|
+
|
|
249
|
+
// Remove connected edges
|
|
250
|
+
this.flow.edges = this.flow.edges.filter(
|
|
251
|
+
e => e.source !== nodeId && e.target !== nodeId
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
this.selection.delete(nodeId);
|
|
255
|
+
this.eventEmitter.emit('flow:change', this.flow);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
connect(sourceId: string, targetId: string, label?: string): void {
|
|
259
|
+
if (this.options.readonly) {
|
|
260
|
+
throw new Error('Cannot edit in readonly mode');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Validate connection
|
|
264
|
+
const validation = this.validateConnection(sourceId, targetId);
|
|
265
|
+
if (!validation.valid) {
|
|
266
|
+
throw new Error(validation.error);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const edge: FlowEdge = {
|
|
270
|
+
id: `edge_${sourceId}_${targetId}`,
|
|
271
|
+
source: sourceId,
|
|
272
|
+
target: targetId,
|
|
273
|
+
label
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
this.flow.edges.push(edge);
|
|
277
|
+
this.eventEmitter.emit('flow:change', this.flow);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
disconnect(sourceId: string, targetId: string): void {
|
|
281
|
+
if (this.options.readonly) {
|
|
282
|
+
throw new Error('Cannot edit in readonly mode');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
this.flow.edges = this.flow.edges.filter(
|
|
286
|
+
e => !(e.source === sourceId && e.target === targetId)
|
|
287
|
+
);
|
|
288
|
+
this.eventEmitter.emit('flow:change', this.flow);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private validateConnection(sourceId: string, targetId: string): { valid: boolean; error?: string } {
|
|
292
|
+
// Check nodes exist
|
|
293
|
+
const sourceNode = this.flow.nodes.find(n => n.id === sourceId);
|
|
294
|
+
const targetNode = this.flow.nodes.find(n => n.id === targetId);
|
|
295
|
+
|
|
296
|
+
if (!sourceNode || !targetNode) {
|
|
297
|
+
return { valid: false, error: 'Node not found' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check for self-connection
|
|
301
|
+
if (sourceId === targetId) {
|
|
302
|
+
return { valid: false, error: 'Cannot connect node to itself' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check for duplicate connection
|
|
306
|
+
const exists = this.flow.edges.some(
|
|
307
|
+
e => e.source === sourceId && e.target === targetId
|
|
308
|
+
);
|
|
309
|
+
if (exists) {
|
|
310
|
+
return { valid: false, error: 'Connection already exists' };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Check for cycles (if DAG required)
|
|
314
|
+
if (this.wouldCreateCycle(sourceId, targetId)) {
|
|
315
|
+
return { valid: false, error: 'Connection would create a cycle' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { valid: true };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private wouldCreateCycle(sourceId: string, targetId: string): boolean {
|
|
322
|
+
// DFS to check if adding edge would create cycle
|
|
323
|
+
const visited = new Set<string>();
|
|
324
|
+
const stack = [sourceId];
|
|
325
|
+
|
|
326
|
+
// First, check if target can reach source
|
|
327
|
+
const reverseStack = [targetId];
|
|
328
|
+
while (reverseStack.length > 0) {
|
|
329
|
+
const current = reverseStack.pop()!;
|
|
330
|
+
if (current === sourceId) {
|
|
331
|
+
return true; // Would create cycle
|
|
332
|
+
}
|
|
333
|
+
if (visited.has(current)) continue;
|
|
334
|
+
visited.add(current);
|
|
335
|
+
|
|
336
|
+
// Get outgoing edges from current
|
|
337
|
+
const outgoing = this.flow.edges
|
|
338
|
+
.filter(e => e.source === current)
|
|
339
|
+
.map(e => e.target);
|
|
340
|
+
reverseStack.push(...outgoing);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Export
|
|
347
|
+
exportFlow(): Flow {
|
|
348
|
+
return JSON.parse(JSON.stringify(this.flow));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async exportImage(format: 'svg' | 'png'): Promise<Blob> {
|
|
352
|
+
if (this.renderer instanceof SVGRenderer) {
|
|
353
|
+
const svg = this.renderer.renderToString(this.flow);
|
|
354
|
+
|
|
355
|
+
if (format === 'svg') {
|
|
356
|
+
return new Blob([svg], { type: 'image/svg+xml' });
|
|
357
|
+
} else {
|
|
358
|
+
// Convert SVG to PNG
|
|
359
|
+
return this.svgToPng(svg);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
throw new Error('Image export only supported with SVG renderer');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
exportASCII(): string {
|
|
366
|
+
const asciiRenderer = new ASCIIRenderer(this.options.theme);
|
|
367
|
+
const positions = this.layout.calculate(this.flow);
|
|
368
|
+
const layoutedFlow = this.applyPositions(this.flow, positions);
|
|
369
|
+
return asciiRenderer.renderToString(layoutedFlow);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Events
|
|
373
|
+
on(event: VisualizerEvent, callback: Function): () => void {
|
|
374
|
+
this.eventEmitter.on(event, callback);
|
|
375
|
+
return () => this.eventEmitter.off(event, callback);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
off(event: VisualizerEvent, callback: Function): void {
|
|
379
|
+
this.eventEmitter.off(event, callback);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private getDefaultConfig(type: NodeType): NodeConfig {
|
|
383
|
+
const defaults: Record<NodeType, NodeConfig> = {
|
|
384
|
+
entry: { name: 'Start' },
|
|
385
|
+
exit: { name: 'End' },
|
|
386
|
+
agent: { name: 'Agent', agentType: 'general-purpose' },
|
|
387
|
+
parallel: { name: 'Parallel', branches: [] },
|
|
388
|
+
sequence: { name: 'Sequence', steps: [] },
|
|
389
|
+
condition: { name: 'Condition', expression: 'true' },
|
|
390
|
+
router: { name: 'Router', routes: [] },
|
|
391
|
+
loop: { name: 'Loop', maxIterations: 10 },
|
|
392
|
+
interrupt: { name: 'Interrupt', type: 'approval' },
|
|
393
|
+
subflow: { name: 'Subflow', flowId: '' }
|
|
394
|
+
};
|
|
395
|
+
return defaults[type] || { name: type };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private getNextPosition(): Position {
|
|
399
|
+
// Find rightmost node and place new node to its right
|
|
400
|
+
let maxX = 0;
|
|
401
|
+
for (const node of this.flow.nodes) {
|
|
402
|
+
if (node.position && node.position.x > maxX) {
|
|
403
|
+
maxX = node.position.x;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { x: maxX + 200, y: 100 };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private applyPositions(flow: Flow, positions: Map<string, Position>): Flow {
|
|
410
|
+
return {
|
|
411
|
+
...flow,
|
|
412
|
+
nodes: flow.nodes.map(node => ({
|
|
413
|
+
...node,
|
|
414
|
+
position: positions.get(node.id) || node.position || { x: 0, y: 0 }
|
|
415
|
+
}))
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## 2. GraphRenderer
|
|
424
|
+
|
|
425
|
+
### Propósito
|
|
426
|
+
Renderiza el grafo del flow en diferentes formatos (ASCII, SVG, HTML).
|
|
427
|
+
|
|
428
|
+
### ASCII Renderer
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
interface ASCIIRendererOptions {
|
|
432
|
+
width?: number;
|
|
433
|
+
height?: number;
|
|
434
|
+
nodeWidth?: number;
|
|
435
|
+
nodeHeight?: number;
|
|
436
|
+
spacing?: number;
|
|
437
|
+
charset?: 'unicode' | 'ascii';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
class ASCIIRenderer implements GraphRenderer {
|
|
441
|
+
private options: Required<ASCIIRendererOptions>;
|
|
442
|
+
private theme: VisualizerTheme;
|
|
443
|
+
|
|
444
|
+
// Unicode box drawing characters
|
|
445
|
+
private chars = {
|
|
446
|
+
unicode: {
|
|
447
|
+
horizontal: '─',
|
|
448
|
+
vertical: '│',
|
|
449
|
+
topLeft: '┌',
|
|
450
|
+
topRight: '┐',
|
|
451
|
+
bottomLeft: '└',
|
|
452
|
+
bottomRight: '┘',
|
|
453
|
+
cross: '┼',
|
|
454
|
+
teeDown: '┬',
|
|
455
|
+
teeUp: '┴',
|
|
456
|
+
teeRight: '├',
|
|
457
|
+
teeLeft: '┤',
|
|
458
|
+
arrowRight: '→',
|
|
459
|
+
arrowDown: '↓',
|
|
460
|
+
arrowLeft: '←',
|
|
461
|
+
arrowUp: '↑',
|
|
462
|
+
bullet: '●',
|
|
463
|
+
diamond: '◆',
|
|
464
|
+
square: '■'
|
|
465
|
+
},
|
|
466
|
+
ascii: {
|
|
467
|
+
horizontal: '-',
|
|
468
|
+
vertical: '|',
|
|
469
|
+
topLeft: '+',
|
|
470
|
+
topRight: '+',
|
|
471
|
+
bottomLeft: '+',
|
|
472
|
+
bottomRight: '+',
|
|
473
|
+
cross: '+',
|
|
474
|
+
teeDown: '+',
|
|
475
|
+
teeUp: '+',
|
|
476
|
+
teeRight: '+',
|
|
477
|
+
teeLeft: '+',
|
|
478
|
+
arrowRight: '>',
|
|
479
|
+
arrowDown: 'v',
|
|
480
|
+
arrowLeft: '<',
|
|
481
|
+
arrowUp: '^',
|
|
482
|
+
bullet: '*',
|
|
483
|
+
diamond: '*',
|
|
484
|
+
square: '#'
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
constructor(theme: VisualizerTheme, options?: ASCIIRendererOptions) {
|
|
489
|
+
this.theme = theme;
|
|
490
|
+
this.options = {
|
|
491
|
+
width: 120,
|
|
492
|
+
height: 40,
|
|
493
|
+
nodeWidth: 20,
|
|
494
|
+
nodeHeight: 5,
|
|
495
|
+
spacing: 4,
|
|
496
|
+
charset: 'unicode',
|
|
497
|
+
...options
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
render(flow: Flow, state: RenderState): string {
|
|
502
|
+
const canvas = this.createCanvas();
|
|
503
|
+
const c = this.chars[this.options.charset];
|
|
504
|
+
|
|
505
|
+
// Render edges first (behind nodes)
|
|
506
|
+
for (const edge of flow.edges) {
|
|
507
|
+
this.renderEdge(canvas, edge, flow, c);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Render nodes
|
|
511
|
+
for (const node of flow.nodes) {
|
|
512
|
+
const isSelected = state.selection.has(node.id);
|
|
513
|
+
this.renderNode(canvas, node, isSelected, c);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Add title
|
|
517
|
+
this.renderTitle(canvas, flow.id || 'Untitled Flow');
|
|
518
|
+
|
|
519
|
+
// Add legend if in edit mode
|
|
520
|
+
if (state.mode === 'edit') {
|
|
521
|
+
this.renderLegend(canvas, c);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return this.canvasToString(canvas);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private createCanvas(): string[][] {
|
|
528
|
+
return Array(this.options.height)
|
|
529
|
+
.fill(null)
|
|
530
|
+
.map(() => Array(this.options.width).fill(' '));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private renderNode(
|
|
534
|
+
canvas: string[][],
|
|
535
|
+
node: FlowNode,
|
|
536
|
+
isSelected: boolean,
|
|
537
|
+
c: typeof this.chars.unicode
|
|
538
|
+
): void {
|
|
539
|
+
const { x, y } = node.position || { x: 0, y: 0 };
|
|
540
|
+
const w = this.options.nodeWidth;
|
|
541
|
+
const h = this.options.nodeHeight;
|
|
542
|
+
|
|
543
|
+
// Scale positions to canvas
|
|
544
|
+
const cx = Math.floor(x / 10);
|
|
545
|
+
const cy = Math.floor(y / 10);
|
|
546
|
+
|
|
547
|
+
// Draw box
|
|
548
|
+
const borderChar = isSelected ? '═' : c.horizontal;
|
|
549
|
+
const vertChar = isSelected ? '║' : c.vertical;
|
|
550
|
+
|
|
551
|
+
// Top border
|
|
552
|
+
this.drawText(canvas, cx, cy, c.topLeft + borderChar.repeat(w - 2) + c.topRight);
|
|
553
|
+
|
|
554
|
+
// Sides and content
|
|
555
|
+
for (let i = 1; i < h - 1; i++) {
|
|
556
|
+
this.drawText(canvas, cx, cy + i, vertChar);
|
|
557
|
+
this.drawText(canvas, cx + w - 1, cy + i, vertChar);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Bottom border
|
|
561
|
+
this.drawText(canvas, cx, cy + h - 1, c.bottomLeft + borderChar.repeat(w - 2) + c.bottomRight);
|
|
562
|
+
|
|
563
|
+
// Node icon based on type
|
|
564
|
+
const icon = this.getNodeIcon(node.type, c);
|
|
565
|
+
this.drawText(canvas, cx + 2, cy + 1, icon);
|
|
566
|
+
|
|
567
|
+
// Node name (truncated)
|
|
568
|
+
const name = (node.config?.name || node.type).slice(0, w - 6);
|
|
569
|
+
this.drawText(canvas, cx + 4, cy + 1, name);
|
|
570
|
+
|
|
571
|
+
// Node type
|
|
572
|
+
const typeLabel = `[${node.type}]`.slice(0, w - 4);
|
|
573
|
+
this.drawText(canvas, cx + 2, cy + 2, typeLabel);
|
|
574
|
+
|
|
575
|
+
// Status indicator if running
|
|
576
|
+
if (node._status === 'running') {
|
|
577
|
+
this.drawText(canvas, cx + w - 3, cy + 1, '⟳');
|
|
578
|
+
} else if (node._status === 'completed') {
|
|
579
|
+
this.drawText(canvas, cx + w - 3, cy + 1, '✓');
|
|
580
|
+
} else if (node._status === 'error') {
|
|
581
|
+
this.drawText(canvas, cx + w - 3, cy + 1, '✗');
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private renderEdge(
|
|
586
|
+
canvas: string[][],
|
|
587
|
+
edge: FlowEdge,
|
|
588
|
+
flow: Flow,
|
|
589
|
+
c: typeof this.chars.unicode
|
|
590
|
+
): void {
|
|
591
|
+
const sourceNode = flow.nodes.find(n => n.id === edge.source);
|
|
592
|
+
const targetNode = flow.nodes.find(n => n.id === edge.target);
|
|
593
|
+
|
|
594
|
+
if (!sourceNode?.position || !targetNode?.position) return;
|
|
595
|
+
|
|
596
|
+
const w = this.options.nodeWidth;
|
|
597
|
+
const h = this.options.nodeHeight;
|
|
598
|
+
|
|
599
|
+
// Calculate connection points
|
|
600
|
+
const sx = Math.floor(sourceNode.position.x / 10) + w;
|
|
601
|
+
const sy = Math.floor(sourceNode.position.y / 10) + Math.floor(h / 2);
|
|
602
|
+
const tx = Math.floor(targetNode.position.x / 10);
|
|
603
|
+
const ty = Math.floor(targetNode.position.y / 10) + Math.floor(h / 2);
|
|
604
|
+
|
|
605
|
+
// Draw edge (simple right-angle routing)
|
|
606
|
+
const midX = Math.floor((sx + tx) / 2);
|
|
607
|
+
|
|
608
|
+
// Horizontal from source
|
|
609
|
+
for (let x = sx; x <= midX; x++) {
|
|
610
|
+
this.drawChar(canvas, x, sy, c.horizontal);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Vertical segment
|
|
614
|
+
const [minY, maxY] = sy < ty ? [sy, ty] : [ty, sy];
|
|
615
|
+
for (let y = minY; y <= maxY; y++) {
|
|
616
|
+
this.drawChar(canvas, midX, y, c.vertical);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Horizontal to target
|
|
620
|
+
for (let x = midX; x < tx; x++) {
|
|
621
|
+
this.drawChar(canvas, x, ty, c.horizontal);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Arrow at target
|
|
625
|
+
this.drawChar(canvas, tx - 1, ty, c.arrowRight);
|
|
626
|
+
|
|
627
|
+
// Corner characters
|
|
628
|
+
if (sy !== ty) {
|
|
629
|
+
if (sy < ty) {
|
|
630
|
+
this.drawChar(canvas, midX, sy, c.topLeft);
|
|
631
|
+
this.drawChar(canvas, midX, ty, c.bottomRight);
|
|
632
|
+
} else {
|
|
633
|
+
this.drawChar(canvas, midX, sy, c.bottomLeft);
|
|
634
|
+
this.drawChar(canvas, midX, ty, c.topRight);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Edge label
|
|
639
|
+
if (edge.label) {
|
|
640
|
+
const labelX = midX - Math.floor(edge.label.length / 2);
|
|
641
|
+
const labelY = Math.floor((sy + ty) / 2);
|
|
642
|
+
this.drawText(canvas, labelX, labelY, `[${edge.label}]`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private getNodeIcon(type: NodeType, c: typeof this.chars.unicode): string {
|
|
647
|
+
const icons: Record<NodeType, string> = {
|
|
648
|
+
entry: '▶',
|
|
649
|
+
exit: '■',
|
|
650
|
+
agent: '◉',
|
|
651
|
+
parallel: '⫴',
|
|
652
|
+
sequence: '→',
|
|
653
|
+
condition: '◇',
|
|
654
|
+
router: '⋔',
|
|
655
|
+
loop: '↻',
|
|
656
|
+
interrupt: '⏸',
|
|
657
|
+
subflow: '⊞'
|
|
658
|
+
};
|
|
659
|
+
return icons[type] || c.bullet;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private renderTitle(canvas: string[][], title: string): void {
|
|
663
|
+
const centeredX = Math.floor((this.options.width - title.length - 4) / 2);
|
|
664
|
+
this.drawText(canvas, centeredX, 0, `╔${'═'.repeat(title.length + 2)}╗`);
|
|
665
|
+
this.drawText(canvas, centeredX, 1, `║ ${title} ║`);
|
|
666
|
+
this.drawText(canvas, centeredX, 2, `╚${'═'.repeat(title.length + 2)}╝`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private renderLegend(canvas: string[][], c: typeof this.chars.unicode): void {
|
|
670
|
+
const y = this.options.height - 3;
|
|
671
|
+
const legend = [
|
|
672
|
+
`${this.getNodeIcon('entry', c)} Entry`,
|
|
673
|
+
`${this.getNodeIcon('agent', c)} Agent`,
|
|
674
|
+
`${this.getNodeIcon('parallel', c)} Parallel`,
|
|
675
|
+
`${this.getNodeIcon('condition', c)} Condition`,
|
|
676
|
+
`${this.getNodeIcon('exit', c)} Exit`
|
|
677
|
+
].join(' │ ');
|
|
678
|
+
|
|
679
|
+
this.drawText(canvas, 2, y, `Legend: ${legend}`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private drawText(canvas: string[][], x: number, y: number, text: string): void {
|
|
683
|
+
if (y < 0 || y >= canvas.length) return;
|
|
684
|
+
for (let i = 0; i < text.length; i++) {
|
|
685
|
+
if (x + i >= 0 && x + i < canvas[0].length) {
|
|
686
|
+
canvas[y][x + i] = text[i];
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private drawChar(canvas: string[][], x: number, y: number, char: string): void {
|
|
692
|
+
if (y >= 0 && y < canvas.length && x >= 0 && x < canvas[0].length) {
|
|
693
|
+
// Don't overwrite nodes
|
|
694
|
+
if (canvas[y][x] === ' ' || canvas[y][x] === '─' || canvas[y][x] === '│') {
|
|
695
|
+
canvas[y][x] = char;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private canvasToString(canvas: string[][]): string {
|
|
701
|
+
return canvas.map(row => row.join('')).join('\n');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
renderToString(flow: Flow): string {
|
|
705
|
+
return this.render(flow, {
|
|
706
|
+
zoom: 1,
|
|
707
|
+
pan: { x: 0, y: 0 },
|
|
708
|
+
selection: new Set(),
|
|
709
|
+
showMinimap: false,
|
|
710
|
+
showToolbar: false,
|
|
711
|
+
mode: 'view'
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
### Visualización de Ejemplo
|
|
718
|
+
|
|
719
|
+
```
|
|
720
|
+
╔════════════════════════════════════════════════════════════════════════════════╗
|
|
721
|
+
║ development_flow ║
|
|
722
|
+
╚════════════════════════════════════════════════════════════════════════════════╝
|
|
723
|
+
|
|
724
|
+
┌──────────────────┐ ┌──────────────────┐
|
|
725
|
+
│ ▶ Start │ │ ⫴ Analyze │
|
|
726
|
+
│ [entry] │───────────────────→│ [parallel] │
|
|
727
|
+
│ │ │ branches: 3 │
|
|
728
|
+
└──────────────────┘ └──────────────────┘
|
|
729
|
+
│
|
|
730
|
+
│
|
|
731
|
+
↓
|
|
732
|
+
┌──────────────────┐ ┌──────────────────┐
|
|
733
|
+
│ ◇ Has Errors? │ │ ◉ Implement │
|
|
734
|
+
│ [condition] │←───────────────────│ [agent] │
|
|
735
|
+
│ │ │ type: executor │
|
|
736
|
+
└──────────────────┘ └──────────────────┘
|
|
737
|
+
│
|
|
738
|
+
┌────┴────┐
|
|
739
|
+
│ yes no │
|
|
740
|
+
↓ ↓
|
|
741
|
+
┌──────────────────┐ ┌──────────────────┐
|
|
742
|
+
│ ↻ Fix Loop │ │ ⫴ Review │
|
|
743
|
+
│ [loop] │ │ [parallel] │
|
|
744
|
+
│ max: 3 │ │ branches: 3 │
|
|
745
|
+
└──────────────────┘ └──────────────────┘
|
|
746
|
+
│
|
|
747
|
+
↓
|
|
748
|
+
┌──────────────────┐
|
|
749
|
+
│ ■ Complete │
|
|
750
|
+
│ [exit] │
|
|
751
|
+
│ │
|
|
752
|
+
└──────────────────┘
|
|
753
|
+
|
|
754
|
+
Legend: ▶ Entry │ ◉ Agent │ ⫴ Parallel │ ◇ Condition │ ■ Exit
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
---
|
|
758
|
+
|
|
759
|
+
## 3. NodeEditor
|
|
760
|
+
|
|
761
|
+
### Propósito
|
|
762
|
+
Editor visual para configurar nodos individuales.
|
|
763
|
+
|
|
764
|
+
### Interfaz
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
interface NodeEditorOptions {
|
|
768
|
+
node: FlowNode;
|
|
769
|
+
availableAgents: AgentDefinition[];
|
|
770
|
+
availableFlows: Flow[];
|
|
771
|
+
customValidators?: Record<string, NodeValidator>;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
interface NodeEditor {
|
|
775
|
+
// Core
|
|
776
|
+
render(): string;
|
|
777
|
+
getValue(): NodeConfig;
|
|
778
|
+
setValue(config: NodeConfig): void;
|
|
779
|
+
|
|
780
|
+
// Validation
|
|
781
|
+
validate(): ValidationResult;
|
|
782
|
+
getErrors(): string[];
|
|
783
|
+
|
|
784
|
+
// Templates
|
|
785
|
+
applyTemplate(templateId: string): void;
|
|
786
|
+
getTemplates(nodeType: NodeType): NodeTemplate[];
|
|
787
|
+
|
|
788
|
+
// Events
|
|
789
|
+
onChange(callback: (config: NodeConfig) => void): () => void;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
interface NodeTemplate {
|
|
793
|
+
id: string;
|
|
794
|
+
name: string;
|
|
795
|
+
description: string;
|
|
796
|
+
nodeType: NodeType;
|
|
797
|
+
config: Partial<NodeConfig>;
|
|
798
|
+
}
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
### Implementación
|
|
802
|
+
|
|
803
|
+
```typescript
|
|
804
|
+
class NodeEditorImpl implements NodeEditor {
|
|
805
|
+
private node: FlowNode;
|
|
806
|
+
private config: NodeConfig;
|
|
807
|
+
private options: NodeEditorOptions;
|
|
808
|
+
private changeCallbacks: Set<Function> = new Set();
|
|
809
|
+
|
|
810
|
+
constructor(options: NodeEditorOptions) {
|
|
811
|
+
this.options = options;
|
|
812
|
+
this.node = options.node;
|
|
813
|
+
this.config = { ...options.node.config };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
render(): string {
|
|
817
|
+
const lines: string[] = [];
|
|
818
|
+
const c = {
|
|
819
|
+
h: '─', v: '│', tl: '┌', tr: '┐', bl: '└', br: '┘'
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// Header
|
|
823
|
+
lines.push(`${c.tl}${'─'.repeat(50)}${c.tr}`);
|
|
824
|
+
lines.push(`${c.v} Node Editor: ${this.node.type.padEnd(33)}${c.v}`);
|
|
825
|
+
lines.push(`${c.v}${'─'.repeat(50)}${c.v}`);
|
|
826
|
+
|
|
827
|
+
// Fields based on node type
|
|
828
|
+
const fields = this.getFieldsForType(this.node.type);
|
|
829
|
+
|
|
830
|
+
for (const field of fields) {
|
|
831
|
+
const value = this.config[field.key] ?? field.default ?? '';
|
|
832
|
+
const displayValue = this.formatValue(value, field.type);
|
|
833
|
+
const label = field.label.padEnd(15);
|
|
834
|
+
|
|
835
|
+
if (field.type === 'select') {
|
|
836
|
+
lines.push(`${c.v} ${label}: [${displayValue}] ▼`.padEnd(51) + c.v);
|
|
837
|
+
} else if (field.type === 'boolean') {
|
|
838
|
+
const checkbox = value ? '[✓]' : '[ ]';
|
|
839
|
+
lines.push(`${c.v} ${label}: ${checkbox}`.padEnd(51) + c.v);
|
|
840
|
+
} else if (field.type === 'array') {
|
|
841
|
+
lines.push(`${c.v} ${label}:`.padEnd(51) + c.v);
|
|
842
|
+
const items = Array.isArray(value) ? value : [];
|
|
843
|
+
for (const item of items.slice(0, 3)) {
|
|
844
|
+
lines.push(`${c.v} • ${String(item).slice(0, 40)}`.padEnd(51) + c.v);
|
|
845
|
+
}
|
|
846
|
+
if (items.length > 3) {
|
|
847
|
+
lines.push(`${c.v} ... and ${items.length - 3} more`.padEnd(51) + c.v);
|
|
848
|
+
}
|
|
849
|
+
} else {
|
|
850
|
+
lines.push(`${c.v} ${label}: ${displayValue.slice(0, 30)}`.padEnd(51) + c.v);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Validation status
|
|
855
|
+
const validation = this.validate();
|
|
856
|
+
lines.push(`${c.v}${'─'.repeat(50)}${c.v}`);
|
|
857
|
+
if (validation.valid) {
|
|
858
|
+
lines.push(`${c.v} Status: ✓ Valid`.padEnd(51) + c.v);
|
|
859
|
+
} else {
|
|
860
|
+
lines.push(`${c.v} Status: ✗ ${validation.errors.length} error(s)`.padEnd(51) + c.v);
|
|
861
|
+
for (const error of validation.errors.slice(0, 2)) {
|
|
862
|
+
lines.push(`${c.v} • ${error.slice(0, 42)}`.padEnd(51) + c.v);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Footer
|
|
867
|
+
lines.push(`${c.bl}${'─'.repeat(50)}${c.br}`);
|
|
868
|
+
|
|
869
|
+
return lines.join('\n');
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private getFieldsForType(type: NodeType): FieldDefinition[] {
|
|
873
|
+
const commonFields: FieldDefinition[] = [
|
|
874
|
+
{ key: 'name', label: 'Name', type: 'string', required: true }
|
|
875
|
+
];
|
|
876
|
+
|
|
877
|
+
const typeSpecificFields: Record<NodeType, FieldDefinition[]> = {
|
|
878
|
+
entry: [],
|
|
879
|
+
exit: [
|
|
880
|
+
{ key: 'outputMapping', label: 'Output Map', type: 'object' }
|
|
881
|
+
],
|
|
882
|
+
agent: [
|
|
883
|
+
{ key: 'agentType', label: 'Agent Type', type: 'select', options: this.getAgentOptions() },
|
|
884
|
+
{ key: 'model', label: 'Model', type: 'select', options: ['haiku', 'sonnet', 'opus'] },
|
|
885
|
+
{ key: 'prompt', label: 'Prompt', type: 'text' },
|
|
886
|
+
{ key: 'timeout', label: 'Timeout (ms)', type: 'number', default: 30000 }
|
|
887
|
+
],
|
|
888
|
+
parallel: [
|
|
889
|
+
{ key: 'branches', label: 'Branches', type: 'array' },
|
|
890
|
+
{ key: 'joinType', label: 'Join Type', type: 'select', options: ['all', 'any', 'quorum', 'first_success'] },
|
|
891
|
+
{ key: 'quorumSize', label: 'Quorum Size', type: 'number', default: 2 }
|
|
892
|
+
],
|
|
893
|
+
sequence: [
|
|
894
|
+
{ key: 'steps', label: 'Steps', type: 'array' },
|
|
895
|
+
{ key: 'stopOnError', label: 'Stop on Error', type: 'boolean', default: true }
|
|
896
|
+
],
|
|
897
|
+
condition: [
|
|
898
|
+
{ key: 'expression', label: 'Expression', type: 'string', required: true },
|
|
899
|
+
{ key: 'trueBranch', label: 'True Branch', type: 'string' },
|
|
900
|
+
{ key: 'falseBranch', label: 'False Branch', type: 'string' }
|
|
901
|
+
],
|
|
902
|
+
router: [
|
|
903
|
+
{ key: 'routes', label: 'Routes', type: 'array' },
|
|
904
|
+
{ key: 'defaultRoute', label: 'Default Route', type: 'string' }
|
|
905
|
+
],
|
|
906
|
+
loop: [
|
|
907
|
+
{ key: 'maxIterations', label: 'Max Iterations', type: 'number', default: 10 },
|
|
908
|
+
{ key: 'condition', label: 'Condition', type: 'string' },
|
|
909
|
+
{ key: 'body', label: 'Loop Body', type: 'string' }
|
|
910
|
+
],
|
|
911
|
+
interrupt: [
|
|
912
|
+
{ key: 'type', label: 'Type', type: 'select', options: ['approval', 'decision', 'input', 'confirmation'] },
|
|
913
|
+
{ key: 'message', label: 'Message', type: 'text' },
|
|
914
|
+
{ key: 'timeout', label: 'Timeout (ms)', type: 'number' },
|
|
915
|
+
{ key: 'defaultAction', label: 'Default Action', type: 'string' }
|
|
916
|
+
],
|
|
917
|
+
subflow: [
|
|
918
|
+
{ key: 'flowId', label: 'Flow ID', type: 'select', options: this.getFlowOptions() },
|
|
919
|
+
{ key: 'inputMapping', label: 'Input Map', type: 'object' },
|
|
920
|
+
{ key: 'outputMapping', label: 'Output Map', type: 'object' }
|
|
921
|
+
]
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
return [...commonFields, ...(typeSpecificFields[type] || [])];
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private getAgentOptions(): string[] {
|
|
928
|
+
return this.options.availableAgents.map(a => a.name);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private getFlowOptions(): string[] {
|
|
932
|
+
return this.options.availableFlows.map(f => f.id);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
validate(): ValidationResult {
|
|
936
|
+
const errors: string[] = [];
|
|
937
|
+
const fields = this.getFieldsForType(this.node.type);
|
|
938
|
+
|
|
939
|
+
for (const field of fields) {
|
|
940
|
+
const value = this.config[field.key];
|
|
941
|
+
|
|
942
|
+
// Required check
|
|
943
|
+
if (field.required && (value === undefined || value === null || value === '')) {
|
|
944
|
+
errors.push(`${field.label} is required`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Type validation
|
|
948
|
+
if (value !== undefined && value !== null) {
|
|
949
|
+
switch (field.type) {
|
|
950
|
+
case 'number':
|
|
951
|
+
if (typeof value !== 'number' || isNaN(value)) {
|
|
952
|
+
errors.push(`${field.label} must be a number`);
|
|
953
|
+
}
|
|
954
|
+
break;
|
|
955
|
+
case 'boolean':
|
|
956
|
+
if (typeof value !== 'boolean') {
|
|
957
|
+
errors.push(`${field.label} must be a boolean`);
|
|
958
|
+
}
|
|
959
|
+
break;
|
|
960
|
+
case 'array':
|
|
961
|
+
if (!Array.isArray(value)) {
|
|
962
|
+
errors.push(`${field.label} must be an array`);
|
|
963
|
+
}
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Custom validators
|
|
970
|
+
if (this.options.customValidators?.[this.node.type]) {
|
|
971
|
+
const customErrors = this.options.customValidators[this.node.type](this.config);
|
|
972
|
+
errors.push(...customErrors);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return { valid: errors.length === 0, errors };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
getValue(): NodeConfig {
|
|
979
|
+
return { ...this.config };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
setValue(config: NodeConfig): void {
|
|
983
|
+
this.config = { ...config };
|
|
984
|
+
this.notifyChange();
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
getTemplates(nodeType: NodeType): NodeTemplate[] {
|
|
988
|
+
const templates: Record<NodeType, NodeTemplate[]> = {
|
|
989
|
+
agent: [
|
|
990
|
+
{
|
|
991
|
+
id: 'explore-agent',
|
|
992
|
+
name: 'Exploration Agent',
|
|
993
|
+
description: 'Fast agent for codebase exploration',
|
|
994
|
+
nodeType: 'agent',
|
|
995
|
+
config: { agentType: 'Explore', model: 'haiku', timeout: 30000 }
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
id: 'implement-agent',
|
|
999
|
+
name: 'Implementation Agent',
|
|
1000
|
+
description: 'High-quality agent for code implementation',
|
|
1001
|
+
nodeType: 'agent',
|
|
1002
|
+
config: { agentType: 'elsabro-executor', model: 'opus', timeout: 120000 }
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
id: 'review-agent',
|
|
1006
|
+
name: 'Code Review Agent',
|
|
1007
|
+
description: 'Agent for thorough code review',
|
|
1008
|
+
nodeType: 'agent',
|
|
1009
|
+
config: { agentType: 'pr-review-toolkit:code-reviewer', model: 'opus', timeout: 60000 }
|
|
1010
|
+
}
|
|
1011
|
+
],
|
|
1012
|
+
parallel: [
|
|
1013
|
+
{
|
|
1014
|
+
id: 'explore-parallel',
|
|
1015
|
+
name: 'Exploration Wave',
|
|
1016
|
+
description: '4 parallel exploration agents',
|
|
1017
|
+
nodeType: 'parallel',
|
|
1018
|
+
config: {
|
|
1019
|
+
branches: [
|
|
1020
|
+
{ agent: 'Explore' },
|
|
1021
|
+
{ agent: 'Plan' },
|
|
1022
|
+
{ agent: 'feature-dev:code-explorer' },
|
|
1023
|
+
{ agent: 'general-purpose' }
|
|
1024
|
+
],
|
|
1025
|
+
joinType: 'all'
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
id: 'review-parallel',
|
|
1030
|
+
name: 'Review Wave',
|
|
1031
|
+
description: '3 parallel review agents',
|
|
1032
|
+
nodeType: 'parallel',
|
|
1033
|
+
config: {
|
|
1034
|
+
branches: [
|
|
1035
|
+
{ agent: 'pr-review-toolkit:code-reviewer' },
|
|
1036
|
+
{ agent: 'pr-review-toolkit:silent-failure-hunter' },
|
|
1037
|
+
{ agent: 'elsabro-verifier' }
|
|
1038
|
+
],
|
|
1039
|
+
joinType: 'all'
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
],
|
|
1043
|
+
// ... other node types
|
|
1044
|
+
} as Record<NodeType, NodeTemplate[]>;
|
|
1045
|
+
|
|
1046
|
+
return templates[nodeType] || [];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
applyTemplate(templateId: string): void {
|
|
1050
|
+
const templates = this.getTemplates(this.node.type);
|
|
1051
|
+
const template = templates.find(t => t.id === templateId);
|
|
1052
|
+
|
|
1053
|
+
if (template) {
|
|
1054
|
+
this.config = { ...this.config, ...template.config };
|
|
1055
|
+
this.notifyChange();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
onChange(callback: Function): () => void {
|
|
1060
|
+
this.changeCallbacks.add(callback);
|
|
1061
|
+
return () => this.changeCallbacks.delete(callback);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
private notifyChange(): void {
|
|
1065
|
+
this.changeCallbacks.forEach(cb => cb(this.config));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
private formatValue(value: unknown, type: string): string {
|
|
1069
|
+
if (value === undefined || value === null) return '';
|
|
1070
|
+
if (type === 'array') return `${(value as unknown[]).length} items`;
|
|
1071
|
+
if (type === 'object') return '{...}';
|
|
1072
|
+
return String(value);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
interface FieldDefinition {
|
|
1077
|
+
key: string;
|
|
1078
|
+
label: string;
|
|
1079
|
+
type: 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'object';
|
|
1080
|
+
required?: boolean;
|
|
1081
|
+
default?: unknown;
|
|
1082
|
+
options?: string[];
|
|
1083
|
+
}
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
---
|
|
1087
|
+
|
|
1088
|
+
## 4. ConnectionManager
|
|
1089
|
+
|
|
1090
|
+
### Propósito
|
|
1091
|
+
Gestiona las conexiones entre nodos con validación y auto-routing.
|
|
1092
|
+
|
|
1093
|
+
### Interfaz
|
|
1094
|
+
|
|
1095
|
+
```typescript
|
|
1096
|
+
interface ConnectionManager {
|
|
1097
|
+
// Validation
|
|
1098
|
+
canConnect(sourceId: string, targetId: string): boolean;
|
|
1099
|
+
validateConnection(edge: FlowEdge): ValidationResult;
|
|
1100
|
+
validateAllConnections(flow: Flow): ValidationResult[];
|
|
1101
|
+
|
|
1102
|
+
// Operations
|
|
1103
|
+
suggestConnections(nodeId: string, flow: Flow): SuggestedConnection[];
|
|
1104
|
+
autoConnect(flow: Flow): FlowEdge[];
|
|
1105
|
+
optimizeRouting(flow: Flow): Flow;
|
|
1106
|
+
|
|
1107
|
+
// Analysis
|
|
1108
|
+
findCycles(flow: Flow): string[][];
|
|
1109
|
+
getUnconnectedNodes(flow: Flow): string[];
|
|
1110
|
+
getDeadEnds(flow: Flow): string[];
|
|
1111
|
+
getOrphanedNodes(flow: Flow): string[];
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
interface SuggestedConnection {
|
|
1115
|
+
sourceId: string;
|
|
1116
|
+
targetId: string;
|
|
1117
|
+
confidence: number;
|
|
1118
|
+
reason: string;
|
|
1119
|
+
}
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
### Implementación
|
|
1123
|
+
|
|
1124
|
+
```typescript
|
|
1125
|
+
class ConnectionManagerImpl implements ConnectionManager {
|
|
1126
|
+
private nodeRules: Map<NodeType, ConnectionRules> = new Map();
|
|
1127
|
+
|
|
1128
|
+
constructor() {
|
|
1129
|
+
this.initializeRules();
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private initializeRules(): void {
|
|
1133
|
+
// Define valid connections for each node type
|
|
1134
|
+
this.nodeRules.set('entry', {
|
|
1135
|
+
maxOutgoing: 1,
|
|
1136
|
+
maxIncoming: 0,
|
|
1137
|
+
allowedTargets: ['agent', 'parallel', 'sequence', 'condition', 'router', 'subflow']
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
this.nodeRules.set('exit', {
|
|
1141
|
+
maxOutgoing: 0,
|
|
1142
|
+
maxIncoming: Infinity,
|
|
1143
|
+
allowedSources: ['agent', 'parallel', 'sequence', 'condition', 'router', 'loop', 'subflow']
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
this.nodeRules.set('agent', {
|
|
1147
|
+
maxOutgoing: 1,
|
|
1148
|
+
maxIncoming: Infinity,
|
|
1149
|
+
allowedTargets: ['agent', 'parallel', 'sequence', 'condition', 'router', 'exit', 'loop', 'interrupt', 'subflow'],
|
|
1150
|
+
allowedSources: ['entry', 'agent', 'parallel', 'sequence', 'condition', 'router', 'loop', 'subflow']
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
this.nodeRules.set('parallel', {
|
|
1154
|
+
maxOutgoing: 1,
|
|
1155
|
+
maxIncoming: Infinity,
|
|
1156
|
+
allowedTargets: ['agent', 'condition', 'router', 'exit', 'parallel', 'sequence'],
|
|
1157
|
+
allowedSources: ['entry', 'agent', 'condition', 'router', 'loop', 'sequence']
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
this.nodeRules.set('condition', {
|
|
1161
|
+
maxOutgoing: 2, // true and false branches
|
|
1162
|
+
maxIncoming: Infinity,
|
|
1163
|
+
allowedTargets: ['agent', 'parallel', 'sequence', 'exit', 'loop', 'subflow'],
|
|
1164
|
+
allowedSources: ['agent', 'parallel', 'sequence', 'loop', 'entry']
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
this.nodeRules.set('loop', {
|
|
1168
|
+
maxOutgoing: 2, // continue and exit
|
|
1169
|
+
maxIncoming: Infinity,
|
|
1170
|
+
allowedTargets: ['agent', 'parallel', 'condition', 'exit'],
|
|
1171
|
+
allowedSources: ['agent', 'parallel', 'condition']
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
canConnect(sourceId: string, targetId: string, flow: Flow): boolean {
|
|
1176
|
+
const result = this.validateConnectionAttempt(sourceId, targetId, flow);
|
|
1177
|
+
return result.valid;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private validateConnectionAttempt(
|
|
1181
|
+
sourceId: string,
|
|
1182
|
+
targetId: string,
|
|
1183
|
+
flow: Flow
|
|
1184
|
+
): ValidationResult {
|
|
1185
|
+
const errors: string[] = [];
|
|
1186
|
+
|
|
1187
|
+
const sourceNode = flow.nodes.find(n => n.id === sourceId);
|
|
1188
|
+
const targetNode = flow.nodes.find(n => n.id === targetId);
|
|
1189
|
+
|
|
1190
|
+
if (!sourceNode) {
|
|
1191
|
+
errors.push(`Source node not found: ${sourceId}`);
|
|
1192
|
+
return { valid: false, errors };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (!targetNode) {
|
|
1196
|
+
errors.push(`Target node not found: ${targetId}`);
|
|
1197
|
+
return { valid: false, errors };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Self-connection
|
|
1201
|
+
if (sourceId === targetId) {
|
|
1202
|
+
errors.push('Cannot connect node to itself');
|
|
1203
|
+
return { valid: false, errors };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Get rules
|
|
1207
|
+
const sourceRules = this.nodeRules.get(sourceNode.type);
|
|
1208
|
+
const targetRules = this.nodeRules.get(targetNode.type);
|
|
1209
|
+
|
|
1210
|
+
// Check outgoing limit
|
|
1211
|
+
if (sourceRules) {
|
|
1212
|
+
const outgoingCount = flow.edges.filter(e => e.source === sourceId).length;
|
|
1213
|
+
if (outgoingCount >= sourceRules.maxOutgoing) {
|
|
1214
|
+
errors.push(`${sourceNode.type} node has reached maximum outgoing connections`);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Check allowed targets
|
|
1218
|
+
if (sourceRules.allowedTargets && !sourceRules.allowedTargets.includes(targetNode.type)) {
|
|
1219
|
+
errors.push(`${sourceNode.type} cannot connect to ${targetNode.type}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Check incoming limit
|
|
1224
|
+
if (targetRules) {
|
|
1225
|
+
const incomingCount = flow.edges.filter(e => e.target === targetId).length;
|
|
1226
|
+
if (incomingCount >= targetRules.maxIncoming) {
|
|
1227
|
+
errors.push(`${targetNode.type} node has reached maximum incoming connections`);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Check allowed sources
|
|
1231
|
+
if (targetRules.allowedSources && !targetRules.allowedSources.includes(sourceNode.type)) {
|
|
1232
|
+
errors.push(`${targetNode.type} cannot receive connection from ${sourceNode.type}`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Check for duplicate edge
|
|
1237
|
+
const exists = flow.edges.some(
|
|
1238
|
+
e => e.source === sourceId && e.target === targetId
|
|
1239
|
+
);
|
|
1240
|
+
if (exists) {
|
|
1241
|
+
errors.push('Connection already exists');
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Check for cycles
|
|
1245
|
+
if (this.wouldCreateCycle(sourceId, targetId, flow)) {
|
|
1246
|
+
errors.push('Connection would create a cycle');
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
return { valid: errors.length === 0, errors };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
private wouldCreateCycle(sourceId: string, targetId: string, flow: Flow): boolean {
|
|
1253
|
+
// Check if there's already a path from target to source
|
|
1254
|
+
const visited = new Set<string>();
|
|
1255
|
+
const stack = [targetId];
|
|
1256
|
+
|
|
1257
|
+
while (stack.length > 0) {
|
|
1258
|
+
const current = stack.pop()!;
|
|
1259
|
+
if (current === sourceId) return true;
|
|
1260
|
+
if (visited.has(current)) continue;
|
|
1261
|
+
visited.add(current);
|
|
1262
|
+
|
|
1263
|
+
const outgoing = flow.edges
|
|
1264
|
+
.filter(e => e.source === current)
|
|
1265
|
+
.map(e => e.target);
|
|
1266
|
+
stack.push(...outgoing);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
suggestConnections(nodeId: string, flow: Flow): SuggestedConnection[] {
|
|
1273
|
+
const node = flow.nodes.find(n => n.id === nodeId);
|
|
1274
|
+
if (!node) return [];
|
|
1275
|
+
|
|
1276
|
+
const suggestions: SuggestedConnection[] = [];
|
|
1277
|
+
const rules = this.nodeRules.get(node.type);
|
|
1278
|
+
|
|
1279
|
+
// Current connections
|
|
1280
|
+
const outgoing = new Set(flow.edges.filter(e => e.source === nodeId).map(e => e.target));
|
|
1281
|
+
const incoming = new Set(flow.edges.filter(e => e.target === nodeId).map(e => e.source));
|
|
1282
|
+
|
|
1283
|
+
// Suggest outgoing connections
|
|
1284
|
+
if (rules && outgoing.size < rules.maxOutgoing) {
|
|
1285
|
+
for (const targetNode of flow.nodes) {
|
|
1286
|
+
if (targetNode.id === nodeId) continue;
|
|
1287
|
+
if (outgoing.has(targetNode.id)) continue;
|
|
1288
|
+
|
|
1289
|
+
if (this.canConnect(nodeId, targetNode.id, flow)) {
|
|
1290
|
+
suggestions.push({
|
|
1291
|
+
sourceId: nodeId,
|
|
1292
|
+
targetId: targetNode.id,
|
|
1293
|
+
confidence: this.calculateConfidence(node, targetNode, flow),
|
|
1294
|
+
reason: this.getConnectionReason(node, targetNode)
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Sort by confidence
|
|
1301
|
+
return suggestions.sort((a, b) => b.confidence - a.confidence);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private calculateConfidence(source: FlowNode, target: FlowNode, flow: Flow): number {
|
|
1305
|
+
let confidence = 0.5;
|
|
1306
|
+
|
|
1307
|
+
// Higher confidence for natural flow (entry -> agent -> exit)
|
|
1308
|
+
const naturalOrder: NodeType[] = ['entry', 'agent', 'parallel', 'condition', 'agent', 'exit'];
|
|
1309
|
+
const sourceIndex = naturalOrder.indexOf(source.type);
|
|
1310
|
+
const targetIndex = naturalOrder.indexOf(target.type);
|
|
1311
|
+
if (sourceIndex >= 0 && targetIndex > sourceIndex) {
|
|
1312
|
+
confidence += 0.3;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Lower confidence if target already has incoming connections
|
|
1316
|
+
const targetIncoming = flow.edges.filter(e => e.target === target.id).length;
|
|
1317
|
+
if (targetIncoming > 0) {
|
|
1318
|
+
confidence -= 0.1 * targetIncoming;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
return Math.max(0, Math.min(1, confidence));
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
private getConnectionReason(source: FlowNode, target: FlowNode): string {
|
|
1325
|
+
if (source.type === 'entry') return 'Start flow execution';
|
|
1326
|
+
if (target.type === 'exit') return 'Complete flow execution';
|
|
1327
|
+
if (source.type === 'condition') return 'Branch based on condition';
|
|
1328
|
+
if (target.type === 'parallel') return 'Execute in parallel';
|
|
1329
|
+
return 'Continue flow execution';
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
findCycles(flow: Flow): string[][] {
|
|
1333
|
+
const cycles: string[][] = [];
|
|
1334
|
+
const visited = new Set<string>();
|
|
1335
|
+
const recStack = new Set<string>();
|
|
1336
|
+
const path: string[] = [];
|
|
1337
|
+
|
|
1338
|
+
const dfs = (nodeId: string): void => {
|
|
1339
|
+
visited.add(nodeId);
|
|
1340
|
+
recStack.add(nodeId);
|
|
1341
|
+
path.push(nodeId);
|
|
1342
|
+
|
|
1343
|
+
const outgoing = flow.edges
|
|
1344
|
+
.filter(e => e.source === nodeId)
|
|
1345
|
+
.map(e => e.target);
|
|
1346
|
+
|
|
1347
|
+
for (const neighbor of outgoing) {
|
|
1348
|
+
if (!visited.has(neighbor)) {
|
|
1349
|
+
dfs(neighbor);
|
|
1350
|
+
} else if (recStack.has(neighbor)) {
|
|
1351
|
+
// Found cycle
|
|
1352
|
+
const cycleStart = path.indexOf(neighbor);
|
|
1353
|
+
cycles.push(path.slice(cycleStart));
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
path.pop();
|
|
1358
|
+
recStack.delete(nodeId);
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
for (const node of flow.nodes) {
|
|
1362
|
+
if (!visited.has(node.id)) {
|
|
1363
|
+
dfs(node.id);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return cycles;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
getUnconnectedNodes(flow: Flow): string[] {
|
|
1371
|
+
const connectedNodes = new Set<string>();
|
|
1372
|
+
|
|
1373
|
+
for (const edge of flow.edges) {
|
|
1374
|
+
connectedNodes.add(edge.source);
|
|
1375
|
+
connectedNodes.add(edge.target);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
return flow.nodes
|
|
1379
|
+
.filter(n => !connectedNodes.has(n.id))
|
|
1380
|
+
.map(n => n.id);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
getDeadEnds(flow: Flow): string[] {
|
|
1384
|
+
// Nodes with incoming but no outgoing (except exit nodes)
|
|
1385
|
+
return flow.nodes
|
|
1386
|
+
.filter(node => {
|
|
1387
|
+
if (node.type === 'exit') return false;
|
|
1388
|
+
const hasIncoming = flow.edges.some(e => e.target === node.id);
|
|
1389
|
+
const hasOutgoing = flow.edges.some(e => e.source === node.id);
|
|
1390
|
+
return hasIncoming && !hasOutgoing;
|
|
1391
|
+
})
|
|
1392
|
+
.map(n => n.id);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
getOrphanedNodes(flow: Flow): string[] {
|
|
1396
|
+
// Nodes with no incoming connections (except entry nodes)
|
|
1397
|
+
return flow.nodes
|
|
1398
|
+
.filter(node => {
|
|
1399
|
+
if (node.type === 'entry') return false;
|
|
1400
|
+
return !flow.edges.some(e => e.target === node.id);
|
|
1401
|
+
})
|
|
1402
|
+
.map(n => n.id);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
interface ConnectionRules {
|
|
1407
|
+
maxOutgoing: number;
|
|
1408
|
+
maxIncoming: number;
|
|
1409
|
+
allowedTargets?: NodeType[];
|
|
1410
|
+
allowedSources?: NodeType[];
|
|
1411
|
+
}
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
---
|
|
1415
|
+
|
|
1416
|
+
## 5. Comandos CLI
|
|
1417
|
+
|
|
1418
|
+
### /elsabro:flow visualize
|
|
1419
|
+
|
|
1420
|
+
```bash
|
|
1421
|
+
# Ver flow actual
|
|
1422
|
+
/elsabro:flow visualize
|
|
1423
|
+
|
|
1424
|
+
# Ver flow específico
|
|
1425
|
+
/elsabro:flow visualize development_flow
|
|
1426
|
+
|
|
1427
|
+
# Exportar a SVG
|
|
1428
|
+
/elsabro:flow visualize --export svg --output flow.svg
|
|
1429
|
+
|
|
1430
|
+
# Modo editor interactivo
|
|
1431
|
+
/elsabro:flow edit development_flow
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
### Output de Visualización
|
|
1435
|
+
|
|
1436
|
+
```
|
|
1437
|
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
1438
|
+
║ Flow: development_flow ║
|
|
1439
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
1440
|
+
║ Status: Active │ Nodes: 8 │ Edges: 9 │ Cycles: 0 ║
|
|
1441
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
1442
|
+
|
|
1443
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
1444
|
+
│ ▶ start │─────────→│ ⫴ explore │─────────→│ ◉ implement │
|
|
1445
|
+
│ [entry] │ │ [parallel] │ │ [agent] │
|
|
1446
|
+
└─────────────┘ │ 4 branches │ │ opus │
|
|
1447
|
+
└─────────────┘ └──────┬──────┘
|
|
1448
|
+
│
|
|
1449
|
+
┌────────────────────────┘
|
|
1450
|
+
↓
|
|
1451
|
+
┌─────────────┐ ┌─────────────┐
|
|
1452
|
+
│ ◇ hasErrors │─── no ──→│ ⫴ review │
|
|
1453
|
+
│ [condition] │ │ [parallel] │
|
|
1454
|
+
└──────┬──────┘ │ 3 branches │
|
|
1455
|
+
│ yes └──────┬──────┘
|
|
1456
|
+
↓ │
|
|
1457
|
+
┌─────────────┐ │
|
|
1458
|
+
│ ↻ fixLoop │ │
|
|
1459
|
+
│ [loop] │←────────────────┘
|
|
1460
|
+
│ max: 3 │ (if errors)
|
|
1461
|
+
└─────────────┘
|
|
1462
|
+
│
|
|
1463
|
+
↓ (success)
|
|
1464
|
+
┌─────────────┐
|
|
1465
|
+
│ ■ complete │
|
|
1466
|
+
│ [exit] │
|
|
1467
|
+
└─────────────┘
|
|
1468
|
+
|
|
1469
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
1470
|
+
║ Legend: ▶Entry ■Exit ◉Agent ⫴Parallel ◇Condition ↻Loop ⊞Subflow ║
|
|
1471
|
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
---
|
|
1475
|
+
|
|
1476
|
+
## 6. Integración con ELSABRO
|
|
1477
|
+
|
|
1478
|
+
### Inicialización
|
|
1479
|
+
|
|
1480
|
+
```typescript
|
|
1481
|
+
// visualization/index.ts
|
|
1482
|
+
import { FlowVisualizer } from './FlowVisualizer';
|
|
1483
|
+
import { ASCIIRenderer, SVGRenderer } from './renderers';
|
|
1484
|
+
import { NodeEditor } from './NodeEditor';
|
|
1485
|
+
import { ConnectionManager } from './ConnectionManager';
|
|
1486
|
+
|
|
1487
|
+
export function createFlowVisualization(flow: Flow, options?: Partial<FlowVisualizerOptions>): FlowVisualizer {
|
|
1488
|
+
return new FlowVisualizer({
|
|
1489
|
+
flow,
|
|
1490
|
+
mode: 'view',
|
|
1491
|
+
renderer: 'ascii',
|
|
1492
|
+
layout: 'dagre',
|
|
1493
|
+
theme: defaultTheme,
|
|
1494
|
+
...options
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
export function visualizeFlowASCII(flow: Flow): string {
|
|
1499
|
+
const visualizer = createFlowVisualization(flow, { renderer: 'ascii' });
|
|
1500
|
+
return visualizer.exportASCII();
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
export function editFlow(flow: Flow): FlowVisualizer {
|
|
1504
|
+
return createFlowVisualization(flow, {
|
|
1505
|
+
mode: 'edit',
|
|
1506
|
+
showToolbar: true,
|
|
1507
|
+
showMinimap: true
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
### Uso en Comandos
|
|
1513
|
+
|
|
1514
|
+
```typescript
|
|
1515
|
+
// commands/flow-visualize.ts
|
|
1516
|
+
export async function handleFlowVisualize(args: string[]): Promise<void> {
|
|
1517
|
+
const flowId = args[0] || getCurrentFlowId();
|
|
1518
|
+
const flow = await loadFlow(flowId);
|
|
1519
|
+
|
|
1520
|
+
if (!flow) {
|
|
1521
|
+
console.error(`Flow not found: ${flowId}`);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const visualizer = createFlowVisualization(flow);
|
|
1526
|
+
const ascii = visualizer.exportASCII();
|
|
1527
|
+
|
|
1528
|
+
console.log(ascii);
|
|
1529
|
+
|
|
1530
|
+
// Show validation
|
|
1531
|
+
const connectionManager = new ConnectionManager();
|
|
1532
|
+
const unconnected = connectionManager.getUnconnectedNodes(flow);
|
|
1533
|
+
const deadEnds = connectionManager.getDeadEnds(flow);
|
|
1534
|
+
const cycles = connectionManager.findCycles(flow);
|
|
1535
|
+
|
|
1536
|
+
if (unconnected.length > 0 || deadEnds.length > 0 || cycles.length > 0) {
|
|
1537
|
+
console.log('\n⚠️ Flow Issues:');
|
|
1538
|
+
if (unconnected.length > 0) {
|
|
1539
|
+
console.log(` • Unconnected nodes: ${unconnected.join(', ')}`);
|
|
1540
|
+
}
|
|
1541
|
+
if (deadEnds.length > 0) {
|
|
1542
|
+
console.log(` • Dead ends: ${deadEnds.join(', ')}`);
|
|
1543
|
+
}
|
|
1544
|
+
if (cycles.length > 0) {
|
|
1545
|
+
console.log(` • Cycles detected: ${cycles.length}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
```
|
|
1550
|
+
|
|
1551
|
+
---
|
|
1552
|
+
|
|
1553
|
+
## Referencias
|
|
1554
|
+
|
|
1555
|
+
- **REF-010**: Flow Orchestration
|
|
1556
|
+
- **REF-016**: Time-Travel Debugging
|
|
1557
|
+
- **REF-028**: Esta referencia (Flow Visualization)
|