agentic-blocks 0.1.17__py3-none-any.whl → 0.1.19__py3-none-any.whl
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.
- agentic_blocks/agent.py +1 -2
- agentic_blocks/llm.py +8 -8
- agentic_blocks/tracing/__init__.py +13 -0
- agentic_blocks/tracing/config.py +111 -0
- agentic_blocks/tracing/core.py +287 -0
- agentic_blocks/tracing/decorator.py +316 -0
- agentic_blocks/visualization/async_flow.py +165 -0
- agentic_blocks/visualization/async_loop_flow.py +73 -0
- agentic_blocks/visualization/visualize.py +1016 -0
- {agentic_blocks-0.1.17.dist-info → agentic_blocks-0.1.19.dist-info}/METADATA +3 -1
- agentic_blocks-0.1.19.dist-info/RECORD +18 -0
- agentic_blocks-0.1.17.dist-info/RECORD +0 -11
- {agentic_blocks-0.1.17.dist-info → agentic_blocks-0.1.19.dist-info}/WHEEL +0 -0
- {agentic_blocks-0.1.17.dist-info → agentic_blocks-0.1.19.dist-info}/licenses/LICENSE +0 -0
- {agentic_blocks-0.1.17.dist-info → agentic_blocks-0.1.19.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1016 @@
|
|
1
|
+
# %%
|
2
|
+
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
import http.server
|
6
|
+
import socketserver
|
7
|
+
import threading
|
8
|
+
import webbrowser
|
9
|
+
import time
|
10
|
+
import socket
|
11
|
+
import importlib
|
12
|
+
import sys
|
13
|
+
from pathlib import Path
|
14
|
+
from typing import Any, Optional, Tuple, Union
|
15
|
+
|
16
|
+
from pocketflow import Flow
|
17
|
+
|
18
|
+
from async_flow import order_pipeline
|
19
|
+
|
20
|
+
|
21
|
+
def build_mermaid(start):
|
22
|
+
ids, visited, lines = {}, set(), ["graph LR"]
|
23
|
+
ctr = 1
|
24
|
+
|
25
|
+
def get_id(n):
|
26
|
+
nonlocal ctr
|
27
|
+
return (
|
28
|
+
ids[n] if n in ids else (ids.setdefault(n, f"N{ctr}"), (ctr := ctr + 1))[0]
|
29
|
+
)
|
30
|
+
|
31
|
+
def link(a, b, action=None):
|
32
|
+
if action:
|
33
|
+
lines.append(f" {a} -->|{action}| {b}")
|
34
|
+
else:
|
35
|
+
lines.append(f" {a} --> {b}")
|
36
|
+
|
37
|
+
def walk(node, parent=None, action=None):
|
38
|
+
if node in visited:
|
39
|
+
return parent and link(parent, get_id(node), action)
|
40
|
+
visited.add(node)
|
41
|
+
if isinstance(node, Flow):
|
42
|
+
node.start_node and parent and link(parent, get_id(node.start_node), action)
|
43
|
+
lines.append(
|
44
|
+
f"\n subgraph sub_flow_{get_id(node)}[{type(node).__name__}]"
|
45
|
+
)
|
46
|
+
node.start_node and walk(node.start_node)
|
47
|
+
for act, nxt in node.successors.items():
|
48
|
+
node.start_node and walk(nxt, get_id(node.start_node), act) or (
|
49
|
+
parent and link(parent, get_id(nxt), action)
|
50
|
+
) or walk(nxt, None, act)
|
51
|
+
lines.append(" end\n")
|
52
|
+
else:
|
53
|
+
lines.append(f" {(nid := get_id(node))}['{type(node).__name__}']")
|
54
|
+
parent and link(parent, nid, action)
|
55
|
+
[walk(nxt, nid, act) for act, nxt in node.successors.items()]
|
56
|
+
|
57
|
+
walk(start)
|
58
|
+
return "\n".join(lines)
|
59
|
+
|
60
|
+
|
61
|
+
def flow_to_json(start):
|
62
|
+
"""Convert a flow to JSON format suitable for D3.js visualization.
|
63
|
+
|
64
|
+
This function walks through the flow graph and builds a structure with:
|
65
|
+
- nodes: All non-Flow nodes with their group memberships
|
66
|
+
- links: Connections between nodes within the same group
|
67
|
+
- group_links: Connections between different groups (for inter-flow connections)
|
68
|
+
- flows: Flow information for group labeling
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
dict: A JSON-serializable dictionary with 'nodes' and 'links' arrays.
|
72
|
+
"""
|
73
|
+
nodes = []
|
74
|
+
links = []
|
75
|
+
group_links = [] # For connections between groups (Flow to Flow)
|
76
|
+
ids = {}
|
77
|
+
node_types = {}
|
78
|
+
flow_nodes = {} # Keep track of flow nodes
|
79
|
+
ctr = 1
|
80
|
+
visited = set()
|
81
|
+
|
82
|
+
def get_id(n):
|
83
|
+
nonlocal ctr
|
84
|
+
if n not in ids:
|
85
|
+
ids[n] = ctr
|
86
|
+
node_types[ctr] = type(n).__name__
|
87
|
+
if isinstance(n, Flow):
|
88
|
+
flow_nodes[ctr] = n # Store flow reference
|
89
|
+
ctr += 1
|
90
|
+
return ids[n]
|
91
|
+
|
92
|
+
def walk(node, parent=None, group=None, parent_group=None, action=None):
|
93
|
+
"""Recursively walk the flow graph to build the visualization data.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
node: Current node being processed
|
97
|
+
parent: ID of the parent node that connects to this node
|
98
|
+
group: Group (Flow) ID this node belongs to
|
99
|
+
parent_group: Group ID of the parent node
|
100
|
+
action: Action label on the edge from parent to this node
|
101
|
+
"""
|
102
|
+
node_id = get_id(node)
|
103
|
+
if (node_id, action) in visited:
|
104
|
+
return
|
105
|
+
visited.add((node_id, action))
|
106
|
+
|
107
|
+
# Add node if not already in nodes list and not a Flow
|
108
|
+
if not any(n["id"] == node_id for n in nodes) and not isinstance(node, Flow):
|
109
|
+
node_data = {
|
110
|
+
"id": node_id,
|
111
|
+
"name": node_types[node_id],
|
112
|
+
"group": group or 0, # Default group
|
113
|
+
}
|
114
|
+
nodes.append(node_data)
|
115
|
+
|
116
|
+
# Add link from parent if exists
|
117
|
+
if parent and not isinstance(node, Flow):
|
118
|
+
links.append(
|
119
|
+
{"source": parent, "target": node_id, "action": action or "default"}
|
120
|
+
)
|
121
|
+
|
122
|
+
# Process different types of nodes
|
123
|
+
if isinstance(node, Flow):
|
124
|
+
# This is a Flow node - it becomes a group container
|
125
|
+
flow_group = node_id # Use flow's ID as group for contained nodes
|
126
|
+
|
127
|
+
# Add a group-to-group link if this flow has a parent group
|
128
|
+
# This creates connections between nested flows
|
129
|
+
if parent_group is not None and parent_group != flow_group:
|
130
|
+
# Check if this link already exists
|
131
|
+
if not any(
|
132
|
+
l["source"] == parent_group and l["target"] == flow_group
|
133
|
+
for l in group_links
|
134
|
+
):
|
135
|
+
group_links.append(
|
136
|
+
{
|
137
|
+
"source": parent_group,
|
138
|
+
"target": flow_group,
|
139
|
+
"action": action or "default",
|
140
|
+
}
|
141
|
+
)
|
142
|
+
|
143
|
+
if node.start_node:
|
144
|
+
# Process the start node of this flow
|
145
|
+
walk(node.start_node, parent, flow_group, parent_group, action)
|
146
|
+
|
147
|
+
# Process successors of the flow's start node
|
148
|
+
for next_action, nxt in node.successors.items():
|
149
|
+
walk(
|
150
|
+
nxt,
|
151
|
+
get_id(node.start_node),
|
152
|
+
flow_group,
|
153
|
+
parent_group,
|
154
|
+
next_action,
|
155
|
+
)
|
156
|
+
else:
|
157
|
+
# Process successors for regular nodes
|
158
|
+
for next_action, nxt in node.successors.items():
|
159
|
+
if isinstance(nxt, Flow):
|
160
|
+
# This node connects to a flow - track the group relationship
|
161
|
+
flow_group_id = get_id(nxt)
|
162
|
+
walk(nxt, node_id, None, group, next_action)
|
163
|
+
else:
|
164
|
+
# Regular node-to-node connection
|
165
|
+
walk(nxt, node_id, group, parent_group, next_action)
|
166
|
+
|
167
|
+
# Start the traversal
|
168
|
+
walk(start)
|
169
|
+
|
170
|
+
# Post-processing: Generate group links based on node connections between different groups
|
171
|
+
# This ensures that when nodes in different groups are connected, we show a group-to-group
|
172
|
+
# link rather than a direct node-to-node link
|
173
|
+
node_groups = {n["id"]: n["group"] for n in nodes}
|
174
|
+
filtered_links = []
|
175
|
+
|
176
|
+
for link in links:
|
177
|
+
source_id = link["source"]
|
178
|
+
target_id = link["target"]
|
179
|
+
source_group = node_groups.get(source_id, 0)
|
180
|
+
target_group = node_groups.get(target_id, 0)
|
181
|
+
|
182
|
+
# If source and target are in different groups and both groups are valid
|
183
|
+
if source_group != target_group and source_group > 0 and target_group > 0:
|
184
|
+
# Add to group links if not already there
|
185
|
+
# This creates the dashed lines connecting group boxes
|
186
|
+
if not any(
|
187
|
+
gl["source"] == source_group and gl["target"] == target_group
|
188
|
+
for gl in group_links
|
189
|
+
):
|
190
|
+
group_links.append(
|
191
|
+
{
|
192
|
+
"source": source_group,
|
193
|
+
"target": target_group,
|
194
|
+
"action": link["action"],
|
195
|
+
}
|
196
|
+
)
|
197
|
+
# Skip adding this link to filtered_links - we don't want direct node connections across groups
|
198
|
+
else:
|
199
|
+
# Keep links within the same group
|
200
|
+
filtered_links.append(link)
|
201
|
+
|
202
|
+
return {
|
203
|
+
"nodes": nodes,
|
204
|
+
"links": filtered_links, # Use filtered links instead of all links
|
205
|
+
"group_links": group_links,
|
206
|
+
"flows": {str(k): v.__class__.__name__ for k, v in flow_nodes.items()},
|
207
|
+
}
|
208
|
+
|
209
|
+
|
210
|
+
def create_d3_visualization(
|
211
|
+
json_data,
|
212
|
+
output_dir="./viz",
|
213
|
+
filename="flow_viz",
|
214
|
+
html_title="PocketFlow Visualization",
|
215
|
+
):
|
216
|
+
"""Create a D3.js visualization from JSON data.
|
217
|
+
|
218
|
+
Args:
|
219
|
+
json_data: The JSON data for the visualization
|
220
|
+
output_dir: Directory to save the files
|
221
|
+
filename: Base filename (without extension)
|
222
|
+
html_title: Title for the HTML page
|
223
|
+
|
224
|
+
Returns:
|
225
|
+
str: Path to the HTML file
|
226
|
+
"""
|
227
|
+
# Create output directory if it doesn't exist
|
228
|
+
os.makedirs(output_dir, exist_ok=True)
|
229
|
+
|
230
|
+
# Save JSON data to file
|
231
|
+
json_path = os.path.join(output_dir, f"{filename}.json")
|
232
|
+
with open(json_path, "w") as f:
|
233
|
+
json.dump(json_data, f, indent=2)
|
234
|
+
|
235
|
+
# Create HTML file with D3.js visualization
|
236
|
+
html_content = r"""<!DOCTYPE html>
|
237
|
+
<html>
|
238
|
+
<head>
|
239
|
+
<meta charset="utf-8">
|
240
|
+
<title>TITLE_PLACEHOLDER</title>
|
241
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
242
|
+
<style>
|
243
|
+
body {
|
244
|
+
font-family: Arial, sans-serif;
|
245
|
+
margin: 0;
|
246
|
+
padding: 0;
|
247
|
+
overflow: hidden;
|
248
|
+
}
|
249
|
+
svg {
|
250
|
+
width: 100vw;
|
251
|
+
height: 100vh;
|
252
|
+
}
|
253
|
+
.links path {
|
254
|
+
fill: none;
|
255
|
+
stroke: #999;
|
256
|
+
stroke-opacity: 0.6;
|
257
|
+
stroke-width: 1.5px;
|
258
|
+
}
|
259
|
+
.group-links path {
|
260
|
+
fill: none;
|
261
|
+
stroke: #333;
|
262
|
+
stroke-opacity: 0.8;
|
263
|
+
stroke-width: 2px;
|
264
|
+
stroke-dasharray: 5,5;
|
265
|
+
}
|
266
|
+
.nodes circle {
|
267
|
+
stroke: #fff;
|
268
|
+
stroke-width: 1.5px;
|
269
|
+
}
|
270
|
+
.node-labels {
|
271
|
+
font-size: 12px;
|
272
|
+
pointer-events: none;
|
273
|
+
}
|
274
|
+
.link-labels {
|
275
|
+
font-size: 10px;
|
276
|
+
fill: #666;
|
277
|
+
pointer-events: none;
|
278
|
+
}
|
279
|
+
.group-link-labels {
|
280
|
+
font-size: 11px;
|
281
|
+
font-weight: bold;
|
282
|
+
fill: #333;
|
283
|
+
pointer-events: none;
|
284
|
+
}
|
285
|
+
.group-container {
|
286
|
+
stroke: #333;
|
287
|
+
stroke-width: 1.5px;
|
288
|
+
stroke-dasharray: 5,5;
|
289
|
+
fill: rgba(200, 200, 200, 0.1);
|
290
|
+
rx: 10;
|
291
|
+
ry: 10;
|
292
|
+
}
|
293
|
+
.group-label {
|
294
|
+
font-size: 14px;
|
295
|
+
font-weight: bold;
|
296
|
+
pointer-events: none;
|
297
|
+
}
|
298
|
+
</style>
|
299
|
+
</head>
|
300
|
+
<body>
|
301
|
+
<svg id="graph"></svg>
|
302
|
+
<script>
|
303
|
+
// Load data from file
|
304
|
+
d3.json("FILENAME_PLACEHOLDER.json").then(data => {
|
305
|
+
const svg = d3.select("#graph");
|
306
|
+
const width = window.innerWidth;
|
307
|
+
const height = window.innerHeight;
|
308
|
+
|
309
|
+
// Define arrow markers for links
|
310
|
+
svg.append("defs").append("marker")
|
311
|
+
.attr("id", "arrowhead")
|
312
|
+
.attr("viewBox", "0 -5 10 10")
|
313
|
+
.attr("refX", 25) // Position the arrow away from the target node
|
314
|
+
.attr("refY", 0)
|
315
|
+
.attr("orient", "auto")
|
316
|
+
.attr("markerWidth", 6)
|
317
|
+
.attr("markerHeight", 6)
|
318
|
+
.attr("xoverflow", "visible")
|
319
|
+
.append("path")
|
320
|
+
.attr("d", "M 0,-5 L 10,0 L 0,5")
|
321
|
+
.attr("fill", "#999");
|
322
|
+
|
323
|
+
// Define thicker arrow markers for group links
|
324
|
+
svg.append("defs").append("marker")
|
325
|
+
.attr("id", "group-arrowhead")
|
326
|
+
.attr("viewBox", "0 -5 10 10")
|
327
|
+
.attr("refX", 3) // Position at the boundary of the group
|
328
|
+
.attr("refY", 0)
|
329
|
+
.attr("orient", "auto")
|
330
|
+
.attr("markerWidth", 8)
|
331
|
+
.attr("markerHeight", 8)
|
332
|
+
.attr("xoverflow", "visible")
|
333
|
+
.append("path")
|
334
|
+
.attr("d", "M 0,-5 L 10,0 L 0,5")
|
335
|
+
.attr("fill", "#333");
|
336
|
+
|
337
|
+
// Color scale for node groups
|
338
|
+
const color = d3.scaleOrdinal(d3.schemeCategory10);
|
339
|
+
|
340
|
+
// Process the data to identify groups
|
341
|
+
const groups = {};
|
342
|
+
data.nodes.forEach(node => {
|
343
|
+
if (node.group > 0) {
|
344
|
+
if (!groups[node.group]) {
|
345
|
+
// Use the flow name instead of generic "Group X"
|
346
|
+
const flowName = data.flows && data.flows[node.group] ? data.flows[node.group] : `Flow ${node.group}`;
|
347
|
+
groups[node.group] = {
|
348
|
+
id: node.group,
|
349
|
+
name: flowName,
|
350
|
+
nodes: [],
|
351
|
+
x: 0,
|
352
|
+
y: 0,
|
353
|
+
width: 0,
|
354
|
+
height: 0
|
355
|
+
};
|
356
|
+
}
|
357
|
+
groups[node.group].nodes.push(node);
|
358
|
+
}
|
359
|
+
});
|
360
|
+
|
361
|
+
// Create a force simulation
|
362
|
+
const simulation = d3.forceSimulation(data.nodes)
|
363
|
+
// Controls the distance between connected nodes
|
364
|
+
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
|
365
|
+
// Controls how nodes repel each other - lower values bring nodes closer
|
366
|
+
.force("charge", d3.forceManyBody().strength(-30))
|
367
|
+
// Centers the entire graph in the SVG
|
368
|
+
.force("center", d3.forceCenter(width / 2, height / 2))
|
369
|
+
// Prevents nodes from overlapping - acts like a minimum distance
|
370
|
+
.force("collide", d3.forceCollide().radius(50));
|
371
|
+
|
372
|
+
// Group forces - create a force to keep nodes in the same group closer together
|
373
|
+
// This creates the effect of nodes clustering within their group boxes
|
374
|
+
const groupForce = alpha => {
|
375
|
+
for (let i = 0; i < data.nodes.length; i++) {
|
376
|
+
const node = data.nodes[i];
|
377
|
+
if (node.group > 0) {
|
378
|
+
const group = groups[node.group];
|
379
|
+
if (group && group.nodes.length > 1) {
|
380
|
+
// Calculate center of group
|
381
|
+
let centerX = 0, centerY = 0;
|
382
|
+
group.nodes.forEach(n => {
|
383
|
+
centerX += n.x || 0;
|
384
|
+
centerY += n.y || 0;
|
385
|
+
});
|
386
|
+
centerX /= group.nodes.length;
|
387
|
+
centerY /= group.nodes.length;
|
388
|
+
|
389
|
+
// Move nodes toward center
|
390
|
+
const k = alpha * 0.3; // Increased from 0.1 to 0.3
|
391
|
+
node.vx += (centerX - node.x) * k;
|
392
|
+
node.vy += (centerY - node.y) * k;
|
393
|
+
}
|
394
|
+
}
|
395
|
+
}
|
396
|
+
};
|
397
|
+
|
398
|
+
// Additional force to position groups in a more organized layout (like in the image)
|
399
|
+
// This arranges the groups horizontally/vertically based on their connections
|
400
|
+
const groupLayoutForce = alpha => {
|
401
|
+
// Get group centers
|
402
|
+
const groupCenters = Object.values(groups).map(g => {
|
403
|
+
return { id: g.id, cx: 0, cy: 0 };
|
404
|
+
});
|
405
|
+
|
406
|
+
// Calculate current center positions
|
407
|
+
Object.values(groups).forEach(g => {
|
408
|
+
if (g.nodes.length > 0) {
|
409
|
+
let cx = 0, cy = 0;
|
410
|
+
g.nodes.forEach(n => {
|
411
|
+
cx += n.x || 0;
|
412
|
+
cy += n.y || 0;
|
413
|
+
});
|
414
|
+
|
415
|
+
const groupCenter = groupCenters.find(gc => gc.id === g.id);
|
416
|
+
if (groupCenter) {
|
417
|
+
groupCenter.cx = cx / g.nodes.length;
|
418
|
+
groupCenter.cy = cy / g.nodes.length;
|
419
|
+
}
|
420
|
+
}
|
421
|
+
});
|
422
|
+
|
423
|
+
// Apply forces to position groups
|
424
|
+
const k = alpha * 0.05;
|
425
|
+
|
426
|
+
// Try to position groups in a more structured way
|
427
|
+
// Adjust these values to change the overall layout
|
428
|
+
for (let i = 0; i < data.group_links.length; i++) {
|
429
|
+
const link = data.group_links[i];
|
430
|
+
const source = groupCenters.find(g => g.id === link.source);
|
431
|
+
const target = groupCenters.find(g => g.id === link.target);
|
432
|
+
|
433
|
+
if (source && target) {
|
434
|
+
// Add a horizontal force to align groups
|
435
|
+
const desiredDx = 300; // Desired horizontal distance between linked groups
|
436
|
+
const dx = target.cx - source.cx;
|
437
|
+
const diff = desiredDx - Math.abs(dx);
|
438
|
+
|
439
|
+
// Apply forces to group nodes
|
440
|
+
groups[source.id].nodes.forEach(n => {
|
441
|
+
if (dx > 0) {
|
442
|
+
n.vx -= diff * k;
|
443
|
+
} else {
|
444
|
+
n.vx += diff * k;
|
445
|
+
}
|
446
|
+
});
|
447
|
+
|
448
|
+
groups[target.id].nodes.forEach(n => {
|
449
|
+
if (dx > 0) {
|
450
|
+
n.vx += diff * k;
|
451
|
+
} else {
|
452
|
+
n.vx -= diff * k;
|
453
|
+
}
|
454
|
+
});
|
455
|
+
}
|
456
|
+
}
|
457
|
+
};
|
458
|
+
|
459
|
+
simulation.force("group", groupForce);
|
460
|
+
simulation.force("groupLayout", groupLayoutForce);
|
461
|
+
|
462
|
+
// Create links with arrow paths instead of lines
|
463
|
+
const link = svg.append("g")
|
464
|
+
.attr("class", "links")
|
465
|
+
.selectAll("path")
|
466
|
+
.data(data.links)
|
467
|
+
.enter()
|
468
|
+
.append("path")
|
469
|
+
.attr("stroke-width", 2)
|
470
|
+
.attr("stroke", "#999")
|
471
|
+
.attr("marker-end", "url(#arrowhead)"); // Add the arrowhead marker
|
472
|
+
|
473
|
+
// Create group containers (drawn before nodes)
|
474
|
+
const groupContainers = svg.append("g")
|
475
|
+
.attr("class", "groups")
|
476
|
+
.selectAll("rect")
|
477
|
+
.data(Object.values(groups))
|
478
|
+
.enter()
|
479
|
+
.append("rect")
|
480
|
+
.attr("class", "group-container")
|
481
|
+
.attr("fill", d => d3.color(color(d.id)).copy({opacity: 0.2}));
|
482
|
+
|
483
|
+
// Create group links between flows
|
484
|
+
const groupLink = svg.append("g")
|
485
|
+
.attr("class", "group-links")
|
486
|
+
.selectAll("path")
|
487
|
+
.data(data.group_links || [])
|
488
|
+
.enter()
|
489
|
+
.append("path")
|
490
|
+
.attr("stroke-width", 2)
|
491
|
+
.attr("stroke", "#333")
|
492
|
+
.attr("marker-end", "url(#group-arrowhead)");
|
493
|
+
|
494
|
+
// Create group link labels
|
495
|
+
const groupLinkLabel = svg.append("g")
|
496
|
+
.attr("class", "group-link-labels")
|
497
|
+
.selectAll("text")
|
498
|
+
.data(data.group_links || [])
|
499
|
+
.enter()
|
500
|
+
.append("text")
|
501
|
+
.text(d => d.action)
|
502
|
+
.attr("font-size", "11px")
|
503
|
+
.attr("font-weight", "bold")
|
504
|
+
.attr("fill", "#333");
|
505
|
+
|
506
|
+
// Create group labels
|
507
|
+
const groupLabels = svg.append("g")
|
508
|
+
.attr("class", "group-labels")
|
509
|
+
.selectAll("text")
|
510
|
+
.data(Object.values(groups))
|
511
|
+
.enter()
|
512
|
+
.append("text")
|
513
|
+
.attr("class", "group-label")
|
514
|
+
.text(d => d.name) // Now using the proper flow name
|
515
|
+
.attr("fill", d => d3.color(color(d.id)).darker());
|
516
|
+
|
517
|
+
// Create link labels
|
518
|
+
const linkLabel = svg.append("g")
|
519
|
+
.attr("class", "link-labels")
|
520
|
+
.selectAll("text")
|
521
|
+
.data(data.links)
|
522
|
+
.enter()
|
523
|
+
.append("text")
|
524
|
+
.text(d => d.action)
|
525
|
+
.attr("font-size", "10px")
|
526
|
+
.attr("fill", "#666");
|
527
|
+
|
528
|
+
// Create nodes
|
529
|
+
const node = svg.append("g")
|
530
|
+
.attr("class", "nodes")
|
531
|
+
.selectAll("circle")
|
532
|
+
.data(data.nodes)
|
533
|
+
.enter()
|
534
|
+
.append("circle")
|
535
|
+
.attr("r", 15)
|
536
|
+
.attr("fill", d => color(d.group))
|
537
|
+
.call(d3.drag()
|
538
|
+
.on("start", dragstarted)
|
539
|
+
.on("drag", dragged)
|
540
|
+
.on("end", dragended));
|
541
|
+
|
542
|
+
// Create node labels
|
543
|
+
const nodeLabel = svg.append("g")
|
544
|
+
.attr("class", "node-labels")
|
545
|
+
.selectAll("text")
|
546
|
+
.data(data.nodes)
|
547
|
+
.enter()
|
548
|
+
.append("text")
|
549
|
+
.text(d => d.name)
|
550
|
+
.attr("text-anchor", "middle")
|
551
|
+
.attr("dy", 25);
|
552
|
+
|
553
|
+
// Add tooltip on hover
|
554
|
+
node.append("title")
|
555
|
+
.text(d => d.name);
|
556
|
+
|
557
|
+
// Update positions on each tick
|
558
|
+
simulation.on("tick", () => {
|
559
|
+
// Update links with curved paths for bidirectional connections
|
560
|
+
link.attr("d", d => {
|
561
|
+
// Handle self-referencing links with a water-drop shape
|
562
|
+
if (d.source === d.target) {
|
563
|
+
const nodeX = d.source.x;
|
564
|
+
const nodeY = d.source.y;
|
565
|
+
const offsetX = 40;
|
566
|
+
const offsetY = 10;
|
567
|
+
const controlOffset = 50;
|
568
|
+
|
569
|
+
// Create a water-drop shaped path
|
570
|
+
return `M ${nodeX},${nodeY - 5}
|
571
|
+
C ${nodeX + controlOffset},${nodeY - 30}
|
572
|
+
${nodeX + offsetX},${nodeY + offsetY}
|
573
|
+
${nodeX},${nodeY}`;
|
574
|
+
}
|
575
|
+
|
576
|
+
// Check if there's a reverse connection
|
577
|
+
const isReverse = data.links.some(l =>
|
578
|
+
l.source === d.target && l.target === d.source
|
579
|
+
);
|
580
|
+
|
581
|
+
// If it's part of a bidirectional connection, curve the path
|
582
|
+
if (isReverse) {
|
583
|
+
const dx = d.target.x - d.source.x;
|
584
|
+
const dy = d.target.y - d.source.y;
|
585
|
+
const dr = Math.sqrt(dx * dx + dy * dy) * 0.9;
|
586
|
+
|
587
|
+
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
|
588
|
+
}
|
589
|
+
|
590
|
+
// For unidirectional connections, use straight lines
|
591
|
+
return `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`;
|
592
|
+
});
|
593
|
+
|
594
|
+
// Update nodes
|
595
|
+
node
|
596
|
+
.attr("cx", d => d.x)
|
597
|
+
.attr("cy", d => d.y);
|
598
|
+
|
599
|
+
// Update node labels
|
600
|
+
nodeLabel
|
601
|
+
.attr("x", d => d.x)
|
602
|
+
.attr("y", d => d.y);
|
603
|
+
|
604
|
+
// Position link labels with offset for bidirectional connections
|
605
|
+
linkLabel.attr("x", d => {
|
606
|
+
// Handle self-referencing links
|
607
|
+
if (d.source === d.target) {
|
608
|
+
return d.source.x + 30;
|
609
|
+
}
|
610
|
+
|
611
|
+
// Check if there's a reverse connection
|
612
|
+
const reverseLink = data.links.find(l =>
|
613
|
+
l.source === d.target && l.target === d.source
|
614
|
+
);
|
615
|
+
|
616
|
+
// If it's part of a bidirectional connection, offset the label
|
617
|
+
if (reverseLink) {
|
618
|
+
const dx = d.target.x - d.source.x;
|
619
|
+
const dy = d.target.y - d.source.y;
|
620
|
+
// Calculate perpendicular offset
|
621
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
622
|
+
const offsetX = -dy / length * 10; // Perpendicular offset
|
623
|
+
|
624
|
+
return (d.source.x + d.target.x) / 2 + offsetX;
|
625
|
+
}
|
626
|
+
|
627
|
+
// For unidirectional connections, use midpoint
|
628
|
+
return (d.source.x + d.target.x) / 2;
|
629
|
+
})
|
630
|
+
.attr("y", d => {
|
631
|
+
// Handle self-referencing links
|
632
|
+
if (d.source === d.target) {
|
633
|
+
return d.source.y;
|
634
|
+
}
|
635
|
+
|
636
|
+
// Check if there's a reverse connection
|
637
|
+
const reverseLink = data.links.find(l =>
|
638
|
+
l.source === d.target && l.target === d.source
|
639
|
+
);
|
640
|
+
|
641
|
+
// If it's part of a bidirectional connection, offset the label
|
642
|
+
if (reverseLink) {
|
643
|
+
const dx = d.target.x - d.source.x;
|
644
|
+
const dy = d.target.y - d.source.y;
|
645
|
+
// Calculate perpendicular offset
|
646
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
647
|
+
const offsetY = dx / length * 10; // Perpendicular offset
|
648
|
+
|
649
|
+
return (d.source.y + d.target.y) / 2 + offsetY;
|
650
|
+
}
|
651
|
+
|
652
|
+
// For unidirectional connections, use midpoint
|
653
|
+
return (d.source.y + d.target.y) / 2;
|
654
|
+
});
|
655
|
+
|
656
|
+
// Update group containers
|
657
|
+
groupContainers.each(function(d) {
|
658
|
+
// If there are nodes in this group
|
659
|
+
if (d.nodes.length > 0) {
|
660
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
661
|
+
|
662
|
+
// Find the bounding box for all nodes in the group
|
663
|
+
d.nodes.forEach(n => {
|
664
|
+
minX = Math.min(minX, n.x - 30);
|
665
|
+
minY = Math.min(minY, n.y - 30);
|
666
|
+
maxX = Math.max(maxX, n.x + 30);
|
667
|
+
maxY = Math.max(maxY, n.y + 40); // Extra space for labels
|
668
|
+
});
|
669
|
+
|
670
|
+
// Add padding
|
671
|
+
const padding = 20;
|
672
|
+
minX -= padding;
|
673
|
+
minY -= padding;
|
674
|
+
maxX += padding;
|
675
|
+
maxY += padding;
|
676
|
+
|
677
|
+
// Save group dimensions
|
678
|
+
d.x = minX;
|
679
|
+
d.y = minY;
|
680
|
+
d.width = maxX - minX;
|
681
|
+
d.height = maxY - minY;
|
682
|
+
d.centerX = minX + d.width / 2;
|
683
|
+
d.centerY = minY + d.height / 2;
|
684
|
+
|
685
|
+
// Set position and size of the group container
|
686
|
+
d3.select(this)
|
687
|
+
.attr("x", minX)
|
688
|
+
.attr("y", minY)
|
689
|
+
.attr("width", d.width)
|
690
|
+
.attr("height", d.height);
|
691
|
+
|
692
|
+
// Update group label position (top-left of group)
|
693
|
+
groupLabels.filter(g => g.id === d.id)
|
694
|
+
.attr("x", minX + 10)
|
695
|
+
.attr("y", minY + 20);
|
696
|
+
}
|
697
|
+
});
|
698
|
+
|
699
|
+
// Update group links between flows
|
700
|
+
groupLink.attr("d", d => {
|
701
|
+
const sourceGroup = groups[d.source];
|
702
|
+
const targetGroup = groups[d.target];
|
703
|
+
|
704
|
+
if (!sourceGroup || !targetGroup) return "";
|
705
|
+
|
706
|
+
// Find intersection points with group boundaries
|
707
|
+
// This ensures links connect to the group's border rather than its center
|
708
|
+
|
709
|
+
// Calculate centers of groups
|
710
|
+
const sx = sourceGroup.centerX;
|
711
|
+
const sy = sourceGroup.centerY;
|
712
|
+
const tx = targetGroup.centerX;
|
713
|
+
const ty = targetGroup.centerY;
|
714
|
+
|
715
|
+
// Calculate angle between centers - used to find intersection points
|
716
|
+
const angle = Math.atan2(ty - sy, tx - sx);
|
717
|
+
|
718
|
+
// Calculate intersection points with source group borders
|
719
|
+
// We cast a ray from center in the direction of the target
|
720
|
+
let sourceX, sourceY;
|
721
|
+
const cosA = Math.cos(angle);
|
722
|
+
const sinA = Math.sin(angle);
|
723
|
+
|
724
|
+
// Check intersection with horizontal borders (top and bottom)
|
725
|
+
const ts_top = (sourceGroup.y - sy) / sinA;
|
726
|
+
const ts_bottom = (sourceGroup.y + sourceGroup.height - sy) / sinA;
|
727
|
+
|
728
|
+
// Check intersection with vertical borders (left and right)
|
729
|
+
const ts_left = (sourceGroup.x - sx) / cosA;
|
730
|
+
const ts_right = (sourceGroup.x + sourceGroup.width - sx) / cosA;
|
731
|
+
|
732
|
+
// Use the closest positive intersection (first hit with the boundary)
|
733
|
+
let t_source = Infinity;
|
734
|
+
if (ts_top > 0) t_source = Math.min(t_source, ts_top);
|
735
|
+
if (ts_bottom > 0) t_source = Math.min(t_source, ts_bottom);
|
736
|
+
if (ts_left > 0) t_source = Math.min(t_source, ts_left);
|
737
|
+
if (ts_right > 0) t_source = Math.min(t_source, ts_right);
|
738
|
+
|
739
|
+
// Target group: Find intersection in the opposite direction
|
740
|
+
// We cast a ray from target center toward the source
|
741
|
+
let targetX, targetY;
|
742
|
+
const oppositeAngle = angle + Math.PI;
|
743
|
+
const cosOpp = Math.cos(oppositeAngle);
|
744
|
+
const sinOpp = Math.sin(oppositeAngle);
|
745
|
+
|
746
|
+
// Check intersections for target group
|
747
|
+
const tt_top = (targetGroup.y - ty) / sinOpp;
|
748
|
+
const tt_bottom = (targetGroup.y + targetGroup.height - ty) / sinOpp;
|
749
|
+
const tt_left = (targetGroup.x - tx) / cosOpp;
|
750
|
+
const tt_right = (targetGroup.x + targetGroup.width - tx) / cosOpp;
|
751
|
+
|
752
|
+
// Use the closest positive intersection
|
753
|
+
let t_target = Infinity;
|
754
|
+
if (tt_top > 0) t_target = Math.min(t_target, tt_top);
|
755
|
+
if (tt_bottom > 0) t_target = Math.min(t_target, tt_bottom);
|
756
|
+
if (tt_left > 0) t_target = Math.min(t_target, tt_left);
|
757
|
+
if (tt_right > 0) t_target = Math.min(t_target, tt_right);
|
758
|
+
|
759
|
+
// Calculate actual border points using parametric equation:
|
760
|
+
// point = center + t * direction
|
761
|
+
if (t_source !== Infinity) {
|
762
|
+
sourceX = sx + cosA * t_source;
|
763
|
+
sourceY = sy + sinA * t_source;
|
764
|
+
} else {
|
765
|
+
sourceX = sx;
|
766
|
+
sourceY = sy;
|
767
|
+
}
|
768
|
+
|
769
|
+
if (t_target !== Infinity) {
|
770
|
+
targetX = tx + cosOpp * t_target;
|
771
|
+
targetY = ty + sinOpp * t_target;
|
772
|
+
} else {
|
773
|
+
targetX = tx;
|
774
|
+
targetY = ty;
|
775
|
+
}
|
776
|
+
|
777
|
+
// Create a straight line between the border points
|
778
|
+
return `M${sourceX},${sourceY} L${targetX},${targetY}`;
|
779
|
+
});
|
780
|
+
|
781
|
+
// Update group link labels
|
782
|
+
groupLinkLabel.attr("x", d => {
|
783
|
+
const sourceGroup = groups[d.source];
|
784
|
+
const targetGroup = groups[d.target];
|
785
|
+
if (!sourceGroup || !targetGroup) return 0;
|
786
|
+
return (sourceGroup.centerX + targetGroup.centerX) / 2;
|
787
|
+
})
|
788
|
+
.attr("y", d => {
|
789
|
+
const sourceGroup = groups[d.source];
|
790
|
+
const targetGroup = groups[d.target];
|
791
|
+
if (!sourceGroup || !targetGroup) return 0;
|
792
|
+
return (sourceGroup.centerY + targetGroup.centerY) / 2 - 10;
|
793
|
+
});
|
794
|
+
});
|
795
|
+
|
796
|
+
// Drag functions
|
797
|
+
function dragstarted(event, d) {
|
798
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
799
|
+
d.fx = d.x;
|
800
|
+
d.fy = d.y;
|
801
|
+
}
|
802
|
+
|
803
|
+
function dragged(event, d) {
|
804
|
+
d.fx = event.x;
|
805
|
+
d.fy = event.y;
|
806
|
+
}
|
807
|
+
|
808
|
+
function dragended(event, d) {
|
809
|
+
if (!event.active) simulation.alphaTarget(0);
|
810
|
+
d.fx = null;
|
811
|
+
d.fy = null;
|
812
|
+
}
|
813
|
+
});
|
814
|
+
</script>
|
815
|
+
</body>
|
816
|
+
</html>
|
817
|
+
"""
|
818
|
+
|
819
|
+
# Replace the placeholders with the actual values
|
820
|
+
html_content = html_content.replace("FILENAME_PLACEHOLDER", filename)
|
821
|
+
html_content = html_content.replace("TITLE_PLACEHOLDER", html_title)
|
822
|
+
|
823
|
+
# Write HTML to file
|
824
|
+
html_path = os.path.join(output_dir, f"{filename}.html")
|
825
|
+
with open(html_path, "w") as f:
|
826
|
+
f.write(html_content)
|
827
|
+
|
828
|
+
print(f"Visualization created at {html_path}")
|
829
|
+
return html_path
|
830
|
+
|
831
|
+
|
832
|
+
def find_free_port():
|
833
|
+
"""Find a free port on localhost."""
|
834
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
835
|
+
s.bind(("", 0))
|
836
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
837
|
+
return s.getsockname()[1]
|
838
|
+
|
839
|
+
|
840
|
+
def start_http_server(directory, port=None):
|
841
|
+
"""Start an HTTP server in the given directory.
|
842
|
+
|
843
|
+
Args:
|
844
|
+
directory: Directory to serve files from
|
845
|
+
port: Port to use (finds a free port if None)
|
846
|
+
|
847
|
+
Returns:
|
848
|
+
tuple: (server_thread, port)
|
849
|
+
"""
|
850
|
+
if port is None:
|
851
|
+
port = find_free_port()
|
852
|
+
|
853
|
+
# Get the absolute path of the directory
|
854
|
+
directory = str(Path(directory).absolute())
|
855
|
+
|
856
|
+
# Change to the directory to serve files
|
857
|
+
os.chdir(directory)
|
858
|
+
|
859
|
+
# Create HTTP server
|
860
|
+
handler = http.server.SimpleHTTPRequestHandler
|
861
|
+
httpd = socketserver.TCPServer(("", port), handler)
|
862
|
+
|
863
|
+
# Start server in a separate thread
|
864
|
+
server_thread = threading.Thread(target=httpd.serve_forever)
|
865
|
+
server_thread.daemon = (
|
866
|
+
True # This makes the thread exit when the main program exits
|
867
|
+
)
|
868
|
+
server_thread.start()
|
869
|
+
|
870
|
+
print(f"Server started at http://localhost:{port}")
|
871
|
+
return server_thread, port
|
872
|
+
|
873
|
+
|
874
|
+
def serve_and_open_visualization(html_path, auto_open=True):
|
875
|
+
"""Serve the HTML file and open it in a browser.
|
876
|
+
|
877
|
+
Args:
|
878
|
+
html_path: Path to the HTML file
|
879
|
+
auto_open: Whether to automatically open the browser
|
880
|
+
|
881
|
+
Returns:
|
882
|
+
tuple: (server_thread, url)
|
883
|
+
"""
|
884
|
+
# Get the directory and filename
|
885
|
+
directory = os.path.dirname(os.path.abspath(html_path))
|
886
|
+
filename = os.path.basename(html_path)
|
887
|
+
|
888
|
+
# Start the server
|
889
|
+
server_thread, port = start_http_server(directory)
|
890
|
+
|
891
|
+
# Build the URL
|
892
|
+
url = f"http://localhost:{port}/{filename}"
|
893
|
+
|
894
|
+
# Open the URL in a browser
|
895
|
+
if auto_open:
|
896
|
+
print(f"Opening {url} in your browser...")
|
897
|
+
webbrowser.open(url)
|
898
|
+
else:
|
899
|
+
print(f"Visualization available at {url}")
|
900
|
+
|
901
|
+
return server_thread, url
|
902
|
+
|
903
|
+
|
904
|
+
def visualize_flow(
|
905
|
+
flow: Flow,
|
906
|
+
flow_name: str,
|
907
|
+
serve: bool = True,
|
908
|
+
auto_open: bool = True,
|
909
|
+
output_dir: str = "./viz",
|
910
|
+
html_title: Optional[str] = None,
|
911
|
+
) -> Union[str, Tuple[str, Any, str]]:
|
912
|
+
"""Helper function to visualize a flow with both mermaid and D3.js
|
913
|
+
|
914
|
+
Args:
|
915
|
+
flow: Flow object to visualize
|
916
|
+
flow_name: Name of the flow (used for filename and display)
|
917
|
+
serve: Whether to start a server for the visualization
|
918
|
+
auto_open: Whether to automatically open in browser
|
919
|
+
output_dir: Directory to save visualization files
|
920
|
+
html_title: Custom title for the HTML page (defaults to flow_name if None)
|
921
|
+
|
922
|
+
Returns:
|
923
|
+
str or tuple: Path to HTML file, or (path, server_thread, url) if serve=True
|
924
|
+
"""
|
925
|
+
print(f"\n--- {flow_name} Mermaid Diagram ---")
|
926
|
+
print(build_mermaid(start=flow))
|
927
|
+
|
928
|
+
print(f"\n--- {flow_name} D3.js Visualization ---")
|
929
|
+
json_data = flow_to_json(flow)
|
930
|
+
|
931
|
+
# Create the visualization
|
932
|
+
output_filename = f"{flow_name.lower().replace(' ', '_')}"
|
933
|
+
|
934
|
+
# Use flow_name as the HTML title if not specified
|
935
|
+
if html_title is None:
|
936
|
+
html_title = f"PocketFlow: {flow_name}"
|
937
|
+
|
938
|
+
html_path = create_d3_visualization(
|
939
|
+
json_data,
|
940
|
+
output_dir=output_dir,
|
941
|
+
filename=output_filename,
|
942
|
+
html_title=html_title,
|
943
|
+
)
|
944
|
+
|
945
|
+
# Serve and open if requested
|
946
|
+
if serve:
|
947
|
+
server_thread, url = serve_and_open_visualization(html_path, auto_open)
|
948
|
+
return html_path, server_thread, url
|
949
|
+
|
950
|
+
return html_path
|
951
|
+
|
952
|
+
|
953
|
+
def load_flow_from_module(module_path: str, flow_variable: str) -> Flow:
|
954
|
+
"""Dynamically load a flow from a module.
|
955
|
+
|
956
|
+
Args:
|
957
|
+
module_path: Path to the module (e.g., 'my_package.my_module')
|
958
|
+
flow_variable: Name of the flow variable in the module
|
959
|
+
|
960
|
+
Returns:
|
961
|
+
Flow: The loaded flow object
|
962
|
+
"""
|
963
|
+
try:
|
964
|
+
module = importlib.import_module(module_path)
|
965
|
+
return getattr(module, flow_variable)
|
966
|
+
except (ImportError, AttributeError) as e:
|
967
|
+
print(f"Error loading flow: {e}")
|
968
|
+
sys.exit(1)
|
969
|
+
|
970
|
+
|
971
|
+
# Example usage
|
972
|
+
if __name__ == "__main__":
|
973
|
+
import argparse
|
974
|
+
|
975
|
+
parser = argparse.ArgumentParser(description="Visualize a PocketFlow flow")
|
976
|
+
parser.add_argument(
|
977
|
+
"--module", default="async_loop_flow", help="Module containing the flow"
|
978
|
+
)
|
979
|
+
parser.add_argument(
|
980
|
+
"--flow", default="order_pipeline", help="Flow variable name in the module"
|
981
|
+
)
|
982
|
+
parser.add_argument(
|
983
|
+
"--name", default="Flow Visualization", help="Name for the visualization"
|
984
|
+
)
|
985
|
+
parser.add_argument(
|
986
|
+
"--output-dir", default="./viz", help="Directory to save visualization files"
|
987
|
+
)
|
988
|
+
parser.add_argument("--no-serve", action="store_true", help="Don't start a server")
|
989
|
+
parser.add_argument(
|
990
|
+
"--no-open", action="store_true", help="Don't open browser automatically"
|
991
|
+
)
|
992
|
+
parser.add_argument("--title", help="Custom HTML title")
|
993
|
+
|
994
|
+
args = parser.parse_args()
|
995
|
+
|
996
|
+
# Load flow from the specified module
|
997
|
+
flow_obj = load_flow_from_module(args.module, args.flow)
|
998
|
+
|
999
|
+
# Visualize the flow
|
1000
|
+
visualize_flow(
|
1001
|
+
flow=flow_obj,
|
1002
|
+
flow_name=args.name,
|
1003
|
+
serve=not args.no_serve,
|
1004
|
+
auto_open=not args.no_open,
|
1005
|
+
output_dir=args.output_dir,
|
1006
|
+
html_title=args.title,
|
1007
|
+
)
|
1008
|
+
|
1009
|
+
# Keep server running if serving
|
1010
|
+
if not args.no_serve:
|
1011
|
+
try:
|
1012
|
+
print("\nServer is running. Press Ctrl+C to stop...")
|
1013
|
+
while True:
|
1014
|
+
time.sleep(1)
|
1015
|
+
except KeyboardInterrupt:
|
1016
|
+
print("\nShutting down...")
|