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 ( + + ); + }); +}