diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 0c41ded..2c83634 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -726,6 +726,8 @@ add_container: children stacked along one axis. dir "row" side by side, "col" on add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame. +add_graph: an ARROW-ORDERED zone inside a nested diagram. Takes nodes+edges like draw_graph; the edges decide layering and ordering, and the resulting block joins the outer layout like any node. Use it when one region's contents follow a flow — a pipeline zone in an architecture diagram, a small flowchart in a poster column. dir: "col" (default) flows down, "row" flows right. + add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Set orientation to "vertical" for vertical swimlanes, where the lanes become columns and the flow runs downwards. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns. {"operations":[ {"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]}, diff --git a/lib/diagram-engine/graph.ts b/lib/diagram-engine/graph.ts index 12cb5a1..12f3f9a 100644 --- a/lib/diagram-engine/graph.ts +++ b/lib/diagram-engine/graph.ts @@ -61,6 +61,16 @@ export interface GraphEdge { export interface GraphOptions { /** "col" (default): layers stack downwards. "row": layers run left to right. */ flow?: "col" | "row" + /** Container to embed the graph in; absent means the page. */ + parent?: string + /** + * Namespace for the synthetic layer-container ids. Without one, two graphs on one + * page would both emit `__layers`/`__layer0` and the second would be rejected as a + * duplicate id. + */ + prefix?: string + /** Id for the outer container itself; defaults to `${prefix}__layers`. */ + rootId?: string } /** Distance between layers. */ @@ -305,12 +315,14 @@ export function graphToOperations( // The flow axis is the OUTER container's direction; a layer runs across it. const outerDir = flow const layerDir = flow === "col" ? "row" : "col" - const root = `${LAYER_ID}s` + const ns = opts.prefix ?? "" + const root = opts.rootId ?? `${ns}${LAYER_ID}s` const operations: Operation[] = [ { op: "add_container", id: root, + ...(opts.parent ? { parent: opts.parent } : {}), label: "", dir: outerDir, gap: LAYER_GAP, @@ -344,7 +356,7 @@ export function graphToOperations( operations.push(add(members[0], root)) return } - const band = `${LAYER_ID}${i}` + const band = `${ns}${LAYER_ID}${i}` operations.push({ op: "add_container", id: band, diff --git a/lib/diagram-engine/operations.ts b/lib/diagram-engine/operations.ts index 1ca1237..7a744f3 100644 --- a/lib/diagram-engine/operations.ts +++ b/lib/diagram-engine/operations.ts @@ -11,6 +11,8 @@ */ import { z } from "zod" +// A runtime import while graph.ts imports only TYPES from here — no cycle at runtime. +import { graphToOperations } from "./graph" import { type ContainerNode, type DiagramNode, @@ -173,6 +175,62 @@ export const OperationSchema = z.discriminatedUnion("op", [ ), after: z.string().optional(), }), + z.object({ + op: z.literal("add_graph"), + id: z.string(), + parent: z + .string() + .optional() + .describe("Container to embed the graph in; omit for top level"), + label: z.string().optional().describe("Frame title; omit for none"), + dir: z + .enum(["col", "row"]) + .optional() + .describe( + "Flow direction: col (default) downwards, row rightwards", + ), + nodes: z + .array( + z.object({ + id: z.string(), + label: z.string(), + shape: z.string().optional(), + icon: z.string().optional(), + group: z.string().optional(), + role: z + .enum([ + "banner", + "heading", + "body", + "callout", + "good", + "bad", + "metric", + "muted", + ]) + .optional(), + }), + ) + .describe("The graph's nodes"), + edges: z + .array( + z.object({ + source: z.string(), + target: z.string(), + label: z.string().optional(), + dashed: z.boolean().optional(), + bold: z.boolean().optional(), + head: z.string().optional(), + tail: z.string().optional(), + headFill: z.boolean().optional(), + tailFill: z.boolean().optional(), + }), + ) + .describe( + "The arrows. THEY decide the node positions — layering and ordering are computed from them", + ), + after: z.string().optional(), + }), z.object({ op: z.literal("add_grid"), id: z.string(), @@ -459,7 +517,40 @@ export function applyOperations( const exists = (id: string) => findNode(tree, id) !== null + // add_graph is a macro: the layered-graph pass (graph.ts) decides which layer each + // node belongs to and who stands beside whom, and emits ordinary container/box/link + // operations. Expanding it HERE — rather than treating graphs as a special page-level + // tool — is what lets a graph sit inside a poster column or an architecture zone and + // still participate in the outer flexbox like any other node. + const expanded: Operation[] = [] for (const op of ops) { + if (op.op !== "add_graph") { + expanded.push(op) + continue + } + const g = graphToOperations(op.nodes, op.edges, { + flow: op.dir ?? "col", + parent: op.parent, + // The graph's own id namespaces the synthetic layer containers, so two + // graphs on one page cannot collide on `__layer0`. + prefix: op.id, + rootId: op.id, + }) + if (g.unknownEndpoints.length) + errors.push( + `add_graph "${op.id}": edge endpoint(s) not in nodes: ${g.unknownEndpoints.join(", ")}`, + ) + if (op.label || op.after) { + const root = g.operations[0] + if (root?.op === "add_container") { + if (op.label) root.label = op.label + if (op.after) root.after = op.after + } + } + expanded.push(...g.operations) + } + + for (const op of expanded) { switch (op.op) { case "add_icon": case "add_box": diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts index d6443c6..d4f1cbd 100644 --- a/lib/system-prompts.ts +++ b/lib/system-prompts.ts @@ -96,6 +96,12 @@ Use draw_graph when the diagram is boxes joined by arrows and the arrows define them — a flowchart written as XML or as nested containers comes out as one column, which forces every branch to jump over the step beside it. +Use restructure_diagram's add_graph when ONE ZONE of a nested diagram is arrow-ordered: + an architecture diagram where a zone's contents follow the data flow, a poster column with + a small flowchart in it. add_graph takes nodes+edges like draw_graph, lays them out inside + its container, and the container joins the outer layout like any node (dir: col flows + down, row flows right). + Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed frame: - Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils first. - Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col. diff --git a/tests/unit/diagram-engine-embed-graph.test.ts b/tests/unit/diagram-engine-embed-graph.test.ts new file mode 100644 index 0000000..a67d528 --- /dev/null +++ b/tests/unit/diagram-engine-embed-graph.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest" +import { restructureDiagram } from "@/lib/diagram-engine" + +/** + * add_graph: arrow-driven layout as a CONTAINER, not just a page-level tool. + * + * D2/TALA's core claim is that containers are first-class at every layout stage — + * hierarchical zones and arrow-ordered graphs mix in one diagram. Before this, the + * engine was split: draw_graph did whole-page flowcharts, containers did nesting, and + * "a poster column with a small flowchart in it" was inexpressible. + */ + +const rectOf = (xml: string, id: string) => { + const m = xml.match( + new RegExp( + `id="${id}"[^>]*>\\s*]*width="([\\d.]+)" height="([\\d.]+)"`, + ), + ) + if (!m) throw new Error(`no geometry for ${id}`) + return { w: Number(m[1]), h: Number(m[2]) } +} + +const FLOW = { + nodes: [ + { id: "a", label: "request" }, + { id: "b", label: "validate", shape: "decision" }, + { id: "c", label: "process" }, + { id: "d", label: "reject" }, + ], + edges: [ + { source: "a", target: "b" }, + { source: "b", target: "c", label: "ok" }, + { source: "b", target: "d", label: "no" }, + ], +} + +describe("add_graph", () => { + it("embeds an arrow-ordered graph inside a flexbox column", () => { + const r = restructureDiagram("", [ + { op: "add_container", id: "page", label: "", dir: "row", gap: 20 }, + { + op: "add_container", + id: "left", + parent: "page", + label: "", + dir: "col", + gap: 12, + }, + { + op: "add_box", + id: "intro", + parent: "left", + label: "How requests flow:", + }, + { op: "add_graph", id: "flow", parent: "left", ...FLOW }, + { op: "add_box", id: "right", parent: "page", label: "Notes" }, + ]) + expect(r.errors).toEqual([]) + const xml = r.xml as string + // The decision's two branches share a layer — arrow-driven, not declaration order. + const c = rectOf(xml, "c") + const d = rectOf(xml, "d") + expect(c).toBeTruthy() + expect(d).toBeTruthy() + // And the graph sits inside the column: the outline nests flow under left. + expect(r.outline).toMatch(/left:[\s\S]*flow:/) + }) + + it("two graphs on one page do not collide on synthetic layer ids", () => { + const r = restructureDiagram("", [ + { op: "add_graph", id: "g1", ...FLOW }, + { + op: "add_graph", + id: "g2", + nodes: [ + { id: "x", label: "start" }, + { id: "y", label: "end" }, + ], + edges: [{ source: "x", target: "y" }], + }, + ]) + expect(r.errors).toEqual([]) + expect(r.xml).toContain('id="g1__layer') + expect(r.xml).toContain('id="g2"') + }) + + it("dir=row transposes the flow", () => { + const make = (dir: "col" | "row") => + restructureDiagram("", [{ op: "add_graph", id: "g", dir, ...FLOW }]) + .xml as string + const down = rectOf(make("col"), "g") + const right = rectOf(make("row"), "g") + // Four layers tall vs four layers wide. + expect(down.h).toBeGreaterThan(down.w * 0.8) + expect(right.w).toBeGreaterThan(right.h) + }) + + it("reports unknown edge endpoints as an error", () => { + const r = restructureDiagram("", [ + { + op: "add_graph", + id: "g", + nodes: [{ id: "a", label: "a" }], + edges: [{ source: "a", target: "ghost" }], + }, + ]) + expect(r.errors.join(" ")).toContain("ghost") + }) +})