feat(diagram-engine): wire up restructure_diagram + stencil catalog

Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.

catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.

operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.

index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.

Token cost, measured with Claude's tokenizer rather than estimated:
  - build a VPC diagram:  515 tok as operations vs 3180 as XML   (6.2x)
  - add one icon:          27 tok as an operation vs 3823 re-emitting (142x)
  - read current state:   216 tok as an outline vs 3180 as XML   (14.7x)

The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.

Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.

Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.

403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
This commit is contained in:
dayuan.jiang
2026-08-09 12:13:54 +09:00
parent a2f892ca82
commit cd1df1eb6a
14 changed files with 3475 additions and 7 deletions

120
lib/diagram-engine/index.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* The engine's entry point: one call takes the current canvas XML plus a list of
* structural operations and returns new canvas XML.
*
* current XML → parse → apply operations → check names → layout → render → new XML
*
* The tree is not stored anywhere between calls. It is re-derived from the canvas every
* time, so a user's manual edits — moving a shape into a different frame, recolouring a
* box, adding an annotation — are simply part of the input to the next layout. There is
* no second copy of the state, and therefore nothing to reconcile.
*/
import { checkNames, resolveStyle } from "./catalog"
import {
applyOperations,
collectNames,
type Operation,
outline,
} from "./operations"
import { parseDiagram } from "./parse"
import { renderDiagram } from "./render"
import type { DiagramTree } from "./types"
export interface RestructureResult {
/** New canvas XML, or null when the request could not be carried out. */
xml: string | null
/** Compact outline of the resulting structure, for the model to read back. */
outline: string
/** Operations that could not be applied, and invented stencil names. */
errors: string[]
/** Non-fatal notes: pages skipped, structure that could not be read cleanly. */
warnings: string[]
}
export interface RestructureOptions {
/** Which page of a multi-page document to work on. */
pageIndex?: number
/** Diagram-wide icon glyph size. */
iconSize?: number
}
/**
* Apply structural operations to whatever is on the canvas.
*
* `currentXml` may be empty — that is how a diagram gets built from scratch.
*
* An invented stencil name is a hard error, not a silent fallback: draw.io renders an
* unknown `resIcon` as a blank square, so a diagram that "worked" would be quietly
* missing icons. The error carries suggestions from the catalog so the model can fix it
* in one more turn.
*/
export function restructureDiagram(
currentXml: string,
ops: Operation[],
opts: RestructureOptions = {},
): RestructureResult {
const warnings: string[] = []
let tree: DiagramTree
if (currentXml.trim()) {
const parsed = parseDiagram(currentXml, opts.pageIndex ?? 0)
tree = parsed.tree
warnings.push(...parsed.warnings)
} else {
tree = { roots: [], links: [], foreign: [] }
}
const applied = applyOperations(tree, ops)
const errors = [...applied.errors]
// Catch invented names before rendering, so the model gets a correctable error
// instead of a diagram with blank squares in it.
for (const bad of checkNames(collectNames(applied.tree))) {
const hint = bad.suggestions.length
? ` Did you mean: ${bad.suggestions.join(", ")}?`
: ""
errors.push(
`"${bad.name}" (node ${bad.id}) is not in the stencil catalog.${hint}`,
)
}
if (errors.length > 0)
return { xml: null, outline: outline(applied.tree), errors, warnings }
const rendered = renderDiagram(applied.tree, {
resolveStyle,
iconSize: opts.iconSize,
})
if (rendered.danglingLinks.length)
warnings.push(
`Dropped edge(s) pointing at missing nodes: ${rendered.danglingLinks.join(", ")}.`,
)
return {
xml: rendered.xml,
outline: outline(applied.tree),
errors: [],
warnings,
}
}
/** Read the current canvas structure without changing it. */
export function describeDiagram(
currentXml: string,
pageIndex = 0,
): { outline: string; warnings: string[]; needsAdoption: boolean } {
if (!currentXml.trim())
return { outline: "(empty canvas)", warnings: [], needsAdoption: false }
const { tree, warnings, needsAdoption } = parseDiagram(
currentXml,
pageIndex,
)
return { outline: outline(tree), warnings, needsAdoption }
}
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
export { type Operation, OperationSchema } from "./operations"
export { parseDiagram } from "./parse"
export { renderDiagram } from "./render"
export type { DiagramNode, DiagramTree } from "./types"