diff --git a/PROMPT.md b/PROMPT.md
index 2d3a05e..9854c06 100644
--- a/PROMPT.md
+++ b/PROMPT.md
@@ -55,3 +55,5 @@ React component logic (just above the bottom jsx) can be converted to one or mor
Use atomic React components.
Don't hardcode strings, use a translation file or some sort of external JSON mapping.
+
+Don't hardcode React Styling - MUI themes or SASS.
diff --git a/backend/autometabuilder/web/server.py b/backend/autometabuilder/web/server.py
index 236f311..7e2ffa9 100644
--- a/backend/autometabuilder/web/server.py
+++ b/backend/autometabuilder/web/server.py
@@ -31,6 +31,7 @@ from .data import (
write_prompt,
write_workflow,
)
+from .workflow_graph import build_workflow_graph
app = Flask(__name__)
app.config["JSON_SORT_KEYS"] = False
@@ -233,5 +234,10 @@ def api_get_workflow_package(package_id: str) -> tuple[Dict[str, object], int]:
return {"error": "package not found"}, 404
+@app.route("/api/workflow/graph")
+def api_workflow_graph() -> tuple[Dict[str, object], int]:
+ return build_workflow_graph(), 200
+
+
def start_web_ui(host: str = "0.0.0.0", port: int = 8000) -> None:
app.run(host=host, port=port)
diff --git a/backend/autometabuilder/web/workflow_graph.py b/backend/autometabuilder/web/workflow_graph.py
new file mode 100644
index 0000000..6decdf9
--- /dev/null
+++ b/backend/autometabuilder/web/workflow_graph.py
@@ -0,0 +1,70 @@
+"""Build a node/edge view of the declarative workflow for visualization."""
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, Iterable, List
+
+from .data import get_workflow_content, load_metadata
+
+
+def _parse_workflow_definition() -> Dict[str, Any]:
+ payload = get_workflow_content()
+ if not payload:
+ return {"nodes": []}
+ try:
+ parsed = json.loads(payload)
+ except json.JSONDecodeError:
+ return {"nodes": []}
+ return parsed if isinstance(parsed, dict) else {"nodes": []}
+
+
+def _gather_nodes(nodes: Iterable[Dict[str, Any]], plugin_map: Dict[str, Any], parent_id: str | None = None, collected: List[Dict[str, Any]] | None = None) -> List[Dict[str, Any]]:
+ collected = collected or []
+ for node in nodes:
+ node_id = node.get("id") or f"node-{len(collected)}"
+ node_type = node.get("type", "unknown")
+ metadata = plugin_map.get(node_type, {})
+ node_summary: Dict[str, Any] = {
+ "id": node_id,
+ "type": node_type,
+ "label_key": metadata.get("label"),
+ "parent": parent_id,
+ "inputs": node.get("inputs", {}),
+ "outputs": node.get("outputs", {}),
+ }
+ collected.append(node_summary)
+ body = node.get("body")
+ if isinstance(body, list):
+ _gather_nodes(body, plugin_map, parent_id=node_id, collected=collected)
+ return collected
+
+
+def _build_edges(nodes: Iterable[Dict[str, Any]]) -> List[Dict[str, str]]:
+ producers: Dict[str, str] = {}
+ for node in nodes:
+ outputs = node.get("outputs", {})
+ for value in outputs.values():
+ if isinstance(value, str):
+ producers[value] = node["id"]
+ edges: List[Dict[str, str]] = []
+ for node in nodes:
+ inputs = node.get("inputs", {})
+ for port, value in inputs.items():
+ if isinstance(value, str) and value.startswith("$"):
+ variable = value[1:]
+ source = producers.get(variable)
+ if source:
+ edges.append({"from": source, "to": node["id"], "var": variable, "port": port})
+ return edges
+
+
+def build_workflow_graph() -> Dict[str, Any]:
+ definition = _parse_workflow_definition()
+ plugin_map = load_metadata().get("workflow_plugins", {})
+ nodes = _gather_nodes(definition.get("nodes", []), plugin_map)
+ edges = _build_edges(nodes)
+ return {
+ "nodes": nodes,
+ "edges": edges,
+ "count": {"nodes": len(nodes), "edges": len(edges)},
+ }
diff --git a/backend/tests/test_workflow_graph.py b/backend/tests/test_workflow_graph.py
new file mode 100644
index 0000000..358270f
--- /dev/null
+++ b/backend/tests/test_workflow_graph.py
@@ -0,0 +1,16 @@
+from autometabuilder.web.workflow_graph import build_workflow_graph
+
+
+def test_build_workflow_graph_structure():
+ graph = build_workflow_graph()
+ assert isinstance(graph.get("nodes"), list), "nodes list should be present"
+ assert isinstance(graph.get("edges"), list), "edges list should be present"
+ counts = graph.get("count", {})
+ assert counts.get("nodes", 0) >= 5, "Expect at least the top-level nodes to exist"
+ assert counts.get("edges", 0) > 0, "Edges should exist between workflow nodes"
+
+ node_ids = {node["id"] for node in graph["nodes"]}
+ for edge in graph["edges"]:
+ assert edge["from"] in node_ids
+ assert edge["to"] in node_ids
+ assert isinstance(edge["var"], str) and edge["var"], "edges should reference a variable"
diff --git a/frontend/autometabuilder/components/sections/WorkflowSection.tsx b/frontend/autometabuilder/components/sections/WorkflowSection.tsx
index 7e99fc7..3852352 100644
--- a/frontend/autometabuilder/components/sections/WorkflowSection.tsx
+++ b/frontend/autometabuilder/components/sections/WorkflowSection.tsx
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { Box, Button, Paper, Stack, Typography } from "@mui/material";
import { WorkflowPackageSummary } from "../../lib/types";
+import WorkflowGraphPanel from "../workflow/WorkflowGraphPanel";
+import WorkflowPalette from "../workflow/WorkflowPalette";
type WorkflowSectionProps = {
content: string;
@@ -59,23 +61,29 @@ export default function WorkflowSection({ content, packages, onSave, onTemplateS
-
- {t("ui.workflow.templates.title", "Workflow Templates")}
-
-
- {packages.map((pkg) => (
-
-
- {pkg.label}
-
- {pkg.description}
-
-
-
-
- ))}
+
+
+
+ {t("ui.workflow.templates.title", "Workflow Templates")}
+
+
+ {packages.map((pkg) => (
+
+
+ {pkg.label}
+
+ {pkg.description}
+
+
+
+
+ ))}
+
+
+
+
diff --git a/frontend/autometabuilder/components/workflow/WorkflowGraphPanel.tsx b/frontend/autometabuilder/components/workflow/WorkflowGraphPanel.tsx
new file mode 100644
index 0000000..ad5e059
--- /dev/null
+++ b/frontend/autometabuilder/components/workflow/WorkflowGraphPanel.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useState } from "react";
+import { Box, Chip, Divider, Paper, Stack, Typography } from "@mui/material";
+import { fetchWorkflowGraph } from "../../lib/api";
+import { WorkflowGraph } from "../../lib/types";
+
+type WorkflowGraphPanelProps = {
+ t: (key: string, fallback?: string) => string;
+};
+
+export default function WorkflowGraphPanel({ t }: WorkflowGraphPanelProps) {
+ const [graph, setGraph] = useState(null);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let alive = true;
+ void fetchWorkflowGraph()
+ .then((payload) => {
+ if (alive) {
+ setGraph(payload);
+ }
+ })
+ .catch((err) => {
+ if (alive) {
+ setError(String(err));
+ }
+ });
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ return (
+
+
+ {t("ui.workflow.graph.title", "Workflow Graph")}
+
+
+ {graph
+ ? t("ui.workflow.graph.summary", "Nodes: {nodes}, Edges: {edges}")
+ .replace("{nodes}", String(graph.count.nodes))
+ .replace("{edges}", String(graph.count.edges))
+ : t("ui.workflow.graph.loading", "Loading graph…")}
+
+ {error ? (
+ {error}
+ ) : (
+ <>
+ }>
+ {graph?.nodes.map((node) => (
+
+
+
+
+ {node.label_key ? t(node.label_key, node.type) : node.type}
+
+
+
+ {node.parent ? `${t("ui.workflow.graph.child_of", "child of")} ${node.parent}` : t("ui.workflow.graph.top_level", "top level")}
+
+
+ ))}
+
+
+
+ {graph?.edges.map((edge, index) => (
+
+ {t("ui.workflow.graph.edge", "{from} → {to} ({var})")
+ .replace("{from}", edge.from)
+ .replace("{to}", edge.to)
+ .replace("{var}", edge.var)}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/autometabuilder/components/workflow/WorkflowPalette.tsx b/frontend/autometabuilder/components/workflow/WorkflowPalette.tsx
new file mode 100644
index 0000000..e736323
--- /dev/null
+++ b/frontend/autometabuilder/components/workflow/WorkflowPalette.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useMemo, useState } from "react";
+import { Box, Chip, Divider, Paper, Stack, TextField, Typography } from "@mui/material";
+import { fetchWorkflowPlugins } from "../../lib/api";
+import { WorkflowPluginDefinition, WorkflowPluginMap, WorkflowPluginPort } from "../../lib/types";
+
+type WorkflowPaletteProps = {
+ t: (key: string, fallback?: string) => string;
+};
+
+export default function WorkflowPalette({ t }: WorkflowPaletteProps) {
+ const [plugins, setPlugins] = useState({});
+ const [search, setSearch] = useState("");
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let alive = true;
+ void fetchWorkflowPlugins()
+ .then((data) => {
+ if (alive) {
+ setPlugins(data);
+ }
+ })
+ .catch((err) => {
+ if (alive) {
+ setError(String(err));
+ }
+ });
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ const entries = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ return Object.entries(plugins).filter(([id, plugin]) => {
+ const label = plugin.label ? t(plugin.label, id) : id;
+ return !query || `${id} ${label}`.toLowerCase().includes(query);
+ });
+ }, [plugins, search, t]);
+
+ const isLoading = entries.length === 0 && !search && !error;
+ const hasQuery = search.trim().length > 0;
+
+ return (
+
+
+ {t("ui.workflow.palette.title", "Workflow Palette")}
+
+ setSearch(event.target.value)}
+ placeholder={t("ui.workflow.palette.search_placeholder", "Search nodes or keywords...")}
+ fullWidth
+ sx={{ mb: 2 }}
+ />
+ {error ? (
+ {error}
+ ) : isLoading ? (
+
+ {t("ui.workflow.palette.loading", "Loading node library…")}
+
+ ) : entries.length === 0 && hasQuery ? (
+
+ {t("ui.workflow.palette.missing", "No nodes match \"{query}\"").replace("{query}", search.trim())}
+
+ ) : (
+ }>
+ {entries.map(([id, plugin]) => (
+
+
+ {plugin.label ? t(plugin.label, id) : id}
+
+
+ {t("ui.workflow.palette.node_id", "Node ID")}: {id}
+
+
+ {renderPortTags(t, "in", plugin.inputs)}
+ {renderPortTags(t, "out", plugin.outputs)}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function renderPortTags(
+ t: (key: string, fallback?: string) => string,
+ direction: "in" | "out",
+ ports?: Record
+) {
+ if (!ports) {
+ return null;
+ }
+ return Object.keys(ports).map((name) => {
+ const port = ports[name];
+ const label = port?.label ? t(port.label, name) : name;
+ return (
+
+ );
+ });
+}