From 81f0d5881bf982c2c8a0492e9f595af5bbbe59db Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Thu, 21 May 2026 11:07:04 -0700 Subject: [PATCH 01/18] feat(cli): add 'dimos graph' subcommand to render Blueprint diagrams --- dimos/robot/cli/dimos.py | 21 ++ dimos/utils/cli/graph.py | 622 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 643 insertions(+) create mode 100644 dimos/utils/cli/graph.py diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 77902a30fe..6c163098a2 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -795,6 +795,27 @@ def apriltag( typer.echo(f"Wrote {len(id_list)} tag(s) to {path}") +@main.command() +def graph( + python_file: str = typer.Argument(..., help="Python file containing Blueprint globals"), + no_disconnected: bool = typer.Option( + False, "--no-disconnected", help="Hide disconnected streams" + ), + port: int = typer.Option(0, "--port", help="HTTP server port (0 = random free port)"), + markdown: bool = typer.Option( + False, "--markdown", help="Print Mermaid markdown to stdout and exit" + ), +) -> None: + """Render DimOS Blueprint graphs as Mermaid diagrams in the browser.""" + from dimos.utils.cli.graph import print_markdown, serve_graph + + show_disconnected = not no_disconnected + if markdown: + print_markdown(python_file, show_disconnected=show_disconnected) + return + serve_graph(python_file, show_disconnected=show_disconnected, port=port) + + @main.command(name="rerun-bridge") def rerun_bridge_cmd( memory_limit: str = typer.Option( diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py new file mode 100644 index 0000000000..bf0fcbb725 --- /dev/null +++ b/dimos/utils/cli/graph.py @@ -0,0 +1,622 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Render DimOS Blueprint graphs as Mermaid diagrams in the browser. + +Loads Blueprint instances defined as module-level variables in a Python file +and serves an interactive Mermaid flowchart per blueprint. +""" + +from __future__ import annotations + +from collections import defaultdict +from http.server import BaseHTTPRequestHandler, HTTPServer +import importlib.util +import json +import os +import sys +import webbrowser + +from dimos.core.coordination.blueprints import Blueprint +from dimos.core.module import ModuleBase + +THEMES: dict[str, dict[str, list[str]]] = { + "tailwind": { + "nodes": [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#8b5cf6", + "#f97316", + "#06b6d4", + "#ec4899", + "#6366f1", + "#eab308", + "#14b8a6", + "#f43f5e", + "#84cc16", + "#0ea5e9", + "#d946ef", + "#10b981", + "#a855f7", + "#f59e0b", + "#38bdf8", + "#fb7185", + "#a3e635", + ], + "edges": [ + "#60a5fa", + "#f87171", + "#4ade80", + "#a78bfa", + "#fb923c", + "#22d3ee", + "#f472b6", + "#818cf8", + "#facc15", + "#2dd4bf", + "#fb7185", + "#a3e635", + "#38bdf8", + "#e879f9", + "#34d399", + "#c084fc", + "#fbbf24", + "#67e8f9", + "#fda4af", + "#bef264", + ], + }, +} + +DEFAULT_THEME = "tailwind" + +DEFAULT_IGNORED_CONNECTIONS: set[tuple[str, str]] = set() +DEFAULT_IGNORED_MODULES = {"WebsocketVisModule"} +_COMPACT_ONLY_IGNORED_MODULES = {"WebsocketVisModule"} + + +class _ColorAssigner: + def __init__(self, palette: list[str]) -> None: + self._palette = palette + self._assigned: dict[str, str] = {} + self._next = 0 + + def __call__(self, key: str) -> str: + if key not in self._assigned: + self._assigned[key] = self._palette[self._next % len(self._palette)] + self._next += 1 + return self._assigned[key] + + +def _mermaid_id(name: str) -> str: + return name.replace(" ", "_").replace("-", "_") + + +def _find_package_root(filepath: str) -> str | None: + directory = os.path.dirname(filepath) + root = None + while os.path.isfile(os.path.join(directory, "__init__.py")): + root = directory + parent = os.path.dirname(directory) + if parent == directory: + break + directory = parent + if root is not None: + return os.path.dirname(root) + return None + + +def _load_blueprints(python_file: str) -> list[tuple[str, Blueprint]]: + filepath = os.path.abspath(python_file) + if not os.path.isfile(filepath): + raise FileNotFoundError(filepath) + + pkg_root = _find_package_root(filepath) + if pkg_root and pkg_root not in sys.path: + sys.path.insert(0, pkg_root) + + spec = importlib.util.spec_from_file_location("_render_target", filepath) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load {filepath}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + blueprints: list[tuple[str, Blueprint]] = [] + for name, obj in vars(module).items(): + if name.startswith("_"): + continue + if isinstance(obj, Blueprint): + blueprints.append((name, obj)) + + if not blueprints: + raise RuntimeError("No Blueprint instances found in module globals.") + + blueprints.reverse() + print(f"Found {len(blueprints)} blueprint(s): {', '.join(n for n, _ in blueprints)}") + return blueprints + + +def _render_mermaid( + blueprint_set: Blueprint, + *, + ignored_streams: set[tuple[str, str]] | None = None, + ignored_modules: set[str] | None = None, + show_disconnected: bool = False, + theme: str = DEFAULT_THEME, +) -> tuple[str, dict[str, str], set[str]]: + """Generate a Mermaid flowchart from a Blueprint. + + Returns (mermaid_code, label_color_map, disconnected_labels). + """ + if ignored_streams is None: + ignored_streams = DEFAULT_IGNORED_CONNECTIONS + if ignored_modules is None: + if show_disconnected: + ignored_modules = DEFAULT_IGNORED_MODULES - _COMPACT_ONLY_IGNORED_MODULES + else: + ignored_modules = DEFAULT_IGNORED_MODULES + + producers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) + consumers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) + module_names: set[str] = set() + + for bp in blueprint_set.blueprints: + if bp.module.__name__ in ignored_modules: + continue + module_names.add(bp.module.__name__) + for conn in bp.streams: + remapped_name = blueprint_set.remapping_map.get((bp.module, conn.name), conn.name) + if not isinstance(remapped_name, str): + continue + key = (remapped_name, conn.type) + if conn.direction == "out": + producers[key].append(bp.module) + else: + consumers[key].append(bp.module) + + active_keys: list[tuple[str, type]] = [] + for key in producers: + name, type_ = key + if key not in consumers: + continue + if (name, type_.__name__) in ignored_streams: + continue + valid_p = [m for m in producers[key] if m.__name__ not in ignored_modules] + valid_c = [m for m in consumers[key] if m.__name__ not in ignored_modules] + if valid_p and valid_c: + active_keys.append(key) + + disconnected_keys: list[tuple[str, type]] = [] + if show_disconnected: + all_keys = set(producers.keys()) | set(consumers.keys()) + for key in all_keys: + if key in active_keys: + continue + name, type_ = key + if (name, type_.__name__) in ignored_streams: + continue + relevant = producers.get(key, []) + consumers.get(key, []) + if all(m.__name__ in ignored_modules for m in relevant): + continue + disconnected_keys.append(key) + + palette = THEMES.get(theme, THEMES[DEFAULT_THEME]) + node_color = _ColorAssigner(palette["nodes"]) + edge_color = _ColorAssigner(palette["edges"]) + + lines = ["graph LR"] + + sorted_modules = sorted(module_names) + for module_name in sorted_modules: + mermaid_id = _mermaid_id(module_name) + lines.append(f" {mermaid_id}([{module_name}]):::moduleNode") + + lines.append("") + + edge_idx = 0 + edge_colors: list[str] = [] + label_color_map: dict[str, str] = {} + stream_node_ids: dict[str, str] = {} + disconnected_labels: set[str] = set() + + lines.append(" %% Stream nodes and edges") + for key in sorted(active_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): + name, type_ = key + label = f"{name}:{type_.__name__}" + color = edge_color(label) + label_color_map[label] = color + + valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules] + valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules] + + for prod in valid_producers: + stream_node_id = _mermaid_id(f"{prod.__name__}_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + + producer_id = _mermaid_id(prod.__name__) + lines.append(f" {producer_id} --- {stream_node_id}") + edge_colors.append(node_color(prod.__name__)) + edge_idx += 1 + + for cons in valid_consumers: + consumer_id = _mermaid_id(cons.__name__) + lines.append(f" {stream_node_id} --> {consumer_id}") + edge_colors.append(color) + edge_idx += 1 + + if disconnected_keys: + lines.append("") + lines.append(" %% Disconnected streams") + for key in sorted(disconnected_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): + name, type_ = key + label = f"{name}:{type_.__name__}" + color = edge_color(label) + label_color_map[label] = color + disconnected_labels.add(label) + + for prod in producers.get(key, []): + if prod.__name__ in ignored_modules: + continue + stream_node_id = _mermaid_id(f"{prod.__name__}_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + producer_id = _mermaid_id(prod.__name__) + lines.append(f" {producer_id} -.- {stream_node_id}") + edge_colors.append(node_color(prod.__name__)) + edge_idx += 1 + + for cons in consumers.get(key, []): + if cons.__name__ in ignored_modules: + continue + stream_node_id = _mermaid_id(f"dangling_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + consumer_id = _mermaid_id(cons.__name__) + lines.append(f" {stream_node_id} -.-> {consumer_id}") + edge_colors.append(color) + edge_idx += 1 + + lines.append("") + for module_name in sorted_modules: + mermaid_id = _mermaid_id(module_name) + color = node_color(module_name) + lines.append( + f" style {mermaid_id} fill:{color}bf,stroke:{color},color:#eee,stroke-width:2px" + ) + + for stream_node_id, color in stream_node_ids.items(): + lines.append( + f" style {stream_node_id} fill:transparent,stroke:{color},color:{color},stroke-width:1px" + ) + + if edge_colors: + lines.append("") + for i, color in enumerate(edge_colors): + lines.append(f" linkStyle {i} stroke:{color},stroke-width:2px") + + return "\n".join(lines), label_color_map, disconnected_labels + + +def _build_html(python_file: str, *, show_disconnected: bool = True) -> str: + blueprints = _load_blueprints(python_file) + + per_bp_label_colors: list[dict[str, str]] = [] + per_bp_disconnected: list[set[str]] = [] + + tab_buttons = [] + tab_panels = [] + for idx, (name, bp) in enumerate(blueprints): + mermaid_code, label_colors, disconnected = _render_mermaid( + bp, show_disconnected=show_disconnected + ) + per_bp_label_colors.append(label_colors) + per_bp_disconnected.append(disconnected) + + active_cls = " active" if idx == 0 else "" + tab_buttons.append(f'') + tab_panels.append( + f'
' + f'
' + f'
\n{mermaid_code}\n
' + f"
" + ) + + all_label_colors_json = json.dumps(per_bp_label_colors) + all_disconnected_json = json.dumps([sorted(d) for d in per_bp_disconnected]) + + tab_bar_html = "" + if len(blueprints) > 1: + tab_bar_html = f'
{"".join(tab_buttons)}
' + + return f"""\ + + + +Blueprint Diagrams + + +{tab_bar_html} +{"".join(tab_panels)} +
+ + + +
+ +""" + + +def print_markdown(python_file: str, *, show_disconnected: bool) -> None: + blueprints = _load_blueprints(python_file) + sections: list[str] = [] + for name, bp in blueprints: + mermaid_code, _, _ = _render_mermaid(bp, show_disconnected=show_disconnected) + sections.append(f"## {name}\n\n```mermaid\n{mermaid_code}\n```") + print("\n\n".join(sections)) + + +def serve_graph(python_file: str, *, show_disconnected: bool, port: int) -> None: + html = _build_html(python_file, show_disconnected=show_disconnected) + html_bytes = html.encode("utf-8") + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html_bytes))) + self.end_headers() + self.wfile.write(html_bytes) + + def log_message(self, format: str, *args: object) -> None: + pass + + server = HTTPServer(("0.0.0.0", port), Handler) + actual_port = server.server_address[1] + url = f"http://localhost:{actual_port}" + print(f"Serving at {url} (will exit after first request)") + webbrowser.open(url) + server.handle_request() + print("Served. Exiting.") From 482d7adb968fba3f689fa715a8897f2bb9bf593c Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Thu, 21 May 2026 13:06:34 -0700 Subject: [PATCH 02/18] review: address greptile on dimos/utils/cli/graph.py:611 Non-root requests (e.g. /favicon.ico) now return 204 instead of terminating the single-request server with the HTML payload. --- dimos/utils/cli/graph.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index bf0fcbb725..a7f541d90b 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -604,6 +604,10 @@ def serve_graph(python_file: str, *, show_disconnected: bool, port: int) -> None class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: + if self.path not in ("/", ""): + self.send_response(204) + self.end_headers() + return self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(html_bytes))) From ddb68568b33fe39fc8576ad03e37e953fb85e1e7 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Thu, 21 May 2026 13:44:24 -0700 Subject: [PATCH 03/18] review: address greptile on dimos/utils/cli/graph.py:147 Route the diagnostic 'Found N blueprint(s)' message to stderr so piping '--markdown' output to a file doesn't get the message injected into the markdown stream. --- dimos/utils/cli/graph.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index a7f541d90b..f8b395c0db 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -144,7 +144,10 @@ def _load_blueprints(python_file: str) -> list[tuple[str, Blueprint]]: raise RuntimeError("No Blueprint instances found in module globals.") blueprints.reverse() - print(f"Found {len(blueprints)} blueprint(s): {', '.join(n for n, _ in blueprints)}") + print( + f"Found {len(blueprints)} blueprint(s): {', '.join(n for n, _ in blueprints)}", + file=sys.stderr, + ) return blueprints From f87845e3f5698f2645272b63b822ac5a25cb255e Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Thu, 21 May 2026 13:54:45 -0700 Subject: [PATCH 04/18] review: address greptile on dimos/utils/cli/graph.py:623 --- dimos/utils/cli/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index f8b395c0db..a1a2d31cdd 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -620,7 +620,7 @@ def do_GET(self) -> None: def log_message(self, format: str, *args: object) -> None: pass - server = HTTPServer(("0.0.0.0", port), Handler) + server = HTTPServer(("127.0.0.1", port), Handler) actual_port = server.server_address[1] url = f"http://localhost:{actual_port}" print(f"Serving at {url} (will exit after first request)") From bcb896d8ef41fcdf47b8e6f0553091cf264a1391 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 12:50:09 -0700 Subject: [PATCH 05/18] fix zoom issue --- dimos/utils/cli/graph.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index bf0fcbb725..9070b12570 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -540,31 +540,33 @@ def _build_html(python_file: str, *, show_disconnected: bool = True) -> str: }}); vp._fitToView = fitToView; - - document.getElementById('zoomIn').addEventListener('click', () => {{ - const rect = vp.getBoundingClientRect(); - const cx = rect.width / 2, cy = rect.height / 2; - const newScale = Math.min(scale * 1.3, 50); - panX = cx - (cx - panX) * (newScale / scale); - panY = cy - (cy - panY) * (newScale / scale); - scale = newScale; apply(); - }}); - document.getElementById('zoomOut').addEventListener('click', () => {{ + vp._zoomBy = (factor) => {{ const rect = vp.getBoundingClientRect(); const cx = rect.width / 2, cy = rect.height / 2; - const newScale = Math.max(scale / 1.3, 0.05); + const newScale = Math.min(Math.max(scale * factor, 0.05), 50); panX = cx - (cx - panX) * (newScale / scale); panY = cy - (cy - panY) * (newScale / scale); scale = newScale; apply(); - }}); - document.getElementById('resetView').addEventListener('click', () => {{ - fitToView(); - }}); + }}; }} +let activeViewport = null; document.querySelectorAll('.tab-panel').forEach((panel, idx) => {{ const vp = panel.querySelector('.viewport'); - if (vp) setupViewport(vp, allLabelColors[idx] || {{}}, allDisconnected[idx] || []); + if (vp) {{ + setupViewport(vp, allLabelColors[idx] || {{}}, allDisconnected[idx] || []); + if (panel.classList.contains('active')) activeViewport = vp; + }} +}}); + +document.getElementById('zoomIn').addEventListener('click', () => {{ + if (activeViewport?._zoomBy) activeViewport._zoomBy(1.3); +}}); +document.getElementById('zoomOut').addEventListener('click', () => {{ + if (activeViewport?._zoomBy) activeViewport._zoomBy(1 / 1.3); +}}); +document.getElementById('resetView').addEventListener('click', () => {{ + if (activeViewport?._fitToView) activeViewport._fitToView(); }}); document.querySelectorAll('.tab-panel:not(.active)').forEach(p => p.classList.add('hidden')); @@ -582,7 +584,10 @@ def _build_html(python_file: str, *, show_disconnected: bool = True) -> str: panel.classList.add('active'); panel.classList.remove('hidden'); const vp = panel.querySelector('.viewport'); - if (vp && vp._fitToView) setTimeout(() => vp._fitToView(), 0); + if (vp) {{ + activeViewport = vp; + if (vp._fitToView) setTimeout(() => vp._fitToView(), 0); + }} }}); }}); From ad522522df5a33c33b2110c3ad8ac75b7b7ba24f Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 13:23:38 -0700 Subject: [PATCH 06/18] fix(cli): sanitize all non-alphanumeric chars in Mermaid node IDs Stream names with characters like [ ] ( ) . would produce invalid Mermaid syntax and break diagram rendering. --- dimos/utils/cli/graph.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index 12c75cfd01..96c06a5c71 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -25,6 +25,7 @@ import importlib.util import json import os +import re import sys import webbrowser @@ -100,8 +101,11 @@ def __call__(self, key: str) -> str: return self._assigned[key] +_MERMAID_SAFE = re.compile(r"[^A-Za-z0-9_]") + + def _mermaid_id(name: str) -> str: - return name.replace(" ", "_").replace("-", "_") + return _MERMAID_SAFE.sub("_", name) def _find_package_root(filepath: str) -> str | None: From 9e7d5a11ce794d41dd3501b0f99397d8e73234ff Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 14:23:42 -0700 Subject: [PATCH 07/18] refactor(cli): extract Mermaid renderer to core.introspection.blueprint.mermaid Move render_mermaid, themes, and color logic out of the CLI module into a reusable location. Add ocean/ember/forest/light themes with per-theme background colors, wire --theme through the CLI, and fix stream resolution for files using `from __future__ import annotations`. --- dimos/core/introspection/blueprint/mermaid.py | 369 ++++++++++++++++++ dimos/robot/cli/dimos.py | 7 +- dimos/utils/cli/graph.py | 336 ++++------------ 3 files changed, 446 insertions(+), 266 deletions(-) create mode 100644 dimos/core/introspection/blueprint/mermaid.py diff --git a/dimos/core/introspection/blueprint/mermaid.py b/dimos/core/introspection/blueprint/mermaid.py new file mode 100644 index 0000000000..a4a08bba8b --- /dev/null +++ b/dimos/core/introspection/blueprint/mermaid.py @@ -0,0 +1,369 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Mermaid flowchart renderer for Blueprint visualization.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from dimos.core.coordination.blueprints import Blueprint +from dimos.core.introspection.utils import sanitize_id +from dimos.core.module import ModuleBase + +Theme = dict[str, Any] + +THEMES: dict[str, Theme] = { + "tailwind": { + "background": "#1e1e1e", + "mermaid_theme": "dark", + "nodes": [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#8b5cf6", + "#f97316", + "#06b6d4", + "#ec4899", + "#6366f1", + "#eab308", + "#14b8a6", + "#f43f5e", + "#84cc16", + "#0ea5e9", + "#d946ef", + "#10b981", + "#a855f7", + "#f59e0b", + "#38bdf8", + "#fb7185", + "#a3e635", + ], + "edges": [ + "#60a5fa", + "#f87171", + "#4ade80", + "#a78bfa", + "#fb923c", + "#22d3ee", + "#f472b6", + "#818cf8", + "#facc15", + "#2dd4bf", + "#fb7185", + "#a3e635", + "#38bdf8", + "#e879f9", + "#34d399", + "#c084fc", + "#fbbf24", + "#67e8f9", + "#fda4af", + "#bef264", + ], + }, + "ocean": { + "background": "#0f172a", + "mermaid_theme": "dark", + "nodes": [ + "#38bdf8", + "#818cf8", + "#2dd4bf", + "#a78bfa", + "#67e8f9", + "#c084fc", + "#5eead4", + "#93c5fd", + "#7dd3fc", + "#6366f1", + ], + "edges": [ + "#7dd3fc", + "#a5b4fc", + "#99f6e4", + "#c4b5fd", + "#a5f3fc", + "#ddd6fe", + "#6ee7b7", + "#bfdbfe", + "#bae6fd", + "#a5b4fc", + ], + }, + "ember": { + "background": "#1c1210", + "mermaid_theme": "dark", + "nodes": [ + "#ef4444", + "#f97316", + "#eab308", + "#f59e0b", + "#fb923c", + "#fbbf24", + "#f87171", + "#facc15", + "#fb7185", + "#fca5a5", + ], + "edges": [ + "#fca5a5", + "#fdba74", + "#fde047", + "#fcd34d", + "#fed7aa", + "#fef08a", + "#fecaca", + "#fef9c3", + "#fda4af", + "#fecdd3", + ], + }, + "forest": { + "background": "#0f1a14", + "mermaid_theme": "dark", + "nodes": [ + "#22c55e", + "#14b8a6", + "#84cc16", + "#10b981", + "#a3e635", + "#34d399", + "#4ade80", + "#2dd4bf", + "#86efac", + "#6ee7b7", + ], + "edges": [ + "#86efac", + "#5eead4", + "#bef264", + "#6ee7b7", + "#d9f99d", + "#99f6e4", + "#bbf7d0", + "#a7f3d0", + "#ecfccb", + "#ccfbf1", + ], + }, + "light": { + "background": "#f8fafc", + "mermaid_theme": "default", + "nodes": [ + "#2563eb", + "#dc2626", + "#16a34a", + "#7c3aed", + "#ea580c", + "#0891b2", + "#db2777", + "#4f46e5", + "#ca8a04", + "#0d9488", + ], + "edges": [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#8b5cf6", + "#f97316", + "#06b6d4", + "#ec4899", + "#6366f1", + "#eab308", + "#14b8a6", + ], + }, +} + +DEFAULT_THEME = "tailwind" + +DEFAULT_IGNORED_CONNECTIONS: set[tuple[str, str]] = set() +DEFAULT_IGNORED_MODULES: set[str] = set() + + +class _ColorAssigner: + def __init__(self, palette: list[str]) -> None: + self._palette = palette + self._assigned: dict[str, str] = {} + self._next = 0 + + def __call__(self, key: str) -> str: + if key not in self._assigned: + self._assigned[key] = self._palette[self._next % len(self._palette)] + self._next += 1 + return self._assigned[key] + + +def render_mermaid( + blueprint_set: Blueprint, + *, + ignored_streams: set[tuple[str, str]] | None = None, + ignored_modules: set[str] | None = None, + show_disconnected: bool = False, + theme: str = DEFAULT_THEME, +) -> tuple[str, dict[str, str], set[str]]: + """Generate a Mermaid flowchart from a Blueprint. + + Returns (mermaid_code, label_color_map, disconnected_labels). + """ + if ignored_streams is None: + ignored_streams = DEFAULT_IGNORED_CONNECTIONS + if ignored_modules is None: + ignored_modules = DEFAULT_IGNORED_MODULES + + producers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) + consumers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) + module_names: set[str] = set() + + for bp in blueprint_set.blueprints: + if bp.module.__name__ in ignored_modules: + continue + module_names.add(bp.module.__name__) + for conn in bp.streams: + remapped_name = blueprint_set.remapping_map.get((bp.module, conn.name), conn.name) + if not isinstance(remapped_name, str): + continue + key = (remapped_name, conn.type) + if conn.direction == "out": + producers[key].append(bp.module) + else: + consumers[key].append(bp.module) + + active_keys: list[tuple[str, type]] = [] + for key in producers: + name, type_ = key + if key not in consumers: + continue + if (name, type_.__name__) in ignored_streams: + continue + valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules] + valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules] + if valid_producers and valid_consumers: + active_keys.append(key) + + disconnected_keys: list[tuple[str, type]] = [] + if show_disconnected: + all_keys = set(producers.keys()) | set(consumers.keys()) + for key in all_keys: + if key in active_keys: + continue + name, type_ = key + if (name, type_.__name__) in ignored_streams: + continue + relevant = producers.get(key, []) + consumers.get(key, []) + if all(m.__name__ in ignored_modules for m in relevant): + continue + disconnected_keys.append(key) + + palette = THEMES.get(theme, THEMES[DEFAULT_THEME]) + node_color = _ColorAssigner(palette["nodes"]) + edge_color = _ColorAssigner(palette["edges"]) + + lines = ["graph LR"] + + sorted_modules = sorted(module_names) + for module_name in sorted_modules: + mermaid_id = sanitize_id(module_name) + lines.append(f" {mermaid_id}([{module_name}]):::moduleNode") + + lines.append("") + + edge_idx = 0 + edge_colors: list[str] = [] + label_color_map: dict[str, str] = {} + stream_node_ids: dict[str, str] = {} + disconnected_labels: set[str] = set() + + lines.append(" %% Stream nodes and edges") + for key in sorted(active_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): + name, type_ = key + label = f"{name}:{type_.__name__}" + color = edge_color(label) + label_color_map[label] = color + + valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules] + valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules] + + for prod in valid_producers: + stream_node_id = sanitize_id(f"{prod.__name__}_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + + producer_id = sanitize_id(prod.__name__) + lines.append(f" {producer_id} --- {stream_node_id}") + edge_colors.append(node_color(prod.__name__)) + edge_idx += 1 + + for cons in valid_consumers: + consumer_id = sanitize_id(cons.__name__) + lines.append(f" {stream_node_id} --> {consumer_id}") + edge_colors.append(color) + edge_idx += 1 + + if disconnected_keys: + lines.append("") + lines.append(" %% Disconnected streams") + for key in sorted(disconnected_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): + name, type_ = key + label = f"{name}:{type_.__name__}" + color = edge_color(label) + label_color_map[label] = color + disconnected_labels.add(label) + + for prod in producers.get(key, []): + if prod.__name__ in ignored_modules: + continue + stream_node_id = sanitize_id(f"{prod.__name__}_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + producer_id = sanitize_id(prod.__name__) + lines.append(f" {producer_id} -.- {stream_node_id}") + edge_colors.append(node_color(prod.__name__)) + edge_idx += 1 + + for cons in consumers.get(key, []): + if cons.__name__ in ignored_modules: + continue + stream_node_id = sanitize_id(f"dangling_{name}_{type_.__name__}") + if stream_node_id not in stream_node_ids: + lines.append(f" {stream_node_id}[{label}]:::streamNode") + stream_node_ids[stream_node_id] = color + consumer_id = sanitize_id(cons.__name__) + lines.append(f" {stream_node_id} -.-> {consumer_id}") + edge_colors.append(color) + edge_idx += 1 + + lines.append("") + for module_name in sorted_modules: + mermaid_id = sanitize_id(module_name) + color = node_color(module_name) + lines.append( + f" style {mermaid_id} fill:{color}bf,stroke:{color},color:#eee,stroke-width:2px" + ) + + for stream_node_id, color in stream_node_ids.items(): + lines.append( + f" style {stream_node_id} fill:transparent,stroke:{color},color:{color},stroke-width:1px" + ) + + if edge_colors: + lines.append("") + for i, color in enumerate(edge_colors): + lines.append(f" linkStyle {i} stroke:{color},stroke-width:2px") + + return "\n".join(lines), label_color_map, disconnected_labels diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index e0ed1c6d9d..03511d0abe 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -796,15 +796,18 @@ def graph( markdown: bool = typer.Option( False, "--markdown", help="Print Mermaid markdown to stdout and exit" ), + theme: str = typer.Option( + "tailwind", "--theme", help="Color theme (tailwind, ocean, ember, forest, light)" + ), ) -> None: """Render DimOS Blueprint graphs as Mermaid diagrams in the browser.""" from dimos.utils.cli.graph import print_markdown, serve_graph show_disconnected = not no_disconnected if markdown: - print_markdown(python_file, show_disconnected=show_disconnected) + print_markdown(python_file, show_disconnected=show_disconnected, theme=theme) return - serve_graph(python_file, show_disconnected=show_disconnected, port=port) + serve_graph(python_file, show_disconnected=show_disconnected, port=port, theme=theme) @main.command(name="rerun-bridge") diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index 96c06a5c71..322568c004 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -20,92 +20,15 @@ from __future__ import annotations -from collections import defaultdict from http.server import BaseHTTPRequestHandler, HTTPServer import importlib.util import json import os -import re import sys import webbrowser from dimos.core.coordination.blueprints import Blueprint -from dimos.core.module import ModuleBase - -THEMES: dict[str, dict[str, list[str]]] = { - "tailwind": { - "nodes": [ - "#3b82f6", - "#ef4444", - "#22c55e", - "#8b5cf6", - "#f97316", - "#06b6d4", - "#ec4899", - "#6366f1", - "#eab308", - "#14b8a6", - "#f43f5e", - "#84cc16", - "#0ea5e9", - "#d946ef", - "#10b981", - "#a855f7", - "#f59e0b", - "#38bdf8", - "#fb7185", - "#a3e635", - ], - "edges": [ - "#60a5fa", - "#f87171", - "#4ade80", - "#a78bfa", - "#fb923c", - "#22d3ee", - "#f472b6", - "#818cf8", - "#facc15", - "#2dd4bf", - "#fb7185", - "#a3e635", - "#38bdf8", - "#e879f9", - "#34d399", - "#c084fc", - "#fbbf24", - "#67e8f9", - "#fda4af", - "#bef264", - ], - }, -} - -DEFAULT_THEME = "tailwind" - -DEFAULT_IGNORED_CONNECTIONS: set[tuple[str, str]] = set() -DEFAULT_IGNORED_MODULES = {"WebsocketVisModule"} -_COMPACT_ONLY_IGNORED_MODULES = {"WebsocketVisModule"} - - -class _ColorAssigner: - def __init__(self, palette: list[str]) -> None: - self._palette = palette - self._assigned: dict[str, str] = {} - self._next = 0 - - def __call__(self, key: str) -> str: - if key not in self._assigned: - self._assigned[key] = self._palette[self._next % len(self._palette)] - self._next += 1 - return self._assigned[key] - - -_MERMAID_SAFE = re.compile(r"[^A-Za-z0-9_]") - - -def _mermaid_id(name: str) -> str: - return _MERMAID_SAFE.sub("_", name) +from dimos.core.introspection.blueprint.mermaid import DEFAULT_THEME, THEMES, render_mermaid def _find_package_root(filepath: str) -> str | None: @@ -135,6 +58,7 @@ def _load_blueprints(python_file: str) -> list[tuple[str, Blueprint]]: if spec is None or spec.loader is None: raise RuntimeError(f"Could not load {filepath}") module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module spec.loader.exec_module(module) blueprints: list[tuple[str, Blueprint]] = [] @@ -155,173 +79,24 @@ def _load_blueprints(python_file: str) -> list[tuple[str, Blueprint]]: return blueprints -def _render_mermaid( - blueprint_set: Blueprint, - *, - ignored_streams: set[tuple[str, str]] | None = None, - ignored_modules: set[str] | None = None, - show_disconnected: bool = False, - theme: str = DEFAULT_THEME, -) -> tuple[str, dict[str, str], set[str]]: - """Generate a Mermaid flowchart from a Blueprint. - - Returns (mermaid_code, label_color_map, disconnected_labels). - """ - if ignored_streams is None: - ignored_streams = DEFAULT_IGNORED_CONNECTIONS - if ignored_modules is None: - if show_disconnected: - ignored_modules = DEFAULT_IGNORED_MODULES - _COMPACT_ONLY_IGNORED_MODULES - else: - ignored_modules = DEFAULT_IGNORED_MODULES - - producers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) - consumers: dict[tuple[str, type], list[type[ModuleBase]]] = defaultdict(list) - module_names: set[str] = set() - - for bp in blueprint_set.blueprints: - if bp.module.__name__ in ignored_modules: - continue - module_names.add(bp.module.__name__) - for conn in bp.streams: - remapped_name = blueprint_set.remapping_map.get((bp.module, conn.name), conn.name) - if not isinstance(remapped_name, str): - continue - key = (remapped_name, conn.type) - if conn.direction == "out": - producers[key].append(bp.module) - else: - consumers[key].append(bp.module) - - active_keys: list[tuple[str, type]] = [] - for key in producers: - name, type_ = key - if key not in consumers: - continue - if (name, type_.__name__) in ignored_streams: - continue - valid_p = [m for m in producers[key] if m.__name__ not in ignored_modules] - valid_c = [m for m in consumers[key] if m.__name__ not in ignored_modules] - if valid_p and valid_c: - active_keys.append(key) - - disconnected_keys: list[tuple[str, type]] = [] - if show_disconnected: - all_keys = set(producers.keys()) | set(consumers.keys()) - for key in all_keys: - if key in active_keys: - continue - name, type_ = key - if (name, type_.__name__) in ignored_streams: - continue - relevant = producers.get(key, []) + consumers.get(key, []) - if all(m.__name__ in ignored_modules for m in relevant): - continue - disconnected_keys.append(key) - - palette = THEMES.get(theme, THEMES[DEFAULT_THEME]) - node_color = _ColorAssigner(palette["nodes"]) - edge_color = _ColorAssigner(palette["edges"]) - - lines = ["graph LR"] - - sorted_modules = sorted(module_names) - for module_name in sorted_modules: - mermaid_id = _mermaid_id(module_name) - lines.append(f" {mermaid_id}([{module_name}]):::moduleNode") - - lines.append("") - - edge_idx = 0 - edge_colors: list[str] = [] - label_color_map: dict[str, str] = {} - stream_node_ids: dict[str, str] = {} - disconnected_labels: set[str] = set() - - lines.append(" %% Stream nodes and edges") - for key in sorted(active_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): - name, type_ = key - label = f"{name}:{type_.__name__}" - color = edge_color(label) - label_color_map[label] = color - - valid_producers = [m for m in producers[key] if m.__name__ not in ignored_modules] - valid_consumers = [m for m in consumers[key] if m.__name__ not in ignored_modules] - - for prod in valid_producers: - stream_node_id = _mermaid_id(f"{prod.__name__}_{name}_{type_.__name__}") - if stream_node_id not in stream_node_ids: - lines.append(f" {stream_node_id}[{label}]:::streamNode") - stream_node_ids[stream_node_id] = color - - producer_id = _mermaid_id(prod.__name__) - lines.append(f" {producer_id} --- {stream_node_id}") - edge_colors.append(node_color(prod.__name__)) - edge_idx += 1 - - for cons in valid_consumers: - consumer_id = _mermaid_id(cons.__name__) - lines.append(f" {stream_node_id} --> {consumer_id}") - edge_colors.append(color) - edge_idx += 1 - - if disconnected_keys: - lines.append("") - lines.append(" %% Disconnected streams") - for key in sorted(disconnected_keys, key=lambda k: f"{k[0]}:{k[1].__name__}"): - name, type_ = key - label = f"{name}:{type_.__name__}" - color = edge_color(label) - label_color_map[label] = color - disconnected_labels.add(label) - - for prod in producers.get(key, []): - if prod.__name__ in ignored_modules: - continue - stream_node_id = _mermaid_id(f"{prod.__name__}_{name}_{type_.__name__}") - if stream_node_id not in stream_node_ids: - lines.append(f" {stream_node_id}[{label}]:::streamNode") - stream_node_ids[stream_node_id] = color - producer_id = _mermaid_id(prod.__name__) - lines.append(f" {producer_id} -.- {stream_node_id}") - edge_colors.append(node_color(prod.__name__)) - edge_idx += 1 - - for cons in consumers.get(key, []): - if cons.__name__ in ignored_modules: - continue - stream_node_id = _mermaid_id(f"dangling_{name}_{type_.__name__}") - if stream_node_id not in stream_node_ids: - lines.append(f" {stream_node_id}[{label}]:::streamNode") - stream_node_ids[stream_node_id] = color - consumer_id = _mermaid_id(cons.__name__) - lines.append(f" {stream_node_id} -.-> {consumer_id}") - edge_colors.append(color) - edge_idx += 1 - - lines.append("") - for module_name in sorted_modules: - mermaid_id = _mermaid_id(module_name) - color = node_color(module_name) - lines.append( - f" style {mermaid_id} fill:{color}bf,stroke:{color},color:#eee,stroke-width:2px" - ) - - for stream_node_id, color in stream_node_ids.items(): - lines.append( - f" style {stream_node_id} fill:transparent,stroke:{color},color:{color},stroke-width:1px" - ) - - if edge_colors: - lines.append("") - for i, color in enumerate(edge_colors): - lines.append(f" linkStyle {i} stroke:{color},stroke-width:2px") - - return "\n".join(lines), label_color_map, disconnected_labels - - -def _build_html(python_file: str, *, show_disconnected: bool = True) -> str: +def _build_html( + python_file: str, *, show_disconnected: bool = True, theme: str = DEFAULT_THEME +) -> str: blueprints = _load_blueprints(python_file) + palette = THEMES.get(theme, THEMES[DEFAULT_THEME]) + background = palette.get("background", "#1e1e1e") + mermaid_theme = palette.get("mermaid_theme", "dark") + is_light = mermaid_theme != "dark" + text_color = "#334155" if is_light else "#ccc" + text_muted = "#64748b" if is_light else "#888" + text_bright = "#1e293b" if is_light else "#eee" + surface = "#e2e8f0" if is_light else "#252525" + surface_hover = "#cbd5e1" if is_light else "#2a2a2a" + controls_bg = "#e2e8f0" if is_light else "#2a2a2a" + controls_btn = "#cbd5e1" if is_light else "#333" + controls_border = "#94a3b8" if is_light else "#555" + border_color = "#cbd5e1" if is_light else "#444" + label_bg = "rgba(248,250,252,0.85)" if is_light else "rgba(30,30,30,0.7)" per_bp_label_colors: list[dict[str, str]] = [] per_bp_disconnected: list[set[str]] = [] @@ -329,8 +104,8 @@ def _build_html(python_file: str, *, show_disconnected: bool = True) -> str: tab_buttons = [] tab_panels = [] for idx, (name, bp) in enumerate(blueprints): - mermaid_code, label_colors, disconnected = _render_mermaid( - bp, show_disconnected=show_disconnected + mermaid_code, label_colors, disconnected = render_mermaid( + bp, show_disconnected=show_disconnected, theme=theme ) per_bp_label_colors.append(label_colors) per_bp_disconnected.append(disconnected) @@ -356,19 +131,20 @@ def _build_html(python_file: str, *, show_disconnected: bool = True) -> str: Blueprint Diagrams + + + +
+graph LR
+    CameraModule([CameraModule]):::moduleNode
+    ControllerModule([ControllerModule]):::moduleNode
+    OdometryModule([OdometryModule]):::moduleNode
+    PerceptionModule([PerceptionModule]):::moduleNode
+    PlannerModule([PlannerModule]):::moduleNode
+    VisualizerModule([VisualizerModule]):::moduleNode
+
+    %% Stream nodes and edges
+    CameraModule_color_image_ImageData[color_image:ImageData]:::streamNode
+    CameraModule --- CameraModule_color_image_ImageData
+    CameraModule_color_image_ImageData --> PerceptionModule
+    CameraModule_color_image_ImageData --> VisualizerModule
+    CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+    CameraModule --- CameraModule_depth_image_DepthData
+    CameraModule_depth_image_DepthData --> PerceptionModule
+    OdometryModule_odometry_OdometryData[odometry:OdometryData]:::streamNode
+    OdometryModule --- OdometryModule_odometry_OdometryData
+    OdometryModule_odometry_OdometryData --> PerceptionModule
+    OdometryModule_odometry_OdometryData --> PlannerModule
+    OdometryModule_odometry_OdometryData --> ControllerModule
+    PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule --- PlannerModule_plan_PlanData
+    PlannerModule_plan_PlanData --> ControllerModule
+    CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+    CameraModule --- CameraModule_point_cloud_PointCloudData
+    CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+    %% Disconnected streams
+    ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+
+    style CameraModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+    style ControllerModule fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+    style OdometryModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+    style PerceptionModule fill:#f97316bf,stroke:#f97316,color:#eee,stroke-width:2px
+    style PlannerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+    style VisualizerModule fill:#06b6d4bf,stroke:#06b6d4,color:#eee,stroke-width:2px
+    style CameraModule_color_image_ImageData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+    style CameraModule_depth_image_DepthData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+    style OdometryModule_odometry_OdometryData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style PlannerModule_plan_PlanData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+
+    linkStyle 0 stroke:#3b82f6,stroke-width:2px
+    linkStyle 1 stroke:#60a5fa,stroke-width:2px
+    linkStyle 2 stroke:#60a5fa,stroke-width:2px
+    linkStyle 3 stroke:#3b82f6,stroke-width:2px
+    linkStyle 4 stroke:#f87171,stroke-width:2px
+    linkStyle 5 stroke:#ef4444,stroke-width:2px
+    linkStyle 6 stroke:#4ade80,stroke-width:2px
+    linkStyle 7 stroke:#4ade80,stroke-width:2px
+    linkStyle 8 stroke:#4ade80,stroke-width:2px
+    linkStyle 9 stroke:#22c55e,stroke-width:2px
+    linkStyle 10 stroke:#a78bfa,stroke-width:2px
+    linkStyle 11 stroke:#3b82f6,stroke-width:2px
+    linkStyle 12 stroke:#fb923c,stroke-width:2px
+    linkStyle 13 stroke:#8b5cf6,stroke-width:2px
+
+
+ + + +
+ + From a4a447aea5e4ce01a57a6878924e9247a297d97c Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 14:28:54 -0700 Subject: [PATCH 09/18] generate test --- .../blueprint/test_mermaid_server_snapshot.html | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html index 2a69fa9e64..2cb858da6e 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html +++ b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html @@ -130,6 +130,17 @@ await mermaid.run(); +const arrowScale = 2.3; +const arrowGap = 6; +document.querySelectorAll('marker').forEach(marker => { + const width = parseFloat(marker.getAttribute('markerWidth')) || 8; + const height = parseFloat(marker.getAttribute('markerHeight')) || 8; + marker.setAttribute('markerWidth', width * arrowScale); + marker.setAttribute('markerHeight', height * arrowScale); + const refX = parseFloat(marker.getAttribute('refX')) || 0; + marker.setAttribute('refX', refX + arrowGap); +}); + const allLabelColors = [{"color_image:ImageData": "#60a5fa", "depth_image:DepthData": "#f87171", "odometry:OdometryData": "#4ade80", "plan:PlanData": "#a78bfa", "point_cloud:PointCloudData": "#fb923c", "cmd_vel:CmdVelData": "#22d3ee"}]; const allDisconnected = [["cmd_vel:CmdVelData"]]; @@ -213,7 +224,7 @@ const svgH = svgRect.height; const pad = 40; scale = Math.min((vpRect.width - pad) / svgW, (vpRect.height - pad) / svgH); - scale = Math.max(scale * 2, 0.2); + scale = Math.max(scale * 0.8, 0.2); panX = (vpRect.width - svgW * scale) / 2; panY = (vpRect.height - svgH * scale) / 2; apply(); From 7c086e89ede0030de16d9248110db513c4a97a7d Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 14:40:36 -0700 Subject: [PATCH 10/18] polish --- .../blueprint/test_mermaid_server.py | 20 ++++--------------- dimos/robot/cli/dimos.py | 8 ++++++-- dimos/utils/cli/graph.py | 13 ++++++++++++ 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/dimos/core/introspection/blueprint/test_mermaid_server.py b/dimos/core/introspection/blueprint/test_mermaid_server.py index 6f05e8adcd..a71c6336e4 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server.py +++ b/dimos/core/introspection/blueprint/test_mermaid_server.py @@ -25,7 +25,6 @@ import requests from dimos.core.coordination.blueprints import Blueprint, autoconnect -from dimos.core.introspection.blueprint.mermaid import render_mermaid from dimos.core.module import Module from dimos.core.stream import In, Out @@ -135,17 +134,6 @@ def log_message(self, format: str, *args: object) -> None: return body -def test_mermaid_render_snapshot() -> None: - blueprint = _build_blueprint() - mermaid_code, label_colors, disconnected = render_mermaid(blueprint, show_disconnected=True) - - assert "graph LR" in mermaid_code - assert "CameraModule" in mermaid_code - assert "ControllerModule" in mermaid_code - assert len(label_colors) > 0 - assert isinstance(disconnected, set) - - def test_graph_server_snapshot() -> None: blueprint_source = textwrap.dedent("""\ from dimos.core.coordination.blueprints import autoconnect @@ -190,12 +178,12 @@ def test_graph_server_snapshot() -> None: assert served_normalized == html_normalized if SNAPSHOT_PATH.exists(): - snapshot = SNAPSHOT_PATH.read_text() - if snapshot != html_normalized: - SNAPSHOT_PATH.write_text(html_normalized) + snapshot = SNAPSHOT_PATH.read_text().rstrip("\n") + if snapshot != html_normalized.rstrip("\n"): + SNAPSHOT_PATH.write_text(html_normalized.rstrip("\n") + "\n") pytest.fail( f"Snapshot mismatch — updated {SNAPSHOT_PATH.name}. " "Re-run to confirm the new snapshot passes." ) else: - SNAPSHOT_PATH.write_text(html_normalized) + SNAPSHOT_PATH.write_text(html_normalized.rstrip("\n") + "\n") diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 03511d0abe..f315b2b761 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -796,17 +796,21 @@ def graph( markdown: bool = typer.Option( False, "--markdown", help="Print Mermaid markdown to stdout and exit" ), + html: str = typer.Option("", "--html", help="Write HTML to a file instead of serving"), theme: str = typer.Option( "tailwind", "--theme", help="Color theme (tailwind, ocean, ember, forest, light)" ), ) -> None: - """Render DimOS Blueprint graphs as Mermaid diagrams in the browser.""" - from dimos.utils.cli.graph import print_markdown, serve_graph + """Render DimOS Blueprint graphs. Serves in browser by default, or use --markdown / --html.""" + from dimos.utils.cli.graph import print_markdown, save_html, serve_graph show_disconnected = not no_disconnected if markdown: print_markdown(python_file, show_disconnected=show_disconnected, theme=theme) return + if html: + save_html(python_file, output_path=html, show_disconnected=show_disconnected, theme=theme) + return serve_graph(python_file, show_disconnected=show_disconnected, port=port, theme=theme) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index 322568c004..d3a968826a 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -399,6 +399,19 @@ def print_markdown( print("\n\n".join(sections)) +def save_html( + python_file: str, + *, + output_path: str, + show_disconnected: bool, + theme: str = DEFAULT_THEME, +) -> None: + html = _build_html(python_file, show_disconnected=show_disconnected, theme=theme) + with open(output_path, "w") as file: + file.write(html) + print(f"Wrote {output_path}", file=sys.stderr) + + def serve_graph( python_file: str, *, show_disconnected: bool, port: int, theme: str = DEFAULT_THEME ) -> None: From 1ce827afcf1d6e46244669651e428e02fc86a9b0 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 14:56:01 -0700 Subject: [PATCH 11/18] feat(graph): add colored input-fighting conflicts box and use test_mermaid_blueprint for snapshot - Add "Possible Input Fighting" warning box showing topics with 2+ publishers - Color conflict items to match diagram node/stream colors - Module names shown as rounded pills with background color, streams as bordered boxes - Separator lines between multiple conflicts - Return node_color_map from render_mermaid for reuse - Simplify test to load blueprints from test_mermaid_blueprint.py directly --- dimos/core/introspection/blueprint/mermaid.py | 7 +- .../blueprint/test_mermaid_blueprint.py | 25 +- .../blueprint/test_mermaid_server.py | 38 +-- .../test_mermaid_server_snapshot.html | 223 ++++++++++++++++-- dimos/utils/cli/graph.py | 69 +++++- 5 files changed, 300 insertions(+), 62 deletions(-) diff --git a/dimos/core/introspection/blueprint/mermaid.py b/dimos/core/introspection/blueprint/mermaid.py index a4a08bba8b..83f44feeb7 100644 --- a/dimos/core/introspection/blueprint/mermaid.py +++ b/dimos/core/introspection/blueprint/mermaid.py @@ -214,10 +214,10 @@ def render_mermaid( ignored_modules: set[str] | None = None, show_disconnected: bool = False, theme: str = DEFAULT_THEME, -) -> tuple[str, dict[str, str], set[str]]: +) -> tuple[str, dict[str, str], set[str], dict[str, str]]: """Generate a Mermaid flowchart from a Blueprint. - Returns (mermaid_code, label_color_map, disconnected_labels). + Returns (mermaid_code, label_color_map, disconnected_labels, node_color_map). """ if ignored_streams is None: ignored_streams = DEFAULT_IGNORED_CONNECTIONS @@ -366,4 +366,5 @@ def render_mermaid( for i, color in enumerate(edge_colors): lines.append(f" linkStyle {i} stroke:{color},stroke-width:2px") - return "\n".join(lines), label_color_map, disconnected_labels + node_color_map = dict(node_color._assigned) + return "\n".join(lines), label_color_map, disconnected_labels, node_color_map diff --git a/dimos/core/introspection/blueprint/test_mermaid_blueprint.py b/dimos/core/introspection/blueprint/test_mermaid_blueprint.py index 53d92c7f23..f2c76a0c58 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_blueprint.py +++ b/dimos/core/introspection/blueprint/test_mermaid_blueprint.py @@ -48,7 +48,8 @@ class PointCloudData: class CameraModule(Module): - color_image: Out[ImageData] + # intentionally doesn't match "color_image" + color_img: Out[ImageData] depth_image: Out[DepthData] point_cloud: Out[PointCloudData] @@ -68,12 +69,23 @@ class PlannerModule(Module): plan: Out[PlanData] +class PlannerModule2(Module): + odometry: In[OdometryData] + plan: Out[PlanData] + + class ControllerModule(Module): plan: In[PlanData] odometry: In[OdometryData] cmd_vel: Out[CmdVelData] +class ControllerModule2(Module): + plan: In[PlanData] + odometry: In[OdometryData] + cmd_vel: Out[CmdVelData] + + class VisualizerModule(Module): color_image: In[ImageData] point_cloud: In[PointCloudData] @@ -94,3 +106,14 @@ class VisualizerModule(Module): ControllerModule.blueprint(), VisualizerModule.blueprint(), ) + +blueprint3 = autoconnect( + CameraModule.blueprint(), + OdometryModule.blueprint(), + PerceptionModule.blueprint(), + PlannerModule.blueprint(), + PlannerModule2.blueprint(), # intenional double + ControllerModule.blueprint(), + ControllerModule2.blueprint(), + VisualizerModule.blueprint(), +) diff --git a/dimos/core/introspection/blueprint/test_mermaid_server.py b/dimos/core/introspection/blueprint/test_mermaid_server.py index a71c6336e4..a25459ed13 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server.py +++ b/dimos/core/introspection/blueprint/test_mermaid_server.py @@ -17,8 +17,6 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path import re -import tempfile -import textwrap import threading import pytest @@ -135,37 +133,11 @@ def log_message(self, format: str, *args: object) -> None: def test_graph_server_snapshot() -> None: - blueprint_source = textwrap.dedent("""\ - from dimos.core.coordination.blueprints import autoconnect - from dimos.core.introspection.blueprint.test_mermaid_server import ( - CameraModule, - ControllerModule, - OdometryModule, - PerceptionModule, - PlannerModule, - VisualizerModule, - ) - - complex_blueprint = autoconnect( - CameraModule.blueprint(), - OdometryModule.blueprint(), - PerceptionModule.blueprint(), - PlannerModule.blueprint(), - ControllerModule.blueprint(), - VisualizerModule.blueprint(), - ) - """) - - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp_file: - tmp_file.write(blueprint_source) - tmp_path = tmp_file.name - - try: - from dimos.utils.cli.graph import _build_html - - html = _build_html(tmp_path, show_disconnected=True) - finally: - Path(tmp_path).unlink() + blueprint_file = str(Path(__file__).with_name("test_mermaid_blueprint.py")) + + from dimos.utils.cli.graph import _build_html + + html = _build_html(blueprint_file, show_disconnected=True) html_normalized = _normalize_html(html) diff --git a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html index 2cb858da6e..f4c9c5bbea 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html +++ b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html @@ -46,23 +46,39 @@ } .moduleNode .nodeLabel { font-size: 38px !important; font-weight: 600 !important; display: block !important; transform: scale(0.7) !important; } .streamNode .nodeLabel { font-size: 18px !important; } +.conflicts-box { + position: fixed; bottom: 1.2em; left: 1.2em; z-index: 10; + background: #2a2a2a; border: 1px solid #e57373; border-radius: 6px; + padding: 0.7em 1em; max-width: 26em; font-size: 0.85em; + color: #ccc; +} +.conflicts-box.hidden { display: none; } +.conflicts-title { color: #e57373; font-weight: 600; margin-bottom: 0.3em; } +.conflicts-item { margin: 0.25em 0; padding-top: 0.4em; border-top: 1px solid #444; } +.conflicts-item:first-of-type { border-top: none; padding-top: 0; } +.conflict-module { + display: inline-block; padding: 3px 10px; border-radius: 10px; + color: #eee; font-size: 0.92em; margin: 2px 2px; +} +.conflict-stream { + display: inline-block; padding: 3px 8px; border: 1px solid; + border-radius: 3px; font-size: 0.92em; margin: 2px 2px; +} - +
 graph LR
     CameraModule([CameraModule]):::moduleNode
     ControllerModule([ControllerModule]):::moduleNode
+    ControllerModule2([ControllerModule2]):::moduleNode
     OdometryModule([OdometryModule]):::moduleNode
     PerceptionModule([PerceptionModule]):::moduleNode
     PlannerModule([PlannerModule]):::moduleNode
+    PlannerModule2([PlannerModule2]):::moduleNode
     VisualizerModule([VisualizerModule]):::moduleNode
 
     %% Stream nodes and edges
-    CameraModule_color_image_ImageData[color_image:ImageData]:::streamNode
-    CameraModule --- CameraModule_color_image_ImageData
-    CameraModule_color_image_ImageData --> PerceptionModule
-    CameraModule_color_image_ImageData --> VisualizerModule
     CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
     CameraModule --- CameraModule_depth_image_DepthData
     CameraModule_depth_image_DepthData --> PerceptionModule
@@ -70,10 +86,17 @@
     OdometryModule --- OdometryModule_odometry_OdometryData
     OdometryModule_odometry_OdometryData --> PerceptionModule
     OdometryModule_odometry_OdometryData --> PlannerModule
+    OdometryModule_odometry_OdometryData --> PlannerModule2
     OdometryModule_odometry_OdometryData --> ControllerModule
+    OdometryModule_odometry_OdometryData --> ControllerModule2
     PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
     PlannerModule --- PlannerModule_plan_PlanData
     PlannerModule_plan_PlanData --> ControllerModule
+    PlannerModule_plan_PlanData --> ControllerModule2
+    PlannerModule2_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule2 --- PlannerModule2_plan_PlanData
+    PlannerModule2_plan_PlanData --> ControllerModule
+    PlannerModule2_plan_PlanData --> ControllerModule2
     CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
     CameraModule --- CameraModule_point_cloud_PointCloudData
     CameraModule_point_cloud_PointCloudData --> VisualizerModule
@@ -81,6 +104,136 @@
     %% Disconnected streams
     ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
     ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+    ControllerModule2_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule2 -.- ControllerModule2_cmd_vel_CmdVelData
+    dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+    dangling_color_image_ImageData -.-> PerceptionModule
+    dangling_color_image_ImageData -.-> VisualizerModule
+    CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+    CameraModule -.- CameraModule_color_img_ImageData
+
+    style CameraModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+    style ControllerModule fill:#f97316bf,stroke:#f97316,color:#eee,stroke-width:2px
+    style ControllerModule2 fill:#06b6d4bf,stroke:#06b6d4,color:#eee,stroke-width:2px
+    style OdometryModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+    style PerceptionModule fill:#ec4899bf,stroke:#ec4899,color:#eee,stroke-width:2px
+    style PlannerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+    style PlannerModule2 fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+    style VisualizerModule fill:#6366f1bf,stroke:#6366f1,color:#eee,stroke-width:2px
+    style CameraModule_depth_image_DepthData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+    style OdometryModule_odometry_OdometryData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+    style PlannerModule_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style PlannerModule2_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style ControllerModule2_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style dangling_color_image_ImageData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+    style CameraModule_color_img_ImageData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+    linkStyle 0 stroke:#3b82f6,stroke-width:2px
+    linkStyle 1 stroke:#60a5fa,stroke-width:2px
+    linkStyle 2 stroke:#ef4444,stroke-width:2px
+    linkStyle 3 stroke:#f87171,stroke-width:2px
+    linkStyle 4 stroke:#f87171,stroke-width:2px
+    linkStyle 5 stroke:#f87171,stroke-width:2px
+    linkStyle 6 stroke:#f87171,stroke-width:2px
+    linkStyle 7 stroke:#f87171,stroke-width:2px
+    linkStyle 8 stroke:#22c55e,stroke-width:2px
+    linkStyle 9 stroke:#4ade80,stroke-width:2px
+    linkStyle 10 stroke:#4ade80,stroke-width:2px
+    linkStyle 11 stroke:#8b5cf6,stroke-width:2px
+    linkStyle 12 stroke:#4ade80,stroke-width:2px
+    linkStyle 13 stroke:#4ade80,stroke-width:2px
+    linkStyle 14 stroke:#3b82f6,stroke-width:2px
+    linkStyle 15 stroke:#a78bfa,stroke-width:2px
+    linkStyle 16 stroke:#f97316,stroke-width:2px
+    linkStyle 17 stroke:#06b6d4,stroke-width:2px
+    linkStyle 18 stroke:#22d3ee,stroke-width:2px
+    linkStyle 19 stroke:#22d3ee,stroke-width:2px
+    linkStyle 20 stroke:#3b82f6,stroke-width:2px
+
+graph LR
+    CameraModule([CameraModule]):::moduleNode
+    ControllerModule([ControllerModule]):::moduleNode
+    PlannerModule([PlannerModule]):::moduleNode
+    VisualizerModule([VisualizerModule]):::moduleNode
+
+    %% Stream nodes and edges
+    PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule --- PlannerModule_plan_PlanData
+    PlannerModule_plan_PlanData --> ControllerModule
+    CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+    CameraModule --- CameraModule_point_cloud_PointCloudData
+    CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+    %% Disconnected streams
+    ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+    dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+    dangling_color_image_ImageData -.-> VisualizerModule
+    CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+    CameraModule -.- CameraModule_color_img_ImageData
+    CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+    CameraModule -.- CameraModule_depth_image_DepthData
+    dangling_odometry_OdometryData[odometry:OdometryData]:::streamNode
+    dangling_odometry_OdometryData -.-> PlannerModule
+    dangling_odometry_OdometryData -.-> ControllerModule
+
+    style CameraModule fill:#ef4444bf,stroke:#ef4444,color:#eee,stroke-width:2px
+    style ControllerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
+    style PlannerModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
+    style VisualizerModule fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
+    style PlannerModule_plan_PlanData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style dangling_color_image_ImageData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+    style CameraModule_color_img_ImageData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style CameraModule_depth_image_DepthData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+    style dangling_odometry_OdometryData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
+
+    linkStyle 0 stroke:#3b82f6,stroke-width:2px
+    linkStyle 1 stroke:#60a5fa,stroke-width:2px
+    linkStyle 2 stroke:#ef4444,stroke-width:2px
+    linkStyle 3 stroke:#f87171,stroke-width:2px
+    linkStyle 4 stroke:#22c55e,stroke-width:2px
+    linkStyle 5 stroke:#a78bfa,stroke-width:2px
+    linkStyle 6 stroke:#ef4444,stroke-width:2px
+    linkStyle 7 stroke:#ef4444,stroke-width:2px
+    linkStyle 8 stroke:#f472b6,stroke-width:2px
+    linkStyle 9 stroke:#f472b6,stroke-width:2px
+
+graph LR
+    CameraModule([CameraModule]):::moduleNode
+    ControllerModule([ControllerModule]):::moduleNode
+    OdometryModule([OdometryModule]):::moduleNode
+    PerceptionModule([PerceptionModule]):::moduleNode
+    PlannerModule([PlannerModule]):::moduleNode
+    VisualizerModule([VisualizerModule]):::moduleNode
+
+    %% Stream nodes and edges
+    CameraModule_depth_image_DepthData[depth_image:DepthData]:::streamNode
+    CameraModule --- CameraModule_depth_image_DepthData
+    CameraModule_depth_image_DepthData --> PerceptionModule
+    OdometryModule_odometry_OdometryData[odometry:OdometryData]:::streamNode
+    OdometryModule --- OdometryModule_odometry_OdometryData
+    OdometryModule_odometry_OdometryData --> PerceptionModule
+    OdometryModule_odometry_OdometryData --> PlannerModule
+    OdometryModule_odometry_OdometryData --> ControllerModule
+    PlannerModule_plan_PlanData[plan:PlanData]:::streamNode
+    PlannerModule --- PlannerModule_plan_PlanData
+    PlannerModule_plan_PlanData --> ControllerModule
+    CameraModule_point_cloud_PointCloudData[point_cloud:PointCloudData]:::streamNode
+    CameraModule --- CameraModule_point_cloud_PointCloudData
+    CameraModule_point_cloud_PointCloudData --> VisualizerModule
+
+    %% Disconnected streams
+    ControllerModule_cmd_vel_CmdVelData[cmd_vel:CmdVelData]:::streamNode
+    ControllerModule -.- ControllerModule_cmd_vel_CmdVelData
+    dangling_color_image_ImageData[color_image:ImageData]:::streamNode
+    dangling_color_image_ImageData -.-> PerceptionModule
+    dangling_color_image_ImageData -.-> VisualizerModule
+    CameraModule_color_img_ImageData[color_img:ImageData]:::streamNode
+    CameraModule -.- CameraModule_color_img_ImageData
 
     style CameraModule fill:#3b82f6bf,stroke:#3b82f6,color:#eee,stroke-width:2px
     style ControllerModule fill:#8b5cf6bf,stroke:#8b5cf6,color:#eee,stroke-width:2px
@@ -88,28 +241,30 @@
     style PerceptionModule fill:#f97316bf,stroke:#f97316,color:#eee,stroke-width:2px
     style PlannerModule fill:#22c55ebf,stroke:#22c55e,color:#eee,stroke-width:2px
     style VisualizerModule fill:#06b6d4bf,stroke:#06b6d4,color:#eee,stroke-width:2px
-    style CameraModule_color_image_ImageData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
-    style CameraModule_depth_image_DepthData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
-    style OdometryModule_odometry_OdometryData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
-    style PlannerModule_plan_PlanData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
-    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
-    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+    style CameraModule_depth_image_DepthData fill:transparent,stroke:#60a5fa,color:#60a5fa,stroke-width:1px
+    style OdometryModule_odometry_OdometryData fill:transparent,stroke:#f87171,color:#f87171,stroke-width:1px
+    style PlannerModule_plan_PlanData fill:transparent,stroke:#4ade80,color:#4ade80,stroke-width:1px
+    style CameraModule_point_cloud_PointCloudData fill:transparent,stroke:#a78bfa,color:#a78bfa,stroke-width:1px
+    style ControllerModule_cmd_vel_CmdVelData fill:transparent,stroke:#fb923c,color:#fb923c,stroke-width:1px
+    style dangling_color_image_ImageData fill:transparent,stroke:#22d3ee,color:#22d3ee,stroke-width:1px
+    style CameraModule_color_img_ImageData fill:transparent,stroke:#f472b6,color:#f472b6,stroke-width:1px
 
     linkStyle 0 stroke:#3b82f6,stroke-width:2px
     linkStyle 1 stroke:#60a5fa,stroke-width:2px
-    linkStyle 2 stroke:#60a5fa,stroke-width:2px
-    linkStyle 3 stroke:#3b82f6,stroke-width:2px
+    linkStyle 2 stroke:#ef4444,stroke-width:2px
+    linkStyle 3 stroke:#f87171,stroke-width:2px
     linkStyle 4 stroke:#f87171,stroke-width:2px
-    linkStyle 5 stroke:#ef4444,stroke-width:2px
-    linkStyle 6 stroke:#4ade80,stroke-width:2px
+    linkStyle 5 stroke:#f87171,stroke-width:2px
+    linkStyle 6 stroke:#22c55e,stroke-width:2px
     linkStyle 7 stroke:#4ade80,stroke-width:2px
-    linkStyle 8 stroke:#4ade80,stroke-width:2px
-    linkStyle 9 stroke:#22c55e,stroke-width:2px
-    linkStyle 10 stroke:#a78bfa,stroke-width:2px
-    linkStyle 11 stroke:#3b82f6,stroke-width:2px
-    linkStyle 12 stroke:#fb923c,stroke-width:2px
-    linkStyle 13 stroke:#8b5cf6,stroke-width:2px
+    linkStyle 8 stroke:#3b82f6,stroke-width:2px
+    linkStyle 9 stroke:#a78bfa,stroke-width:2px
+    linkStyle 10 stroke:#8b5cf6,stroke-width:2px
+    linkStyle 11 stroke:#22d3ee,stroke-width:2px
+    linkStyle 12 stroke:#22d3ee,stroke-width:2px
+    linkStyle 13 stroke:#3b82f6,stroke-width:2px
 
+
@@ -141,8 +296,27 @@ marker.setAttribute('refX', refX + arrowGap); }); -const allLabelColors = [{"color_image:ImageData": "#60a5fa", "depth_image:DepthData": "#f87171", "odometry:OdometryData": "#4ade80", "plan:PlanData": "#a78bfa", "point_cloud:PointCloudData": "#fb923c", "cmd_vel:CmdVelData": "#22d3ee"}]; -const allDisconnected = [["cmd_vel:CmdVelData"]]; +const allLabelColors = [{"depth_image:DepthData": "#60a5fa", "odometry:OdometryData": "#f87171", "plan:PlanData": "#4ade80", "point_cloud:PointCloudData": "#a78bfa", "cmd_vel:CmdVelData": "#fb923c", "color_image:ImageData": "#22d3ee", "color_img:ImageData": "#f472b6"}, {"plan:PlanData": "#60a5fa", "point_cloud:PointCloudData": "#f87171", "cmd_vel:CmdVelData": "#4ade80", "color_image:ImageData": "#a78bfa", "color_img:ImageData": "#fb923c", "depth_image:DepthData": "#22d3ee", "odometry:OdometryData": "#f472b6"}, {"depth_image:DepthData": "#60a5fa", "odometry:OdometryData": "#f87171", "plan:PlanData": "#4ade80", "point_cloud:PointCloudData": "#a78bfa", "cmd_vel:CmdVelData": "#fb923c", "color_image:ImageData": "#22d3ee", "color_img:ImageData": "#f472b6"}]; +const allDisconnected = [["cmd_vel:CmdVelData", "color_image:ImageData", "color_img:ImageData"], ["cmd_vel:CmdVelData", "color_image:ImageData", "color_img:ImageData", "depth_image:DepthData", "odometry:OdometryData"], ["cmd_vel:CmdVelData", "color_image:ImageData", "color_img:ImageData"]]; +const allConflicts = [[{"topic": "plan:PlanData", "topicColor": "#4ade80", "modules": [{"name": "PlannerModule", "color": "#22c55e"}, {"name": "PlannerModule2", "color": "#8b5cf6"}]}, {"topic": "cmd_vel:CmdVelData", "topicColor": "#fb923c", "modules": [{"name": "ControllerModule", "color": "#f97316"}, {"name": "ControllerModule2", "color": "#06b6d4"}]}], [], []]; + +function renderConflicts(idx) { + const box = document.getElementById('conflictsBox'); + const conflicts = allConflicts[idx] || []; + if (conflicts.length === 0) { + box.classList.add('hidden'); + box.innerHTML = ''; + return; + } + box.classList.remove('hidden'); + box.innerHTML = '
⚠ Possible Input Fighting
' + + conflicts.map(c => + `
` + + `${c.topic} ` + + c.modules.map(m => `${m.name}`).join(' ') + + `
` + ).join(''); +} function setupViewport(vp, labelColors, disconnectedList) { const canvas = vp.querySelector('.canvas'); @@ -294,6 +468,8 @@ if (activeViewport?._fitToView) activeViewport._fitToView(); }); +renderConflicts(0); + document.querySelectorAll('.tab-panel:not(.active)').forEach(p => p.classList.add('hidden')); document.querySelectorAll('.tab-btn').forEach(btn => { @@ -313,6 +489,7 @@ activeViewport = vp; if (vp._fitToView) setTimeout(() => vp._fitToView(), 0); } + renderConflicts(parseInt(idx)); }); }); diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index d3a968826a..47f96e1d17 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -100,15 +100,38 @@ def _build_html( per_bp_label_colors: list[dict[str, str]] = [] per_bp_disconnected: list[set[str]] = [] + per_bp_node_colors: list[dict[str, str]] = [] + per_bp_conflicts: list[list[dict[str, object]]] = [] tab_buttons = [] tab_panels = [] for idx, (name, bp) in enumerate(blueprints): - mermaid_code, label_colors, disconnected = render_mermaid( + mermaid_code, label_colors, disconnected, node_colors = render_mermaid( bp, show_disconnected=show_disconnected, theme=theme ) per_bp_label_colors.append(label_colors) per_bp_disconnected.append(disconnected) + per_bp_node_colors.append(node_colors) + + producers: dict[str, list[str]] = {} + for atom in bp.blueprints: + for stream in atom.streams: + if stream.direction == "out": + topic = f"{stream.name}:{stream.type.__name__}" + producers.setdefault(topic, []).append(atom.module.__name__) + conflicts = [ + { + "topic": topic, + "topicColor": label_colors.get(topic, "#ccc"), + "modules": [ + {"name": module_name, "color": node_colors.get(module_name, "#ccc")} + for module_name in modules + ], + } + for topic, modules in producers.items() + if len(modules) > 1 + ] + per_bp_conflicts.append(conflicts) active_cls = " active" if idx == 0 else "" tab_buttons.append(f'') @@ -121,6 +144,7 @@ def _build_html( all_label_colors_json = json.dumps(per_bp_label_colors) all_disconnected_json = json.dumps([sorted(d) for d in per_bp_disconnected]) + all_conflicts_json = json.dumps(per_bp_conflicts) tab_bar_html = "" if len(blueprints) > 1: @@ -175,10 +199,29 @@ def _build_html( }} .moduleNode .nodeLabel {{ font-size: 38px !important; font-weight: 600 !important; display: block !important; transform: scale(0.7) !important; }} .streamNode .nodeLabel {{ font-size: 18px !important; }} +.conflicts-box {{ + position: fixed; bottom: 1.2em; left: 1.2em; z-index: 10; + background: {controls_bg}; border: 1px solid #e57373; border-radius: 6px; + padding: 0.7em 1em; max-width: 26em; font-size: 0.85em; + color: {text_color}; +}} +.conflicts-box.hidden {{ display: none; }} +.conflicts-title {{ color: #e57373; font-weight: 600; margin-bottom: 0.3em; }} +.conflicts-item {{ margin: 0.25em 0; padding-top: 0.4em; border-top: 1px solid {border_color}; }} +.conflicts-item:first-of-type {{ border-top: none; padding-top: 0; }} +.conflict-module {{ + display: inline-block; padding: 3px 10px; border-radius: 10px; + color: #eee; font-size: 0.92em; margin: 2px 2px; +}} +.conflict-stream {{ + display: inline-block; padding: 3px 8px; border: 1px solid; + border-radius: 3px; font-size: 0.92em; margin: 2px 2px; +}} {tab_bar_html} {"".join(tab_panels)} +
@@ -212,6 +255,25 @@ def _build_html( const allLabelColors = {all_label_colors_json}; const allDisconnected = {all_disconnected_json}; +const allConflicts = {all_conflicts_json}; + +function renderConflicts(idx) {{ + const box = document.getElementById('conflictsBox'); + const conflicts = allConflicts[idx] || []; + if (conflicts.length === 0) {{ + box.classList.add('hidden'); + box.innerHTML = ''; + return; + }} + box.classList.remove('hidden'); + box.innerHTML = '
⚠ Possible Input Fighting
' + + conflicts.map(c => + `
` + + `${{c.topic}} ` + + c.modules.map(m => `${{m.name}}`).join(' ') + + `
` + ).join(''); +}} function setupViewport(vp, labelColors, disconnectedList) {{ const canvas = vp.querySelector('.canvas'); @@ -363,6 +425,8 @@ def _build_html( if (activeViewport?._fitToView) activeViewport._fitToView(); }}); +renderConflicts(0); + document.querySelectorAll('.tab-panel:not(.active)').forEach(p => p.classList.add('hidden')); document.querySelectorAll('.tab-btn').forEach(btn => {{ @@ -382,6 +446,7 @@ def _build_html( activeViewport = vp; if (vp._fitToView) setTimeout(() => vp._fitToView(), 0); }} + renderConflicts(parseInt(idx)); }}); }}); @@ -394,7 +459,7 @@ def print_markdown( blueprints = _load_blueprints(python_file) sections: list[str] = [] for name, bp in blueprints: - mermaid_code, _, _ = render_mermaid(bp, show_disconnected=show_disconnected, theme=theme) + mermaid_code, _, _, _ = render_mermaid(bp, show_disconnected=show_disconnected, theme=theme) sections.append(f"## {name}\n\n```mermaid\n{mermaid_code}\n```") print("\n\n".join(sections)) From 501419cbd63cbc429225e12e16042a4afbf43ca4 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 15:03:29 -0700 Subject: [PATCH 12/18] add typo checking --- .../test_mermaid_server_snapshot.html | 69 ++++++---- dimos/utils/cli/graph.py | 122 ++++++++++++++---- 2 files changed, 139 insertions(+), 52 deletions(-) diff --git a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html index f4c9c5bbea..8a29fa943e 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html +++ b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html @@ -46,24 +46,27 @@ } .moduleNode .nodeLabel { font-size: 38px !important; font-weight: 600 !important; display: block !important; transform: scale(0.7) !important; } .streamNode .nodeLabel { font-size: 18px !important; } -.conflicts-box { +.warnings-container { position: fixed; bottom: 1.2em; left: 1.2em; z-index: 10; + display: flex; flex-direction: column; gap: 0.6em; max-width: 30em; +} +.warnings-container:empty { display: none; } +.warning-box { background: #2a2a2a; border: 1px solid #e57373; border-radius: 6px; - padding: 0.7em 1em; max-width: 26em; font-size: 0.85em; - color: #ccc; + padding: 0.7em 1em; font-size: 0.85em; color: #ccc; } -.conflicts-box.hidden { display: none; } -.conflicts-title { color: #e57373; font-weight: 600; margin-bottom: 0.3em; } -.conflicts-item { margin: 0.25em 0; padding-top: 0.4em; border-top: 1px solid #444; } -.conflicts-item:first-of-type { border-top: none; padding-top: 0; } -.conflict-module { +.warning-title { color: #e57373; font-weight: 600; margin-bottom: 0.3em; } +.warning-item { margin: 0.25em 0; padding-top: 0.4em; border-top: 1px solid #444; } +.warning-item:first-of-type { border-top: none; padding-top: 0; } +.warning-module { display: inline-block; padding: 3px 10px; border-radius: 10px; color: #eee; font-size: 0.92em; margin: 2px 2px; } -.conflict-stream { +.warning-stream { display: inline-block; padding: 3px 8px; border: 1px solid; border-radius: 3px; font-size: 0.92em; margin: 2px 2px; } +.typo-arrow { color: #888; margin: 0 2px; }
@@ -264,7 +267,7 @@ linkStyle 12 stroke:#22d3ee,stroke-width:2px linkStyle 13 stroke:#3b82f6,stroke-width:2px
- +
@@ -299,23 +302,37 @@ const allLabelColors = [{"depth_image:DepthData": "#60a5fa", "odometry:OdometryData": "#f87171", "plan:PlanData": "#4ade80", "point_cloud:PointCloudData": "#a78bfa", "cmd_vel:CmdVelData": "#fb923c", "color_image:ImageData": "#22d3ee", "color_img:ImageData": "#f472b6"}, {"plan:PlanData": "#60a5fa", "point_cloud:PointCloudData": "#f87171", "cmd_vel:CmdVelData": "#4ade80", "color_image:ImageData": "#a78bfa", "color_img:ImageData": "#fb923c", "depth_image:DepthData": "#22d3ee", "odometry:OdometryData": "#f472b6"}, {"depth_image:DepthData": "#60a5fa", "odometry:OdometryData": "#f87171", "plan:PlanData": "#4ade80", "point_cloud:PointCloudData": "#a78bfa", "cmd_vel:CmdVelData": "#fb923c", "color_image:ImageData": "#22d3ee", "color_img:ImageData": "#f472b6"}]; const allDisconnected = [["cmd_vel:CmdVelData", "color_image:ImageData", "color_img:ImageData"], ["cmd_vel:CmdVelData", "color_image:ImageData", "color_img:ImageData", "depth_image:DepthData", "odometry:OdometryData"], ["cmd_vel:CmdVelData", "color_image:ImageData", "color_img:ImageData"]]; const allConflicts = [[{"topic": "plan:PlanData", "topicColor": "#4ade80", "modules": [{"name": "PlannerModule", "color": "#22c55e"}, {"name": "PlannerModule2", "color": "#8b5cf6"}]}, {"topic": "cmd_vel:CmdVelData", "topicColor": "#fb923c", "modules": [{"name": "ControllerModule", "color": "#f97316"}, {"name": "ControllerModule2", "color": "#06b6d4"}]}], [], []]; +const allTypos = [[{"outLabel": "color_img:ImageData", "inLabel": "color_image:ImageData", "outColor": "#f472b6", "inColor": "#22d3ee", "outModules": [{"name": "CameraModule", "color": "#3b82f6"}], "inModules": [{"name": "PerceptionModule", "color": "#ec4899"}, {"name": "VisualizerModule", "color": "#6366f1"}]}], [{"outLabel": "color_img:ImageData", "inLabel": "color_image:ImageData", "outColor": "#fb923c", "inColor": "#a78bfa", "outModules": [{"name": "CameraModule", "color": "#ef4444"}], "inModules": [{"name": "VisualizerModule", "color": "#8b5cf6"}]}], [{"outLabel": "color_img:ImageData", "inLabel": "color_image:ImageData", "outColor": "#f472b6", "inColor": "#22d3ee", "outModules": [{"name": "CameraModule", "color": "#3b82f6"}], "inModules": [{"name": "PerceptionModule", "color": "#f97316"}, {"name": "VisualizerModule", "color": "#06b6d4"}]}]]; -function renderConflicts(idx) { - const box = document.getElementById('conflictsBox'); +function renderWarnings(idx) { + const container = document.getElementById('warningsContainer'); + let html = ''; const conflicts = allConflicts[idx] || []; - if (conflicts.length === 0) { - box.classList.add('hidden'); - box.innerHTML = ''; - return; + if (conflicts.length > 0) { + html += '
⚠ Possible Input Fighting
' + + conflicts.map(c => + `
` + + `${c.topic} ` + + c.modules.map(m => `${m.name}`).join(' ') + + `
` + ).join('') + '
'; + } + const typos = allTypos[idx] || []; + if (typos.length > 0) { + html += '
⚠ Possible Typos
' + + typos.map(t => + `
` + + `${t.outLabel}` + + `` + + `${t.inLabel}` + + `
` + + t.outModules.map(m => `${m.name}`).join(' ') + + `` + + t.inModules.map(m => `${m.name}`).join(' ') + + `
` + ).join('') + '
'; } - box.classList.remove('hidden'); - box.innerHTML = '
⚠ Possible Input Fighting
' + - conflicts.map(c => - `
` + - `${c.topic} ` + - c.modules.map(m => `${m.name}`).join(' ') + - `
` - ).join(''); + container.innerHTML = html; } function setupViewport(vp, labelColors, disconnectedList) { @@ -468,7 +485,7 @@ if (activeViewport?._fitToView) activeViewport._fitToView(); }); -renderConflicts(0); +renderWarnings(0); document.querySelectorAll('.tab-panel:not(.active)').forEach(p => p.classList.add('hidden')); @@ -489,7 +506,7 @@ activeViewport = vp; if (vp._fitToView) setTimeout(() => vp._fitToView(), 0); } - renderConflicts(parseInt(idx)); + renderWarnings(parseInt(idx)); }); }); diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index 47f96e1d17..fec42527f1 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -31,6 +31,20 @@ from dimos.core.introspection.blueprint.mermaid import DEFAULT_THEME, THEMES, render_mermaid +def _levenshtein(a: str, b: str) -> int: + if len(a) < len(b): + return _levenshtein(b, a) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, char_a in enumerate(a): + curr = [i + 1] + for j, char_b in enumerate(b): + curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (char_a != char_b))) + prev = curr + return prev[-1] + + def _find_package_root(filepath: str) -> str | None: directory = os.path.dirname(filepath) root = None @@ -102,6 +116,7 @@ def _build_html( per_bp_disconnected: list[set[str]] = [] per_bp_node_colors: list[dict[str, str]] = [] per_bp_conflicts: list[list[dict[str, object]]] = [] + per_bp_typos: list[list[dict[str, object]]] = [] tab_buttons = [] tab_panels = [] @@ -133,6 +148,43 @@ def _build_html( ] per_bp_conflicts.append(conflicts) + outputs: dict[tuple[str, str], list[str]] = {} + inputs: dict[tuple[str, str], list[str]] = {} + for atom in bp.blueprints: + for stream in atom.streams: + key = (stream.name, stream.type.__name__) + if stream.direction == "out": + outputs.setdefault(key, []).append(atom.module.__name__) + else: + inputs.setdefault(key, []).append(atom.module.__name__) + dangling_outs = {k: v for k, v in outputs.items() if k not in inputs} + dangling_ins = {k: v for k, v in inputs.items() if k not in outputs} + typos: list[dict[str, object]] = [] + for (out_name, out_type), out_modules in dangling_outs.items(): + for (in_name, in_type), in_modules in dangling_ins.items(): + if out_type != in_type: + continue + distance = _levenshtein(out_name, in_name) + if 0 < distance <= 2: + out_label = f"{out_name}:{out_type}" + in_label = f"{in_name}:{in_type}" + typos.append( + { + "outLabel": out_label, + "inLabel": in_label, + "outColor": label_colors.get(out_label, "#ccc"), + "inColor": label_colors.get(in_label, "#ccc"), + "outModules": [ + {"name": m, "color": node_colors.get(m, "#ccc")} + for m in out_modules + ], + "inModules": [ + {"name": m, "color": node_colors.get(m, "#ccc")} for m in in_modules + ], + } + ) + per_bp_typos.append(typos) + active_cls = " active" if idx == 0 else "" tab_buttons.append(f'') tab_panels.append( @@ -145,6 +197,7 @@ def _build_html( all_label_colors_json = json.dumps(per_bp_label_colors) all_disconnected_json = json.dumps([sorted(d) for d in per_bp_disconnected]) all_conflicts_json = json.dumps(per_bp_conflicts) + all_typos_json = json.dumps(per_bp_typos) tab_bar_html = "" if len(blueprints) > 1: @@ -199,29 +252,32 @@ def _build_html( }} .moduleNode .nodeLabel {{ font-size: 38px !important; font-weight: 600 !important; display: block !important; transform: scale(0.7) !important; }} .streamNode .nodeLabel {{ font-size: 18px !important; }} -.conflicts-box {{ +.warnings-container {{ position: fixed; bottom: 1.2em; left: 1.2em; z-index: 10; + display: flex; flex-direction: column; gap: 0.6em; max-width: 30em; +}} +.warnings-container:empty {{ display: none; }} +.warning-box {{ background: {controls_bg}; border: 1px solid #e57373; border-radius: 6px; - padding: 0.7em 1em; max-width: 26em; font-size: 0.85em; - color: {text_color}; + padding: 0.7em 1em; font-size: 0.85em; color: {text_color}; }} -.conflicts-box.hidden {{ display: none; }} -.conflicts-title {{ color: #e57373; font-weight: 600; margin-bottom: 0.3em; }} -.conflicts-item {{ margin: 0.25em 0; padding-top: 0.4em; border-top: 1px solid {border_color}; }} -.conflicts-item:first-of-type {{ border-top: none; padding-top: 0; }} -.conflict-module {{ +.warning-title {{ color: #e57373; font-weight: 600; margin-bottom: 0.3em; }} +.warning-item {{ margin: 0.25em 0; padding-top: 0.4em; border-top: 1px solid {border_color}; }} +.warning-item:first-of-type {{ border-top: none; padding-top: 0; }} +.warning-module {{ display: inline-block; padding: 3px 10px; border-radius: 10px; color: #eee; font-size: 0.92em; margin: 2px 2px; }} -.conflict-stream {{ +.warning-stream {{ display: inline-block; padding: 3px 8px; border: 1px solid; border-radius: 3px; font-size: 0.92em; margin: 2px 2px; }} +.typo-arrow {{ color: {text_muted}; margin: 0 2px; }} {tab_bar_html} {"".join(tab_panels)} - +
@@ -256,23 +312,37 @@ def _build_html( const allLabelColors = {all_label_colors_json}; const allDisconnected = {all_disconnected_json}; const allConflicts = {all_conflicts_json}; +const allTypos = {all_typos_json}; -function renderConflicts(idx) {{ - const box = document.getElementById('conflictsBox'); +function renderWarnings(idx) {{ + const container = document.getElementById('warningsContainer'); + let html = ''; const conflicts = allConflicts[idx] || []; - if (conflicts.length === 0) {{ - box.classList.add('hidden'); - box.innerHTML = ''; - return; + if (conflicts.length > 0) {{ + html += '
⚠ Possible Input Fighting
' + + conflicts.map(c => + `
` + + `${{c.topic}} ` + + c.modules.map(m => `${{m.name}}`).join(' ') + + `
` + ).join('') + '
'; + }} + const typos = allTypos[idx] || []; + if (typos.length > 0) {{ + html += '
⚠ Possible Typos
' + + typos.map(t => + `
` + + `${{t.outLabel}}` + + `` + + `${{t.inLabel}}` + + `
` + + t.outModules.map(m => `${{m.name}}`).join(' ') + + `` + + t.inModules.map(m => `${{m.name}}`).join(' ') + + `
` + ).join('') + '
'; }} - box.classList.remove('hidden'); - box.innerHTML = '
⚠ Possible Input Fighting
' + - conflicts.map(c => - `
` + - `${{c.topic}} ` + - c.modules.map(m => `${{m.name}}`).join(' ') + - `
` - ).join(''); + container.innerHTML = html; }} function setupViewport(vp, labelColors, disconnectedList) {{ @@ -425,7 +495,7 @@ def _build_html( if (activeViewport?._fitToView) activeViewport._fitToView(); }}); -renderConflicts(0); +renderWarnings(0); document.querySelectorAll('.tab-panel:not(.active)').forEach(p => p.classList.add('hidden')); @@ -446,7 +516,7 @@ def _build_html( activeViewport = vp; if (vp._fitToView) setTimeout(() => vp._fitToView(), 0); }} - renderConflicts(parseInt(idx)); + renderWarnings(parseInt(idx)); }}); }}); From 61c8a5f632cf8b7c83b9b51045f9c64b9585f25d Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 16:04:35 -0700 Subject: [PATCH 13/18] feat(graph): embed mermaid.js for offline use and add typo detection - Bundle mermaid.min.js inline so generated HTML works without network - Track mermaid.min.js with Git LFS - Detect possible typos: dangling In/Out streams with matching types and Levenshtein distance <= 2 (e.g. color_img vs color_image) - Normalize mermaid JS out of snapshot to keep it small --- .gitattributes | 1 + .../introspection/blueprint/test_mermaid_server.py | 6 ++++-- .../blueprint/test_mermaid_server_snapshot.html | 8 ++++---- dimos/utils/cli/graph.py | 11 +++++++---- dimos/utils/cli/mermaid.min.js | 3 +++ 5 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 dimos/utils/cli/mermaid.min.js diff --git a/.gitattributes b/.gitattributes index e4139e83c3..5ae63d79ff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,3 +19,4 @@ docs/capabilities/memory/assets/** filter=lfs diff=lfs merge=lfs -text docs/capabilities/memory/assets/.gitattributes -filter -diff -merge text docs/capabilities/mapping/assets/** filter=lfs diff=lfs merge=lfs -text +dimos/utils/cli/mermaid.min.js filter=lfs diff=lfs merge=lfs -text diff --git a/dimos/core/introspection/blueprint/test_mermaid_server.py b/dimos/core/introspection/blueprint/test_mermaid_server.py index a25459ed13..f94b7186e3 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server.py +++ b/dimos/core/introspection/blueprint/test_mermaid_server.py @@ -98,9 +98,11 @@ def _build_blueprint() -> Blueprint: def _normalize_html(html: str) -> str: return re.sub( - r"https://cdn\.jsdelivr\.net/npm/mermaid@[^/]+/", - "https://cdn.jsdelivr.net/npm/mermaid@VERSION/", + r"", + "", html, + count=1, + flags=re.DOTALL, ) diff --git a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html index 8a29fa943e..374875c9d0 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html +++ b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html @@ -273,10 +273,10 @@
- + +})() diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index fec42527f1..4896acfa31 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -24,12 +24,15 @@ import importlib.util import json import os +from pathlib import Path import sys import webbrowser from dimos.core.coordination.blueprints import Blueprint from dimos.core.introspection.blueprint.mermaid import DEFAULT_THEME, THEMES, render_mermaid +_MERMAID_JS = (Path(__file__).parent / "mermaid.min.js").read_text(encoding="utf-8") + def _levenshtein(a: str, b: str) -> int: if len(a) < len(b): @@ -283,10 +286,10 @@ def _build_html(
- + +}})() """ diff --git a/dimos/utils/cli/mermaid.min.js b/dimos/utils/cli/mermaid.min.js new file mode 100644 index 0000000000..cec11b200f --- /dev/null +++ b/dimos/utils/cli/mermaid.min.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51e439abf12e72752be1b0d67ed80195fb8cd85f2c7af017d57c5c6b717b58d5 +size 3312888 From aab61a120452058d97a308f70aad14f914b22b36 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 17:08:57 -0700 Subject: [PATCH 14/18] fix(mypy): annotate conflicts list type in graph._build_html --- dimos/utils/cli/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index 4896acfa31..a34e915e69 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -137,7 +137,7 @@ def _build_html( if stream.direction == "out": topic = f"{stream.name}:{stream.type.__name__}" producers.setdefault(topic, []).append(atom.module.__name__) - conflicts = [ + conflicts: list[dict[str, object]] = [ { "topic": topic, "topicColor": label_colors.get(topic, "#ccc"), From 8505f1781cfd162024bf322fe574addee8b1ce4f Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 17:10:15 -0700 Subject: [PATCH 15/18] mypy --- dimos/utils/cli/graph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index 4896acfa31..8dfed38590 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -26,6 +26,7 @@ import os from pathlib import Path import sys +from typing import Any import webbrowser from dimos.core.coordination.blueprints import Blueprint @@ -118,7 +119,7 @@ def _build_html( per_bp_label_colors: list[dict[str, str]] = [] per_bp_disconnected: list[set[str]] = [] per_bp_node_colors: list[dict[str, str]] = [] - per_bp_conflicts: list[list[dict[str, object]]] = [] + per_bp_conflicts: list[list[dict[str, Any]]] = [] per_bp_typos: list[list[dict[str, object]]] = [] tab_buttons = [] From b1d5e64319f8a9bd52d7af94bb193efe1ecd59f1 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 17:18:39 -0700 Subject: [PATCH 16/18] refactor(graph): extract HTML template to Jinja file --- .../test_mermaid_server_snapshot.html | 38 +- dimos/utils/cli/graph.html.jinja | 331 ++++++++++++++++ dimos/utils/cli/graph.py | 375 ++---------------- 3 files changed, 399 insertions(+), 345 deletions(-) create mode 100644 dimos/utils/cli/graph.html.jinja diff --git a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html index 374875c9d0..e60f97c981 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html +++ b/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html @@ -69,8 +69,21 @@ .typo-arrow { color: #888; margin: 0 2px; } -
-
+
+
+ + + + + + + +
+ + +
+
+
 graph LR
     CameraModule([CameraModule]):::moduleNode
     ControllerModule([ControllerModule]):::moduleNode
@@ -154,7 +167,13 @@
     linkStyle 18 stroke:#22d3ee,stroke-width:2px
     linkStyle 19 stroke:#22d3ee,stroke-width:2px
     linkStyle 20 stroke:#3b82f6,stroke-width:2px
-
+
+
+
+ +
+
+
 graph LR
     CameraModule([CameraModule]):::moduleNode
     ControllerModule([ControllerModule]):::moduleNode
@@ -204,7 +223,13 @@
     linkStyle 7 stroke:#ef4444,stroke-width:2px
     linkStyle 8 stroke:#f472b6,stroke-width:2px
     linkStyle 9 stroke:#f472b6,stroke-width:2px
-
+
+
+
+ +
+
+
 graph LR
     CameraModule([CameraModule]):::moduleNode
     ControllerModule([ControllerModule]):::moduleNode
@@ -266,7 +291,10 @@
     linkStyle 11 stroke:#22d3ee,stroke-width:2px
     linkStyle 12 stroke:#22d3ee,stroke-width:2px
     linkStyle 13 stroke:#3b82f6,stroke-width:2px
-
+
+
+
+
diff --git a/dimos/utils/cli/graph.html.jinja b/dimos/utils/cli/graph.html.jinja new file mode 100644 index 0000000000..db315554e4 --- /dev/null +++ b/dimos/utils/cli/graph.html.jinja @@ -0,0 +1,331 @@ + + + +Blueprint Diagrams + + + +{% if tab_buttons | length > 1 %} +
+ {% for button in tab_buttons %} + + {% endfor %} +
+{% endif %} +{% for panel in tab_panels %} +
+
+
+{{ panel.mermaid_code }}
+
+
+
+{% endfor %} +
+
+ + + +
+ + + diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index ad5a91e8af..bfbb210ae0 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -29,10 +29,17 @@ from typing import Any import webbrowser +import jinja2 + from dimos.core.coordination.blueprints import Blueprint from dimos.core.introspection.blueprint.mermaid import DEFAULT_THEME, THEMES, render_mermaid -_MERMAID_JS = (Path(__file__).parent / "mermaid.min.js").read_text(encoding="utf-8") +_CLI_DIR = Path(__file__).parent +_MERMAID_JS = (_CLI_DIR / "mermaid.min.js").read_text(encoding="utf-8") +_TEMPLATE = jinja2.Template( + (_CLI_DIR / "graph.html.jinja").read_text(encoding="utf-8"), + autoescape=False, +) def _levenshtein(a: str, b: str) -> int: @@ -122,9 +129,9 @@ def _build_html( per_bp_conflicts: list[list[dict[str, Any]]] = [] per_bp_typos: list[list[dict[str, object]]] = [] - tab_buttons = [] - tab_panels = [] - for idx, (name, bp) in enumerate(blueprints): + tab_buttons: list[dict[str, str]] = [] + tab_panels: list[dict[str, str]] = [] + for name, bp in blueprints: mermaid_code, label_colors, disconnected, node_colors = render_mermaid( bp, show_disconnected=show_disconnected, theme=theme ) @@ -189,342 +196,30 @@ def _build_html( ) per_bp_typos.append(typos) - active_cls = " active" if idx == 0 else "" - tab_buttons.append(f'') - tab_panels.append( - f'
' - f'
' - f'
\n{mermaid_code}\n
' - f"
" - ) - - all_label_colors_json = json.dumps(per_bp_label_colors) - all_disconnected_json = json.dumps([sorted(d) for d in per_bp_disconnected]) - all_conflicts_json = json.dumps(per_bp_conflicts) - all_typos_json = json.dumps(per_bp_typos) - - tab_bar_html = "" - if len(blueprints) > 1: - tab_bar_html = f'
{"".join(tab_buttons)}
' - - return f"""\ - - - -Blueprint Diagrams - - - -{tab_bar_html} -{"".join(tab_panels)} -
-
- - - -
- - -""" + tab_buttons.append({"name": name}) + tab_panels.append({"mermaid_code": mermaid_code}) + + return _TEMPLATE.render( + background=background, + text_color=text_color, + text_muted=text_muted, + text_bright=text_bright, + surface=surface, + surface_hover=surface_hover, + controls_bg=controls_bg, + controls_btn=controls_btn, + controls_border=controls_border, + border_color=border_color, + label_bg=label_bg, + mermaid_theme=mermaid_theme, + mermaid_js=_MERMAID_JS, + tab_buttons=tab_buttons, + tab_panels=tab_panels, + all_label_colors_json=json.dumps(per_bp_label_colors), + all_disconnected_json=json.dumps([sorted(d) for d in per_bp_disconnected]), + all_conflicts_json=json.dumps(per_bp_conflicts), + all_typos_json=json.dumps(per_bp_typos), + ) def print_markdown( From 73d3ce2445eccb28c7fdb60ea12738b990399c55 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 17:28:39 -0700 Subject: [PATCH 17/18] clean --- .gitattributes | 1 - data/.lfs/mermaid.min.js.tar.gz | 3 +++ dimos/core/introspection/{blueprint => }/mermaid.py | 0 dimos/utils/cli/graph.py | 5 +++-- dimos/utils/cli/mermaid.min.js | 3 --- .../test_mermaid_server.py => utils/cli/test_graph.py} | 4 ++-- .../cli/test_graph_blueprints.py} | 2 +- .../cli/test_graph_snapshot.html} | 0 8 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 data/.lfs/mermaid.min.js.tar.gz rename dimos/core/introspection/{blueprint => }/mermaid.py (100%) delete mode 100644 dimos/utils/cli/mermaid.min.js rename dimos/{core/introspection/blueprint/test_mermaid_server.py => utils/cli/test_graph.py} (96%) rename dimos/{core/introspection/blueprint/test_mermaid_blueprint.py => utils/cli/test_graph_blueprints.py} (97%) rename dimos/{core/introspection/blueprint/test_mermaid_server_snapshot.html => utils/cli/test_graph_snapshot.html} (100%) diff --git a/.gitattributes b/.gitattributes index 5ae63d79ff..e4139e83c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,4 +19,3 @@ docs/capabilities/memory/assets/** filter=lfs diff=lfs merge=lfs -text docs/capabilities/memory/assets/.gitattributes -filter -diff -merge text docs/capabilities/mapping/assets/** filter=lfs diff=lfs merge=lfs -text -dimos/utils/cli/mermaid.min.js filter=lfs diff=lfs merge=lfs -text diff --git a/data/.lfs/mermaid.min.js.tar.gz b/data/.lfs/mermaid.min.js.tar.gz new file mode 100644 index 0000000000..27599a64ca --- /dev/null +++ b/data/.lfs/mermaid.min.js.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:016ba263bb37555515227f55ed59f72cfe761110c00ef6c1b8aa0d88b9e6c20c +size 907963 diff --git a/dimos/core/introspection/blueprint/mermaid.py b/dimos/core/introspection/mermaid.py similarity index 100% rename from dimos/core/introspection/blueprint/mermaid.py rename to dimos/core/introspection/mermaid.py diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index bfbb210ae0..90fbae6340 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -32,10 +32,11 @@ import jinja2 from dimos.core.coordination.blueprints import Blueprint -from dimos.core.introspection.blueprint.mermaid import DEFAULT_THEME, THEMES, render_mermaid +from dimos.core.introspection.mermaid import DEFAULT_THEME, THEMES, render_mermaid +from dimos.utils.data import get_data _CLI_DIR = Path(__file__).parent -_MERMAID_JS = (_CLI_DIR / "mermaid.min.js").read_text(encoding="utf-8") +_MERMAID_JS = get_data("mermaid.min.js").read_text(encoding="utf-8") _TEMPLATE = jinja2.Template( (_CLI_DIR / "graph.html.jinja").read_text(encoding="utf-8"), autoescape=False, diff --git a/dimos/utils/cli/mermaid.min.js b/dimos/utils/cli/mermaid.min.js deleted file mode 100644 index cec11b200f..0000000000 --- a/dimos/utils/cli/mermaid.min.js +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:51e439abf12e72752be1b0d67ed80195fb8cd85f2c7af017d57c5c6b717b58d5 -size 3312888 diff --git a/dimos/core/introspection/blueprint/test_mermaid_server.py b/dimos/utils/cli/test_graph.py similarity index 96% rename from dimos/core/introspection/blueprint/test_mermaid_server.py rename to dimos/utils/cli/test_graph.py index f94b7186e3..be52d98619 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_server.py +++ b/dimos/utils/cli/test_graph.py @@ -26,7 +26,7 @@ from dimos.core.module import Module from dimos.core.stream import In, Out -SNAPSHOT_PATH = Path(__file__).with_name("test_mermaid_server_snapshot.html") +SNAPSHOT_PATH = Path(__file__).with_name("test_graph_snapshot.html") class ImageData: @@ -135,7 +135,7 @@ def log_message(self, format: str, *args: object) -> None: def test_graph_server_snapshot() -> None: - blueprint_file = str(Path(__file__).with_name("test_mermaid_blueprint.py")) + blueprint_file = str(Path(__file__).with_name("test_graph_blueprints.py")) from dimos.utils.cli.graph import _build_html diff --git a/dimos/core/introspection/blueprint/test_mermaid_blueprint.py b/dimos/utils/cli/test_graph_blueprints.py similarity index 97% rename from dimos/core/introspection/blueprint/test_mermaid_blueprint.py rename to dimos/utils/cli/test_graph_blueprints.py index f2c76a0c58..e7bc6b3b04 100644 --- a/dimos/core/introspection/blueprint/test_mermaid_blueprint.py +++ b/dimos/utils/cli/test_graph_blueprints.py @@ -20,7 +20,7 @@ from dimos.core.module import Module from dimos.core.stream import In, Out -SNAPSHOT_PATH = Path(__file__).with_name("test_mermaid_server_snapshot.html") +SNAPSHOT_PATH = Path(__file__).with_name("test_graph_snapshot.html") class ImageData: diff --git a/dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html b/dimos/utils/cli/test_graph_snapshot.html similarity index 100% rename from dimos/core/introspection/blueprint/test_mermaid_server_snapshot.html rename to dimos/utils/cli/test_graph_snapshot.html From ce08e51e43e9e00bcee56836c230156759946cad Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Sat, 30 May 2026 17:50:36 -0700 Subject: [PATCH 18/18] naming --- dimos/utils/cli/graph.html.jinja | 2 +- dimos/utils/cli/graph.py | 2 -- dimos/utils/cli/test_graph.py | 3 ++- dimos/utils/cli/test_graph_blueprints.py | 6 +++--- dimos/utils/cli/test_graph_snapshot.html | 8 ++++---- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/dimos/utils/cli/graph.html.jinja b/dimos/utils/cli/graph.html.jinja index db315554e4..88c9e39dd7 100644 --- a/dimos/utils/cli/graph.html.jinja +++ b/dimos/utils/cli/graph.html.jinja @@ -127,7 +127,7 @@ function renderWarnings(idx) { let html = ''; const conflicts = allConflicts[idx] || []; if (conflicts.length > 0) { - html += '
⚠ Possible Input Fighting
' + + html += '
⚠ Possible Stream Fighting
' + conflicts.map(c => `
` + `${c.topic} ` + diff --git a/dimos/utils/cli/graph.py b/dimos/utils/cli/graph.py index 90fbae6340..b80340d0cb 100644 --- a/dimos/utils/cli/graph.py +++ b/dimos/utils/cli/graph.py @@ -89,8 +89,6 @@ def _load_blueprints(python_file: str) -> list[tuple[str, Blueprint]]: blueprints: list[tuple[str, Blueprint]] = [] for name, obj in vars(module).items(): - if name.startswith("_"): - continue if isinstance(obj, Blueprint): blueprints.append((name, obj)) diff --git a/dimos/utils/cli/test_graph.py b/dimos/utils/cli/test_graph.py index be52d98619..fb8519cc5f 100644 --- a/dimos/utils/cli/test_graph.py +++ b/dimos/utils/cli/test_graph.py @@ -97,13 +97,14 @@ def _build_blueprint() -> Blueprint: def _normalize_html(html: str) -> str: - return re.sub( + html = re.sub( r"", "", html, count=1, flags=re.DOTALL, ) + return "\n".join(line.rstrip() for line in html.splitlines()) def _serve_and_fetch(html: str) -> str: diff --git a/dimos/utils/cli/test_graph_blueprints.py b/dimos/utils/cli/test_graph_blueprints.py index e7bc6b3b04..cdbf7ef99b 100644 --- a/dimos/utils/cli/test_graph_blueprints.py +++ b/dimos/utils/cli/test_graph_blueprints.py @@ -91,7 +91,7 @@ class VisualizerModule(Module): point_cloud: In[PointCloudData] -blueprint1 = autoconnect( +_blueprint1 = autoconnect( CameraModule.blueprint(), OdometryModule.blueprint(), PerceptionModule.blueprint(), @@ -100,14 +100,14 @@ class VisualizerModule(Module): VisualizerModule.blueprint(), ) -blueprint2 = autoconnect( +_blueprint2 = autoconnect( CameraModule.blueprint(), PlannerModule.blueprint(), ControllerModule.blueprint(), VisualizerModule.blueprint(), ) -blueprint3 = autoconnect( +_blueprint3 = autoconnect( CameraModule.blueprint(), OdometryModule.blueprint(), PerceptionModule.blueprint(), diff --git a/dimos/utils/cli/test_graph_snapshot.html b/dimos/utils/cli/test_graph_snapshot.html index e60f97c981..af90b1795f 100644 --- a/dimos/utils/cli/test_graph_snapshot.html +++ b/dimos/utils/cli/test_graph_snapshot.html @@ -72,11 +72,11 @@
- + - + - +
@@ -337,7 +337,7 @@ let html = ''; const conflicts = allConflicts[idx] || []; if (conflicts.length > 0) { - html += '
⚠ Possible Input Fighting
' + + html += '
⚠ Possible Stream Fighting
' + conflicts.map(c => `
` + `${c.topic} ` +