agentworkflow-viz 0.2.0__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.
- agentviz/__init__.py +88 -0
- agentviz/client.py +151 -0
- agentviz/config.py +123 -0
- agentviz/server.py +104 -0
- agentviz/static/graph.css +334 -0
- agentviz/static/graph.html +58 -0
- agentviz/static/graph.js +704 -0
- agentviz/tracer.py +91 -0
- agentworkflow_viz-0.2.0.dist-info/METADATA +113 -0
- agentworkflow_viz-0.2.0.dist-info/RECORD +13 -0
- agentworkflow_viz-0.2.0.dist-info/WHEEL +5 -0
- agentworkflow_viz-0.2.0.dist-info/licenses/LICENSE +21 -0
- agentworkflow_viz-0.2.0.dist-info/top_level.txt +1 -0
agentviz/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from agentviz.config import (
|
|
5
|
+
VizConfig,
|
|
6
|
+
VizStyle,
|
|
7
|
+
NodeStyle,
|
|
8
|
+
SectionVisibility,
|
|
9
|
+
ThemeColors,
|
|
10
|
+
VizCustomization,
|
|
11
|
+
NodeCustomProps,
|
|
12
|
+
NodeRenderer,
|
|
13
|
+
)
|
|
14
|
+
from agentviz.client import VizClient
|
|
15
|
+
from agentviz.server import _run_server, mount_app
|
|
16
|
+
from agentviz.tracer import LangGraphTracer, VizTracer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_env_config():
|
|
20
|
+
enabled = os.environ.get("AGENT_VIZ_ENABLED", "true").lower() not in ("false", "0", "no")
|
|
21
|
+
port = int(os.environ.get("AGENT_VIZ_PORT", "8100"))
|
|
22
|
+
host = os.environ.get("AGENT_VIZ_HOST", "0.0.0.0")
|
|
23
|
+
mount = os.environ.get("AGENT_VIZ_MOUNT", "false").lower() in ("true", "1", "yes")
|
|
24
|
+
return VizConfig(enable=enabled, port=port, host=host, mount=mount)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _merge_config(user_cfg, env_cfg):
|
|
28
|
+
return VizConfig(
|
|
29
|
+
enable=user_cfg.enable if user_cfg.enable is not None else env_cfg.enable,
|
|
30
|
+
port=user_cfg.port if user_cfg.port is not None else env_cfg.port,
|
|
31
|
+
host=user_cfg.host if user_cfg.host is not None else env_cfg.host,
|
|
32
|
+
mount=user_cfg.mount if user_cfg.mount is not None else env_cfg.mount,
|
|
33
|
+
style=user_cfg.style if user_cfg.style is not None else env_cfg.style,
|
|
34
|
+
customization=user_cfg.customization if user_cfg.customization is not None else env_cfg.customization,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class VizPlugin:
|
|
39
|
+
def __init__(self, config: Optional[VizConfig] = None):
|
|
40
|
+
env_cfg = _read_env_config()
|
|
41
|
+
if config is None:
|
|
42
|
+
self.config = env_cfg
|
|
43
|
+
else:
|
|
44
|
+
self.config = _merge_config(config, env_cfg)
|
|
45
|
+
self._ready = False
|
|
46
|
+
self._client = VizClient(
|
|
47
|
+
host=self.config.host,
|
|
48
|
+
port=self.config.port,
|
|
49
|
+
style=self.config.style,
|
|
50
|
+
customization=self.config.customization,
|
|
51
|
+
)
|
|
52
|
+
self._tracer = LangGraphTracer(client=self._client) if self.config.enable else None
|
|
53
|
+
|
|
54
|
+
def start(self):
|
|
55
|
+
if not self.config.enable:
|
|
56
|
+
self._ready = True
|
|
57
|
+
return
|
|
58
|
+
if self.config.mount:
|
|
59
|
+
self._ready = True
|
|
60
|
+
return
|
|
61
|
+
_run_server(self.config, client=self._client)
|
|
62
|
+
self._client.push_style()
|
|
63
|
+
self._client.push_customization()
|
|
64
|
+
self._ready = True
|
|
65
|
+
|
|
66
|
+
def stop(self):
|
|
67
|
+
self._client.flush()
|
|
68
|
+
self._client.close()
|
|
69
|
+
|
|
70
|
+
def wrap(self, graph):
|
|
71
|
+
if not self.config.enable:
|
|
72
|
+
return graph
|
|
73
|
+
return self._tracer.wrap(graph)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"VizPlugin",
|
|
78
|
+
"VizConfig",
|
|
79
|
+
"VizStyle",
|
|
80
|
+
"NodeStyle",
|
|
81
|
+
"SectionVisibility",
|
|
82
|
+
"ThemeColors",
|
|
83
|
+
"VizCustomization",
|
|
84
|
+
"NodeCustomProps",
|
|
85
|
+
"NodeRenderer",
|
|
86
|
+
"VizTracer",
|
|
87
|
+
"mount_app",
|
|
88
|
+
]
|
agentviz/client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from agentviz.config import VizStyle, VizCustomization, NodeRenderer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VizClient:
|
|
13
|
+
MAX_BUFFER = 200
|
|
14
|
+
MAX_RETRIES = 3
|
|
15
|
+
BACKOFF_BASE = 0.1
|
|
16
|
+
|
|
17
|
+
def __init__(self, host: str = "0.0.0.0", port: int = 8100, style: Optional[VizStyle] = None, customization: Optional[VizCustomization] = None):
|
|
18
|
+
self._host = host
|
|
19
|
+
self._port = port
|
|
20
|
+
self._url = f"http://{host}:{port}"
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._trace_log: deque[dict] = deque(maxlen=self.MAX_BUFFER)
|
|
23
|
+
self._graph_data: Optional[dict] = None
|
|
24
|
+
self._trace_id = 0
|
|
25
|
+
self._closed = False
|
|
26
|
+
self._style = style
|
|
27
|
+
self._customization = customization
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def trace_log(self):
|
|
31
|
+
return list(self._trace_log)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def graph_data(self):
|
|
35
|
+
return self._graph_data
|
|
36
|
+
|
|
37
|
+
@graph_data.setter
|
|
38
|
+
def graph_data(self, value: dict):
|
|
39
|
+
self._graph_data = value
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def style_config(self) -> Optional[dict]:
|
|
43
|
+
if self._style is None:
|
|
44
|
+
return None
|
|
45
|
+
return self._style_to_dict(self._style)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _style_to_dict(style: VizStyle) -> dict:
|
|
49
|
+
out = {}
|
|
50
|
+
ns = {}
|
|
51
|
+
for node_id, nst in style.node_styles.items():
|
|
52
|
+
ns[node_id] = asdict(nst)
|
|
53
|
+
out["node_styles"] = ns
|
|
54
|
+
out["sections"] = asdict(style.sections)
|
|
55
|
+
tc = {}
|
|
56
|
+
for k, v in asdict(style.theme).items():
|
|
57
|
+
if v is not None:
|
|
58
|
+
tc[k] = v
|
|
59
|
+
out["theme"] = tc
|
|
60
|
+
out["title"] = style.title
|
|
61
|
+
out["show_groups"] = style.show_groups
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
def _build_customization_payload(self) -> Optional[dict]:
|
|
65
|
+
if self._customization is None:
|
|
66
|
+
return None
|
|
67
|
+
cfg = self._customization
|
|
68
|
+
out = {}
|
|
69
|
+
out["title"] = cfg.title
|
|
70
|
+
out["show_groups"] = cfg.show_groups
|
|
71
|
+
out["custom_css"] = cfg.custom_css
|
|
72
|
+
out["custom_js"] = cfg.custom_js
|
|
73
|
+
out["custom_html_header"] = cfg.custom_html_header
|
|
74
|
+
out["custom_html_body"] = cfg.custom_html_body
|
|
75
|
+
if cfg.theme_colors is not None:
|
|
76
|
+
tc = {}
|
|
77
|
+
for k, v in asdict(cfg.theme_colors).items():
|
|
78
|
+
if v is not None:
|
|
79
|
+
tc[k] = v
|
|
80
|
+
out["theme"] = tc
|
|
81
|
+
if cfg.section_visibility is not None:
|
|
82
|
+
out["sections"] = asdict(cfg.section_visibility)
|
|
83
|
+
node_props = {}
|
|
84
|
+
for node_id, ncp in cfg.node_props.items():
|
|
85
|
+
node_props[node_id] = asdict(ncp)
|
|
86
|
+
if cfg.node_renderer is not None:
|
|
87
|
+
node_props["_renderer"] = True
|
|
88
|
+
out["node_props"] = node_props
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
def _apply_node_renderer(self, nodes: list) -> list:
|
|
92
|
+
if self._customization is None or self._customization.node_renderer is None:
|
|
93
|
+
return nodes
|
|
94
|
+
renderer: NodeRenderer = self._customization.node_renderer
|
|
95
|
+
result = []
|
|
96
|
+
for node in nodes:
|
|
97
|
+
n = dict(node)
|
|
98
|
+
nid = n.get("id", "")
|
|
99
|
+
rendered = renderer.render(nid, n)
|
|
100
|
+
if rendered:
|
|
101
|
+
existing = n.get("_custom_props") or {}
|
|
102
|
+
existing.update(rendered)
|
|
103
|
+
n["_custom_props"] = existing
|
|
104
|
+
result.append(n)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
def _next_id(self):
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._trace_id += 1
|
|
110
|
+
return self._trace_id
|
|
111
|
+
|
|
112
|
+
def push_graph(self, nodes: list, edges: list):
|
|
113
|
+
enriched = self._apply_node_renderer(nodes)
|
|
114
|
+
self.graph_data = {"nodes": enriched, "edges": edges}
|
|
115
|
+
self._http_post("/api/graph/push", {"nodes": enriched, "edges": edges})
|
|
116
|
+
|
|
117
|
+
def push_style(self):
|
|
118
|
+
if self._style is not None:
|
|
119
|
+
payload = self.style_config
|
|
120
|
+
self._http_post("/api/style/push", payload)
|
|
121
|
+
|
|
122
|
+
def push_customization(self):
|
|
123
|
+
payload = self._build_customization_payload()
|
|
124
|
+
if payload is not None:
|
|
125
|
+
self._http_post("/api/customize/push", payload)
|
|
126
|
+
|
|
127
|
+
def push_trace(self, node: str, duration: Optional[float] = None):
|
|
128
|
+
tid = self._next_id()
|
|
129
|
+
entry = {"id": tid, "node": node, "duration": duration}
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._trace_log.append(entry)
|
|
132
|
+
self._http_post("/api/trace/push", entry)
|
|
133
|
+
|
|
134
|
+
def _http_post(self, path: str, payload: dict):
|
|
135
|
+
for attempt in range(self.MAX_RETRIES):
|
|
136
|
+
try:
|
|
137
|
+
requests.post(
|
|
138
|
+
f"{self._url}{path}",
|
|
139
|
+
json=payload,
|
|
140
|
+
timeout=2,
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
except Exception:
|
|
144
|
+
if attempt < self.MAX_RETRIES - 1:
|
|
145
|
+
time.sleep(self.BACKOFF_BASE * (2 ** attempt))
|
|
146
|
+
|
|
147
|
+
def flush(self):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def close(self):
|
|
151
|
+
self._closed = True
|
agentviz/config.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Callable, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class NodeStyle:
|
|
7
|
+
fill: Optional[str] = None
|
|
8
|
+
stroke: Optional[str] = None
|
|
9
|
+
text_color: Optional[str] = None
|
|
10
|
+
stroke_width: Optional[float] = None
|
|
11
|
+
shape: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SectionVisibility:
|
|
16
|
+
graph: bool = True
|
|
17
|
+
nodes_table: bool = True
|
|
18
|
+
trace_panel: bool = True
|
|
19
|
+
header: bool = True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ThemeColors:
|
|
24
|
+
bg: Optional[str] = None
|
|
25
|
+
surface: Optional[str] = None
|
|
26
|
+
text: Optional[str] = None
|
|
27
|
+
accent: Optional[str] = None
|
|
28
|
+
border: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class VizStyle:
|
|
33
|
+
node_styles: dict[str, NodeStyle] = field(default_factory=dict)
|
|
34
|
+
sections: SectionVisibility = field(default_factory=SectionVisibility)
|
|
35
|
+
theme: ThemeColors = field(default_factory=ThemeColors)
|
|
36
|
+
title: str = "Agent Graph"
|
|
37
|
+
show_groups: bool = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class NodeCustomProps:
|
|
42
|
+
"""Rich per-node customization sent to the frontend renderer."""
|
|
43
|
+
width: Optional[int] = None
|
|
44
|
+
height: Optional[int] = None
|
|
45
|
+
icon: Optional[str] = None
|
|
46
|
+
css_class: Optional[str] = None
|
|
47
|
+
html_template: Optional[str] = None
|
|
48
|
+
metadata: dict = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NodeRenderer:
|
|
52
|
+
"""Callable-based Python node renderer.
|
|
53
|
+
|
|
54
|
+
Users implement ``render(self, node_id, node_data)`` to return a dict
|
|
55
|
+
of custom properties that override the default rendering for that node.
|
|
56
|
+
|
|
57
|
+
Returns a dict with any subset of keys from :class:`NodeCustomProps`.
|
|
58
|
+
Example::
|
|
59
|
+
|
|
60
|
+
class MyRenderer(NodeRenderer):
|
|
61
|
+
def render(self, node_id, node_data):
|
|
62
|
+
if node_id == "agent":
|
|
63
|
+
return {
|
|
64
|
+
"icon": "🤖",
|
|
65
|
+
"css_class": "custom-agent",
|
|
66
|
+
"width": 160,
|
|
67
|
+
"height": 60,
|
|
68
|
+
"metadata": {"custom_key": "value"},
|
|
69
|
+
}
|
|
70
|
+
return {}
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def render(self, node_id: str, node_data: dict) -> dict:
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class VizCustomization:
|
|
79
|
+
"""Full customization config that bridges Python and the frontend.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
node_renderer : NodeRenderer | None
|
|
84
|
+
Python-side renderer called for each node to compute custom props.
|
|
85
|
+
node_props : dict[str, NodeCustomProps]
|
|
86
|
+
Static per-node overrides (merged with renderer output).
|
|
87
|
+
custom_css : str | None
|
|
88
|
+
Raw CSS injected into the page via a ``<style>`` tag.
|
|
89
|
+
custom_js : str | None
|
|
90
|
+
Raw JS injected into the page via a ``<script>`` tag.
|
|
91
|
+
custom_html_header : str | None
|
|
92
|
+
HTML injected into ``<head>`` (useful for external CSS/JS links).
|
|
93
|
+
custom_html_body : str | None
|
|
94
|
+
HTML injected at the end of ``<body>``.
|
|
95
|
+
theme_colors : ThemeColors | None
|
|
96
|
+
Theme overrides.
|
|
97
|
+
title : str
|
|
98
|
+
Page and header title.
|
|
99
|
+
show_groups : bool
|
|
100
|
+
Whether to draw group containers in the SVG graph.
|
|
101
|
+
section_visibility : SectionVisibility | None
|
|
102
|
+
Which UI sections to show/hide.
|
|
103
|
+
"""
|
|
104
|
+
node_renderer: Optional[NodeRenderer] = None
|
|
105
|
+
node_props: dict[str, NodeCustomProps] = field(default_factory=dict)
|
|
106
|
+
custom_css: Optional[str] = None
|
|
107
|
+
custom_js: Optional[str] = None
|
|
108
|
+
custom_html_header: Optional[str] = None
|
|
109
|
+
custom_html_body: Optional[str] = None
|
|
110
|
+
theme_colors: Optional[ThemeColors] = None
|
|
111
|
+
title: str = "Agent Graph"
|
|
112
|
+
show_groups: bool = True
|
|
113
|
+
section_visibility: Optional[SectionVisibility] = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class VizConfig:
|
|
118
|
+
enable: bool = True
|
|
119
|
+
port: int = 8100
|
|
120
|
+
host: str = "0.0.0.0"
|
|
121
|
+
mount: bool = False
|
|
122
|
+
style: Optional[VizStyle] = None
|
|
123
|
+
customization: Optional[VizCustomization] = None
|
agentviz/server.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
9
|
+
|
|
10
|
+
from agentviz.client import VizClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _create_app(client: Optional[VizClient] = None) -> FastAPI:
|
|
17
|
+
app = FastAPI(title="Agent Viz", version="0.1.0")
|
|
18
|
+
_style_cache: dict = {}
|
|
19
|
+
_customize_cache: dict = {}
|
|
20
|
+
|
|
21
|
+
@app.get("/health")
|
|
22
|
+
async def health():
|
|
23
|
+
return {"status": "ok"}
|
|
24
|
+
|
|
25
|
+
@app.get("/api/config")
|
|
26
|
+
async def api_config():
|
|
27
|
+
customize = _customize_cache if _customize_cache else None
|
|
28
|
+
return {
|
|
29
|
+
"style": _style_cache if _style_cache else None,
|
|
30
|
+
"customize": customize,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@app.post("/api/style/push")
|
|
34
|
+
async def style_push(data: dict):
|
|
35
|
+
nonlocal _style_cache
|
|
36
|
+
_style_cache = data
|
|
37
|
+
return {"status": "ok"}
|
|
38
|
+
|
|
39
|
+
@app.post("/api/customize/push")
|
|
40
|
+
async def customize_push(data: dict):
|
|
41
|
+
nonlocal _customize_cache
|
|
42
|
+
_customize_cache = data
|
|
43
|
+
return {"status": "ok"}
|
|
44
|
+
|
|
45
|
+
@app.get("/api/graph")
|
|
46
|
+
async def api_graph():
|
|
47
|
+
if client is None or client.graph_data is None:
|
|
48
|
+
raise HTTPException(status_code=503, detail="No graph data available")
|
|
49
|
+
return client.graph_data
|
|
50
|
+
|
|
51
|
+
@app.get("/api/trace")
|
|
52
|
+
async def api_trace(after: int = 0):
|
|
53
|
+
if client is None:
|
|
54
|
+
return []
|
|
55
|
+
return [t for t in client.trace_log if t["id"] > after]
|
|
56
|
+
|
|
57
|
+
@app.post("/api/trace/push")
|
|
58
|
+
async def trace_push(entry: dict):
|
|
59
|
+
if client is not None:
|
|
60
|
+
with client._lock:
|
|
61
|
+
client._trace_log.append(entry)
|
|
62
|
+
return {"status": "ok"}
|
|
63
|
+
|
|
64
|
+
@app.post("/api/graph/push")
|
|
65
|
+
async def graph_push(data: dict):
|
|
66
|
+
if client is not None:
|
|
67
|
+
client.graph_data = data
|
|
68
|
+
return {"status": "ok"}
|
|
69
|
+
|
|
70
|
+
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
|
71
|
+
|
|
72
|
+
@app.get("/graph")
|
|
73
|
+
async def graph_page():
|
|
74
|
+
html_path = os.path.join(_static_dir, "graph.html")
|
|
75
|
+
return FileResponse(html_path)
|
|
76
|
+
|
|
77
|
+
return app
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def mount_app(client: Optional[VizClient] = None) -> FastAPI:
|
|
81
|
+
return _create_app(client)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _run_server(config, client: Optional[VizClient] = None):
|
|
85
|
+
import uvicorn
|
|
86
|
+
|
|
87
|
+
app = _create_app(client)
|
|
88
|
+
|
|
89
|
+
cfg = uvicorn.Config(
|
|
90
|
+
app,
|
|
91
|
+
host=config.host,
|
|
92
|
+
port=config.port,
|
|
93
|
+
log_level="warning",
|
|
94
|
+
loop="asyncio",
|
|
95
|
+
)
|
|
96
|
+
server = uvicorn.Server(cfg)
|
|
97
|
+
|
|
98
|
+
def _run():
|
|
99
|
+
server.run()
|
|
100
|
+
|
|
101
|
+
thread = threading.Thread(target=_run, daemon=True)
|
|
102
|
+
thread.start()
|
|
103
|
+
time.sleep(0.5)
|
|
104
|
+
return server
|