From 9cb7d68abec237d16aec9e1bcc88912b180b7649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Tue, 12 May 2026 10:27:07 +0800 Subject: [PATCH] feat: Dashboard workflow graph visualization with React Flow (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: API + static graph rendering Backend: - GET /workflows/:name now returns descriptor (with graph) from bundle YAML - Graceful fallback to null if YAML missing/invalid Frontend: - New workflow-graph/ component module (7 files) - React Flow + dagre auto-layout (TB direction) - Custom nodes: RoleNode (rounded rect) + TerminalNode (circle for START/END) - Custom edges: dashed for FALLBACK, solid with label for conditions - Self-loop edges supported (e.g. coder → coder) - Node states: default/completed/active with color-coded borders - Active node pulse animation - Collapsible graph panel (300px) above thread records - Dark theme using existing CSS variables Integration: - ThreadDetail extracts workflow name → fetches descriptor → computes node states → renders graph - Node states derived from ThreadRecord[] (completed/active/default) --- .../src/commands/serve/routes-workflow.ts | 17 ++- packages/workflow-dashboard/package.json | 2 + packages/workflow-dashboard/src/api.ts | 39 ++++++ .../src/components/thread-detail.tsx | 106 ++++++++++++++- .../workflow-graph/condition-edge.tsx | 76 +++++++++++ .../src/components/workflow-graph/index.ts | 2 + .../components/workflow-graph/role-node.tsx | 69 ++++++++++ .../workflow-graph/terminal-node.tsx | 57 ++++++++ .../src/components/workflow-graph/types.ts | 29 ++++ .../components/workflow-graph/use-layout.ts | 127 ++++++++++++++++++ .../workflow-graph/workflow-graph.tsx | 61 +++++++++ packages/workflow-dashboard/src/index.css | 14 ++ 12 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx create mode 100644 packages/workflow-dashboard/src/components/workflow-graph/index.ts create mode 100644 packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx create mode 100644 packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx create mode 100644 packages/workflow-dashboard/src/components/workflow-graph/types.ts create mode 100644 packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts create mode 100644 packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx diff --git a/packages/cli-workflow/src/commands/serve/routes-workflow.ts b/packages/cli-workflow/src/commands/serve/routes-workflow.ts index d06a39d..34c91b2 100644 --- a/packages/cli-workflow/src/commands/serve/routes-workflow.ts +++ b/packages/cli-workflow/src/commands/serve/routes-workflow.ts @@ -1,9 +1,14 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { WorkflowDescriptor } from "@uncaged/workflow-protocol"; import { getRegisteredWorkflow, listRegisteredWorkflowNames, readWorkflowRegistry, + validateWorkflowDescriptor, } from "@uncaged/workflow-register"; import { Hono } from "hono"; +import { parse as parseYaml } from "yaml"; export function createWorkflowRoutes(storageRoot: string): Hono { const app = new Hono(); @@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono { if (entry === null) { return c.json({ error: `workflow not found: ${name}` }, 404); } - return c.json({ name, ...entry }); + let descriptor: WorkflowDescriptor | null = null; + try { + const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`); + const yamlText = await readFile(yamlPath, "utf8"); + const parsed: unknown = parseYaml(yamlText); + const validated = validateWorkflowDescriptor(parsed); + descriptor = validated.ok ? validated.value : null; + } catch { + descriptor = null; + } + return c.json({ name, ...entry, descriptor }); }); app.get("/:name/history", async (c) => { diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json index 4a1d32a..75cba22 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", + "@xyflow/react": "^12.10.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-markdown": "^10.1.0", diff --git a/packages/workflow-dashboard/src/api.ts b/packages/workflow-dashboard/src/api.ts index d3b1714..4cdab22 100644 --- a/packages/workflow-dashboard/src/api.ts +++ b/packages/workflow-dashboard/src/api.ts @@ -104,6 +104,36 @@ export type WorkflowResultRecord = { export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord; +export type WorkflowGraphEdge = { + from: string; + to: string; + condition: string; + conditionDescription: string | null; +}; + +export type WorkflowGraph = { + edges: readonly WorkflowGraphEdge[]; +}; + +export type WorkflowRoleDescriptor = { + description: string; + schema: Record; +}; + +export type WorkflowDescriptor = { + description: string; + roles: Record; + graph: WorkflowGraph; +}; + +export type WorkflowDetail = { + name: string; + hash: string; + timestamp: number; + history: unknown[]; + descriptor: WorkflowDescriptor | null; +}; + // ── Gateway endpoints ─────────────────────────────────────────────── export function listAgents(): Promise { @@ -117,6 +147,15 @@ export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSumma return fetchJson(agentBase(agent), "/workflows"); } +export function getWorkflowDescriptor( + agent: string, + name: string, +): Promise { + return fetchJson(agentBase(agent), `/workflows/${encodeURIComponent(name)}`).then( + (res) => res.descriptor, + ); +} + export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> { return fetchJson(agentBase(agent), "/threads"); } diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index 4127759..3e4b822 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -1,8 +1,17 @@ -import { useEffect, useRef, useState } from "react"; -import { getThread, killThread, pauseThread, resumeThread } from "../api.ts"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + getThread, + getWorkflowDescriptor, + killThread, + pauseThread, + resumeThread, + type ThreadRecord, + type WorkflowDescriptor, +} from "../api.ts"; import { useFetch } from "../hooks.ts"; import { useSSE } from "../use-sse.ts"; import { RecordCard } from "./record-card.tsx"; +import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts"; type Props = { agent: string; @@ -10,6 +19,84 @@ type Props = { onBack: () => void; }; +function extractWorkflowName(records: readonly ThreadRecord[]): string | null { + for (const r of records) { + if (r.type === "thread-start") return r.workflow; + } + return null; +} + +type GraphPanelProps = { + descriptor: WorkflowDescriptor; + workflowName: string | null; + nodeStates: Map; +}; + +function GraphPanel({ descriptor, workflowName, nodeStates }: GraphPanelProps) { + const [open, setOpen] = useState(true); + const edgeCount = descriptor.graph.edges.length; + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} + +function computeNodeStates(records: readonly ThreadRecord[]): Map { + const states = new Map(); + const roleRecords = records.filter( + (r): r is Extract => r.type === "role", + ); + const hasResult = records.some((r) => r.type === "workflow-result"); + + for (let i = 0; i < roleRecords.length; i++) { + const role = roleRecords[i].role; + const isLast = i === roleRecords.length - 1; + states.set(role, !hasResult && isLast ? "active" : "completed"); + } + + if (roleRecords.length > 0) { + states.set("__start__", "completed"); + } + if (hasResult) { + states.set("__end__", "completed"); + for (const [k, v] of states) { + if (v === "active") states.set(k, "completed"); + } + } + + return states; +} + export function ThreadDetail({ agent, threadId, onBack }: Props) { const sse = useSSE(agent, threadId); const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]); @@ -23,6 +110,17 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) { ? data.records : ([] as typeof sse.records); + const workflowName = useMemo(() => extractWorkflowName(records), [records]); + + const descriptorFetch = useFetch( + () => + workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName), + [agent, workflowName], + ); + + const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null; + const nodeStates = useMemo(() => computeNodeStates(records), [records]); + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows useEffect(() => { recordsEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -95,6 +193,10 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {

)} + {descriptor !== null && descriptor.graph.edges.length > 0 && ( + + )} + {status === "loading" && !liveActive && records.length === 0 && (

Loading...

)} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx new file mode 100644 index 0000000..28fb962 --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx @@ -0,0 +1,76 @@ +import { + BaseEdge, + EdgeLabelRenderer, + type EdgeProps, + getBezierPath, + getSmoothStepPath, +} from "@xyflow/react"; +import type { ConditionEdgeData } from "./types.ts"; + +export function ConditionEdge(props: EdgeProps) { + const { + id, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd, + } = props; + const edgeData = data as ConditionEdgeData | undefined; + const isFallback = edgeData?.isFallback ?? false; + const isSelfLoop = source === target; + + const [path, labelX, labelY] = isSelfLoop + ? getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 20, + }) + : getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)"; + const strokeDasharray = isFallback ? "5 4" : undefined; + + return ( + <> + + {edgeData && !isFallback && edgeData.condition !== "" && ( + +
+ {edgeData.condition} +
+
+ )} + + ); +} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/index.ts b/packages/workflow-dashboard/src/components/workflow-graph/index.ts new file mode 100644 index 0000000..797015e --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-graph/index.ts @@ -0,0 +1,2 @@ +export type { NodeState } from "./types.ts"; +export { WorkflowGraph } from "./workflow-graph.tsx"; diff --git a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx new file mode 100644 index 0000000..ae73cde --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx @@ -0,0 +1,69 @@ +import { Handle, type NodeProps, Position } from "@xyflow/react"; +import type { RoleNodeData } from "./types.ts"; + +function borderColor(state: RoleNodeData["state"]): string { + switch (state) { + case "completed": + return "var(--color-success)"; + case "active": + return "var(--color-accent)"; + default: + return "var(--color-border)"; + } +} + +function stateIcon(state: RoleNodeData["state"]): string | null { + if (state === "completed") return "✓"; + if (state === "active") return "●"; + return null; +} + +export function RoleNode(props: NodeProps) { + const data = props.data as RoleNodeData; + const icon = stateIcon(data.state); + const isActive = data.state === "active"; + const handleStyle = { + background: "var(--color-text-muted)", + width: 6, + height: 6, + border: "none", + } as const; + + return ( +
+ +
+ {icon !== null && ( + + {icon} + + )} + {data.label} +
+ {data.description !== "" && ( +
+ {data.description} +
+ )} + +
+ ); +} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx new file mode 100644 index 0000000..4adea67 --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx @@ -0,0 +1,57 @@ +import { Handle, type NodeProps, Position } from "@xyflow/react"; +import type { TerminalNodeData } from "./types.ts"; + +function borderColor(state: TerminalNodeData["state"]): string { + switch (state) { + case "completed": + return "var(--color-success)"; + case "active": + return "var(--color-accent)"; + default: + return "var(--color-border)"; + } +} + +function bgColor(state: TerminalNodeData["state"]): string { + if (state === "completed") return "var(--color-success)"; + if (state === "active") return "var(--color-accent)"; + return "var(--color-surface)"; +} + +export function TerminalNode(props: NodeProps) { + const data = props.data as TerminalNodeData; + const isStart = data.kind === "start"; + const isActive = data.state === "active"; + const handleStyle = { + background: "var(--color-text-muted)", + width: 6, + height: 6, + border: "none", + } as const; + + return ( +
+ {isStart ? ( + + ) : ( + + )} + {isStart ? "▶" : "■"} +
+ ); +} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/types.ts b/packages/workflow-dashboard/src/components/workflow-graph/types.ts new file mode 100644 index 0000000..0c509ce --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-graph/types.ts @@ -0,0 +1,29 @@ +import type { WorkflowGraphEdge } from "../../api.ts"; + +export type NodeState = "default" | "completed" | "active"; + +export type TerminalKind = "start" | "end"; + +export type RoleNodeData = { + label: string; + description: string; + state: NodeState; + [key: string]: unknown; +}; + +export type TerminalNodeData = { + kind: TerminalKind; + state: NodeState; + [key: string]: unknown; +}; + +export type ConditionEdgeData = { + condition: string; + conditionDescription: string | null; + isFallback: boolean; + [key: string]: unknown; +}; + +export type GraphInput = { + edges: readonly WorkflowGraphEdge[]; +}; diff --git a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts new file mode 100644 index 0000000..5dd64fd --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts @@ -0,0 +1,127 @@ +import Dagre from "@dagrejs/dagre"; +import type { Edge, Node } from "@xyflow/react"; +import { useMemo } from "react"; +import type { WorkflowGraphEdge } from "../../api.ts"; +import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts"; + +const START_ID = "__start__"; +const END_ID = "__end__"; +const ROLE_NODE_WIDTH = 180; +const ROLE_NODE_HEIGHT = 60; +const TERMINAL_NODE_SIZE = 40; + +type LayoutInput = { + edges: readonly WorkflowGraphEdge[]; + roles: Record; + nodeStates: Map; +}; + +type LayoutResult = { + nodes: Node[]; + edges: Edge[]; +}; + +function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set { + const ids = new Set(); + for (const e of edges) { + ids.add(e.from); + ids.add(e.to); + } + return ids; +} + +function nodeSize(id: string): { width: number; height: number } { + if (id === START_ID || id === END_ID) { + return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE }; + } + return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT }; +} + +function buildRoleNode( + id: string, + pos: { x: number; y: number }, + roles: Record, + state: NodeState, +): Node { + const description = roles[id]?.description ?? ""; + return { + id, + type: "role", + position: pos, + data: { label: id, description, state }, + draggable: false, + }; +} + +function buildTerminalNode( + id: string, + pos: { x: number; y: number }, + state: NodeState, +): Node { + return { + id, + type: "terminal", + position: pos, + data: { kind: id === START_ID ? "start" : "end", state }, + draggable: false, + selectable: false, + }; +} + +function edgeKey(e: WorkflowGraphEdge): string { + return `${e.from}->${e.to}::${e.condition}`; +} + +function buildEdge(e: WorkflowGraphEdge): Edge { + const isFallback = e.condition === "FALLBACK"; + return { + id: edgeKey(e), + source: e.from, + target: e.to, + type: "condition", + data: { + condition: e.condition, + conditionDescription: e.conditionDescription, + isFallback, + }, + }; +} + +export function useLayout(input: LayoutInput): LayoutResult { + return useMemo(() => { + const ids = collectNodeIds(input.edges); + + const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 }); + + for (const id of ids) { + const size = nodeSize(id); + g.setNode(id, { width: size.width, height: size.height }); + } + for (const e of input.edges) { + if (e.from === e.to) { + continue; + } + g.setEdge(e.from, e.to, {}, edgeKey(e)); + } + + Dagre.layout(g); + + const nodes: Node[] = []; + for (const id of ids) { + const dagNode = g.node(id); + const size = nodeSize(id); + const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 }; + const state = input.nodeStates.get(id) ?? "default"; + if (id === START_ID || id === END_ID) { + nodes.push(buildTerminalNode(id, pos, state)); + } else { + nodes.push(buildRoleNode(id, pos, input.roles, state)); + } + } + + const edges: Edge[] = input.edges.map(buildEdge); + + return { nodes, edges }; + }, [input.edges, input.roles, input.nodeStates]); +} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx new file mode 100644 index 0000000..132de10 --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx @@ -0,0 +1,61 @@ +import { Background, type EdgeTypes, MarkerType, type NodeTypes, ReactFlow } from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { useMemo } from "react"; +import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts"; +import { ConditionEdge } from "./condition-edge.tsx"; +import { RoleNode } from "./role-node.tsx"; +import { TerminalNode } from "./terminal-node.tsx"; +import type { NodeState } from "./types.ts"; +import { useLayout } from "./use-layout.ts"; + +type Props = { + graph: WorkflowGraphData; + roles: Record; + nodeStates: Map; +}; + +const nodeTypes: NodeTypes = { + role: RoleNode, + terminal: TerminalNode, +}; + +const edgeTypes: EdgeTypes = { + condition: ConditionEdge, +}; + +export function WorkflowGraph({ graph, roles, nodeStates }: Props) { + const layout = useLayout({ edges: graph.edges, roles, nodeStates }); + + const styledEdges = useMemo( + () => + layout.edges.map((e) => ({ + ...e, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 14, + height: 14, + color: "var(--color-text)", + }, + })), + [layout.edges], + ); + + return ( + + + + ); +} diff --git a/packages/workflow-dashboard/src/index.css b/packages/workflow-dashboard/src/index.css index 537e9ea..7c44386 100644 --- a/packages/workflow-dashboard/src/index.css +++ b/packages/workflow-dashboard/src/index.css @@ -19,3 +19,17 @@ body { color: var(--color-text); font-family: "Inter", system-ui, -apple-system, sans-serif; } + +@keyframes wf-node-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55); + } + 50% { + box-shadow: 0 0 0 6px rgba(124, 109, 240, 0); + } +} + +.wf-node-pulse { + animation: wf-node-pulse 1.6s ease-in-out infinite; +}