mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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:
219
lib/diagram-engine/catalog.ts
Normal file
219
lib/diagram-engine/catalog.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The stencil catalog: a name → verbatim draw.io style map.
|
||||
*
|
||||
* This is the anti-hallucination layer. The model asks for `icon("s3")`; the engine
|
||||
* looks the name up here and gets the exact style draw.io ships, including the official
|
||||
* category colour, the connection points and `aspect=fixed`. A name that is not in the
|
||||
* catalog fails at build time with a suggestion, rather than becoming an empty square in
|
||||
* the rendered diagram — which is what happens when a model writes
|
||||
* `resIcon=mxgraph.aws4.s3_bucket_thing` by hand and nothing checks it.
|
||||
*
|
||||
* The styles are verbatim from draw.io's own shape index (via drawio-ai-kit, which
|
||||
* generated them from jgraph/drawio-mcp's index, Apache-2.0). Nothing here is
|
||||
* hand-assembled, so there is no chance of a plausible-looking but wrong colour.
|
||||
*/
|
||||
|
||||
import stencils from "./data/aws-stencils.json"
|
||||
|
||||
const ICONS = stencils.icons as Record<string, string>
|
||||
const GROUPS = stencils.groups as Record<string, string>
|
||||
|
||||
export interface CatalogEntry {
|
||||
name: string
|
||||
kind: "icon" | "group"
|
||||
style: string
|
||||
/** Official colour from the style, for showing the model what it is getting. */
|
||||
color: string | null
|
||||
}
|
||||
|
||||
function colorOf(style: string): string | null {
|
||||
return style.match(/(?:^|;)fillColor=([^;]+)/)?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Exact lookup. Returns null for an unknown name — never a guess. */
|
||||
export function lookupStencil(
|
||||
name: string,
|
||||
kind?: "icon" | "group",
|
||||
): CatalogEntry | null {
|
||||
if (kind !== "group" && ICONS[name])
|
||||
return {
|
||||
name,
|
||||
kind: "icon",
|
||||
style: ICONS[name],
|
||||
color: colorOf(ICONS[name]),
|
||||
}
|
||||
if (kind !== "icon" && GROUPS[name])
|
||||
return {
|
||||
name,
|
||||
kind: "group",
|
||||
style: GROUPS[name],
|
||||
color: colorOf(GROUPS[name]),
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The resolver the renderer takes, so the engine itself does not depend on the catalog. */
|
||||
export function resolveStyle(
|
||||
name: string,
|
||||
kind: "icon" | "group",
|
||||
): string | null {
|
||||
return lookupStencil(name, kind)?.style ?? null
|
||||
}
|
||||
|
||||
/** Normalise for matching: lowercase, and non-alphanumerics collapsed to single spaces. */
|
||||
function norm(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand people type, mapped to words that actually appear in a catalog name.
|
||||
*
|
||||
* The direction matters: the target has to exist in the catalog. AWS's own stencil names
|
||||
* are already abbreviated — EKS is `eks`, not `elastic_kubernetes_service`, and nothing
|
||||
* in the catalog contains the word "kubernetes" at all — so expanding an abbreviation
|
||||
* into its full product name finds nothing. These entries go the other way, from a
|
||||
* spoken-out name or a nickname to the token the catalog uses.
|
||||
*/
|
||||
const ALIASES: Record<string, string> = {
|
||||
k8s: "eks",
|
||||
kubernetes: "eks",
|
||||
kube: "eks",
|
||||
alb: "application load balancer",
|
||||
nlb: "network load balancer",
|
||||
elb: "elastic load balancing",
|
||||
asg: "auto scaling",
|
||||
apigw: "api gateway",
|
||||
cf: "cloudfront",
|
||||
cw: "cloudwatch",
|
||||
ddb: "dynamodb",
|
||||
tgw: "transit gateway",
|
||||
igw: "internet gateway",
|
||||
r53: "route 53",
|
||||
iam: "identity and access management",
|
||||
kms: "key management service",
|
||||
postgres: "rds",
|
||||
postgresql: "rds",
|
||||
mysql: "rds",
|
||||
aurora: "aurora",
|
||||
bucket: "s3",
|
||||
}
|
||||
|
||||
/**
|
||||
* Score one entry against the query tokens. Higher is better; 0 means no match.
|
||||
*
|
||||
* The extra-words penalty is what makes "s3" return `s3` rather than
|
||||
* `backup_aws_backup_support_for_amazon_s3` — both contain the token, so without it the
|
||||
* winner comes down to iteration order. It counts only the words the query did NOT ask
|
||||
* for, so a deliberately multi-word query like "nat gateway" is not punished for being
|
||||
* specific.
|
||||
*/
|
||||
function score(name: string, qTokens: string[], qJoined: string): number {
|
||||
const n = norm(name)
|
||||
const words = n.split(" ")
|
||||
let s = 0
|
||||
if (n === qJoined) s += 100
|
||||
if (n.replace(/ /g, "") === qJoined.replace(/ /g, "")) s += 60
|
||||
for (const t of qTokens) {
|
||||
if (words.includes(t)) s += 25
|
||||
else if (n.includes(t)) s += 12
|
||||
}
|
||||
if (s === 0) return 0
|
||||
const extra = words.filter((w) => !qTokens.includes(w)).length
|
||||
return s - Math.min(24, extra * 4)
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
name: string
|
||||
kind: "icon" | "group"
|
||||
color: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stencils by keyword.
|
||||
*
|
||||
* Returns names and colours only, not styles. The model builds with `icon("<name>")` and
|
||||
* the engine resolves the style itself, so sending the style — around 600 characters per
|
||||
* AWS entry, and 20KB+ for an Azure one with an embedded image — would be pure context
|
||||
* burn.
|
||||
*/
|
||||
export function searchStencils(
|
||||
query: string,
|
||||
opts: { limit?: number; kind?: "icon" | "group" } = {},
|
||||
): SearchHit[] {
|
||||
const limit = opts.limit ?? 8
|
||||
const tokens = norm(query)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((t) => ALIASES[t] ?? t)
|
||||
.flatMap((t) => t.split(" "))
|
||||
if (tokens.length === 0) return []
|
||||
const joined = tokens.join(" ")
|
||||
|
||||
const pool: [string, string, "icon" | "group"][] = []
|
||||
if (opts.kind !== "group")
|
||||
for (const [n, st] of Object.entries(ICONS)) pool.push([n, st, "icon"])
|
||||
if (opts.kind !== "icon")
|
||||
for (const [n, st] of Object.entries(GROUPS))
|
||||
pool.push([n, st, "group"])
|
||||
|
||||
return pool
|
||||
.map(([name, style, kind]) => ({
|
||||
name,
|
||||
kind,
|
||||
color: colorOf(style),
|
||||
s: score(name, tokens, joined),
|
||||
}))
|
||||
.filter((r) => r.s > 0)
|
||||
.sort((a, b) => b.s - a.s || a.name.length - b.name.length)
|
||||
.slice(0, limit)
|
||||
.map(({ name, kind, color }) => ({ name, kind, color }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest real names for one that does not exist.
|
||||
*
|
||||
* Plain search is not quite the right tool here. A model that writes
|
||||
* `s3_bucket_storage` most likely meant `s3`, but searching that whole phrase ranks
|
||||
* `s3_storage_lens` first — it matches more of the query. So we also search the
|
||||
* leading token on its own and put those hits first: an invented name is usually a
|
||||
* real service name with extra words stuck on the end.
|
||||
*/
|
||||
function suggestFor(name: string, kind: "icon" | "group"): string[] {
|
||||
const words = norm(name.replace(/_/g, " ")).split(" ").filter(Boolean)
|
||||
const out: string[] = []
|
||||
const add = (hits: SearchHit[]) => {
|
||||
for (const h of hits) if (!out.includes(h.name)) out.push(h.name)
|
||||
}
|
||||
if (words.length > 1) add(searchStencils(words[0], { limit: 2, kind }))
|
||||
add(searchStencils(words.join(" "), { limit: 3, kind }))
|
||||
return out.slice(0, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the icon names in a tree before laying it out, so a bad name is reported as
|
||||
* a correctable error with suggestions instead of rendering as a blank square — which is
|
||||
* what an unchecked invented name becomes in draw.io.
|
||||
*/
|
||||
export function checkNames(
|
||||
names: { id: string; name: string; kind: "icon" | "group" }[],
|
||||
): { id: string; name: string; suggestions: string[] }[] {
|
||||
const bad: { id: string; name: string; suggestions: string[] }[] = []
|
||||
for (const n of names) {
|
||||
if (!n.name || lookupStencil(n.name, n.kind)) continue
|
||||
bad.push({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
suggestions: suggestFor(n.name, n.kind),
|
||||
})
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
/** Total catalog size, for the tool description. */
|
||||
export const CATALOG_SIZE = {
|
||||
icons: Object.keys(ICONS).length,
|
||||
groups: Object.keys(GROUPS).length,
|
||||
}
|
||||
1008
lib/diagram-engine/data/aws-stencils.json
Normal file
1008
lib/diagram-engine/data/aws-stencils.json
Normal file
File diff suppressed because it is too large
Load Diff
120
lib/diagram-engine/index.ts
Normal file
120
lib/diagram-engine/index.ts
Normal 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"
|
||||
440
lib/diagram-engine/operations.ts
Normal file
440
lib/diagram-engine/operations.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Structural operations — what the model sends instead of XML.
|
||||
*
|
||||
* The tree is re-derived from the canvas on every call, so an operation names existing
|
||||
* nodes by id and says what to change. Adding one node costs a few dozen tokens; the
|
||||
* equivalent as raw mxCell XML is hundreds, and re-emitting the whole diagram to add one
|
||||
* icon costs thousands.
|
||||
*
|
||||
* Operations are applied in order, each against the result of the last, so a sequence
|
||||
* like "add a frame, then move two nodes into it" works in a single call.
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
import {
|
||||
type ContainerNode,
|
||||
type DiagramNode,
|
||||
type DiagramTree,
|
||||
findNode,
|
||||
findParent,
|
||||
isContainer,
|
||||
type LinkSpec,
|
||||
walkTree,
|
||||
} from "./types"
|
||||
|
||||
export const OperationSchema = z.discriminatedUnion("op", [
|
||||
z.object({
|
||||
op: z.literal("add_icon"),
|
||||
id: z.string().describe("New unique id for this node"),
|
||||
parent: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Container id to add into; omit for top level"),
|
||||
name: z.string().describe("Catalog stencil name, e.g. 's3' or 'ec2'"),
|
||||
label: z.string().optional(),
|
||||
after: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Insert after this sibling id; omit to append"),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("add_box"),
|
||||
id: z.string(),
|
||||
parent: z.string().optional(),
|
||||
label: z.string(),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("add_container"),
|
||||
id: z.string(),
|
||||
parent: z.string().optional(),
|
||||
label: z
|
||||
.string()
|
||||
.describe("Frame title; empty string means invisible wrapper"),
|
||||
dir: z.enum(["row", "col"]).describe("How children stack"),
|
||||
gname: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame",
|
||||
),
|
||||
gap: z.number().optional(),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("add_grid"),
|
||||
id: z.string(),
|
||||
parent: z.string().optional(),
|
||||
label: z.string(),
|
||||
cols: z.number().describe("Number of columns"),
|
||||
gap: z.number().optional(),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("remove"),
|
||||
id: z
|
||||
.string()
|
||||
.describe("Node to delete; its descendants and edges go too"),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("move"),
|
||||
id: z.string(),
|
||||
parent: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("New container id; omit to move to top level"),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_label"),
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_dir"),
|
||||
id: z.string().describe("Container to re-orient"),
|
||||
dir: z.enum(["row", "col"]),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_gap"),
|
||||
id: z.string(),
|
||||
gap: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("link"),
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
label: z.string().optional(),
|
||||
dashed: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Dashed line — replication, sync, policy"),
|
||||
step: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Step number, shown as an 'N. ' prefix"),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("unlink"),
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_title"),
|
||||
title: z.string(),
|
||||
}),
|
||||
])
|
||||
|
||||
export type Operation = z.infer<typeof OperationSchema>
|
||||
|
||||
export interface ApplyResult {
|
||||
tree: DiagramTree
|
||||
/** One entry per operation that could not be applied, in order. */
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/** Insert into a child list, after a named sibling or at the end. */
|
||||
function insert(
|
||||
list: DiagramNode[],
|
||||
node: DiagramNode,
|
||||
after: string | undefined,
|
||||
): void {
|
||||
if (after) {
|
||||
const i = list.findIndex((c) => c.id === after)
|
||||
if (i >= 0) {
|
||||
list.splice(i + 1, 0, node)
|
||||
return
|
||||
}
|
||||
}
|
||||
list.push(node)
|
||||
}
|
||||
|
||||
/** Detach a node from wherever it currently sits. Returns it, or null if not found. */
|
||||
function detach(tree: DiagramTree, id: string): DiagramNode | null {
|
||||
const rootIdx = tree.roots.findIndex((r) => r.id === id)
|
||||
if (rootIdx >= 0) return tree.roots.splice(rootIdx, 1)[0]
|
||||
const parent = findParent(tree, id)
|
||||
if (!parent) return null
|
||||
const i = parent.children.findIndex((c) => c.id === id)
|
||||
return i >= 0 ? parent.children.splice(i, 1)[0] : null
|
||||
}
|
||||
|
||||
/** Would making `id` a descendant of `parentId` create a cycle? */
|
||||
function wouldCycle(tree: DiagramTree, id: string, parentId: string): boolean {
|
||||
if (id === parentId) return true
|
||||
const node = findNode(tree, id)
|
||||
if (!node || !isContainer(node)) return false
|
||||
for (const d of walkTree({ ...tree, roots: [node] }))
|
||||
if (d.id === parentId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where a new or moved node goes. Returns the child list to insert into, or an
|
||||
* error string.
|
||||
*/
|
||||
function targetList(
|
||||
tree: DiagramTree,
|
||||
parentId: string | undefined,
|
||||
): DiagramNode[] | string {
|
||||
if (!parentId) return tree.roots
|
||||
const p = findNode(tree, parentId)
|
||||
if (!p) return `No node with id "${parentId}"`
|
||||
if (!isContainer(p))
|
||||
return `"${parentId}" is a ${p.kind}, not a container — it cannot hold children`
|
||||
return p.children
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply operations to a tree, in order.
|
||||
*
|
||||
* The input tree is deep-copied first: a partially-applied batch must not leave the
|
||||
* caller's tree half-mutated when a later operation fails.
|
||||
*/
|
||||
export function applyOperations(
|
||||
input: DiagramTree,
|
||||
ops: Operation[],
|
||||
): ApplyResult {
|
||||
const tree: DiagramTree = structuredClone(input)
|
||||
const errors: string[] = []
|
||||
|
||||
const exists = (id: string) => findNode(tree, id) !== null
|
||||
|
||||
for (const op of ops) {
|
||||
switch (op.op) {
|
||||
case "add_icon":
|
||||
case "add_box":
|
||||
case "add_container":
|
||||
case "add_grid": {
|
||||
if (exists(op.id)) {
|
||||
errors.push(`${op.op}: id "${op.id}" is already taken`)
|
||||
break
|
||||
}
|
||||
const list = targetList(tree, op.parent)
|
||||
if (typeof list === "string") {
|
||||
errors.push(`${op.op}: ${list}`)
|
||||
break
|
||||
}
|
||||
let node: DiagramNode
|
||||
if (op.op === "add_icon")
|
||||
node = {
|
||||
kind: "icon",
|
||||
id: op.id,
|
||||
name: op.name,
|
||||
label: op.label ?? "",
|
||||
}
|
||||
else if (op.op === "add_box")
|
||||
node = { kind: "box", id: op.id, label: op.label }
|
||||
else if (op.op === "add_container")
|
||||
node = {
|
||||
kind: "group",
|
||||
id: op.id,
|
||||
gname: op.gname ?? null,
|
||||
label: op.label,
|
||||
dir: op.dir,
|
||||
gap: op.gap ?? 20,
|
||||
children: [],
|
||||
}
|
||||
else
|
||||
node = {
|
||||
kind: "grid",
|
||||
id: op.id,
|
||||
gname: null,
|
||||
label: op.label,
|
||||
cols: Math.max(1, op.cols),
|
||||
gap: op.gap ?? 14,
|
||||
children: [],
|
||||
}
|
||||
insert(list, node, op.after)
|
||||
break
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node) {
|
||||
errors.push(`remove: no node with id "${op.id}"`)
|
||||
break
|
||||
}
|
||||
// Collect the subtree's ids first — edges touching any of them go too,
|
||||
// otherwise draw.io renders an arrow pointing at nothing.
|
||||
const doomed = new Set<string>()
|
||||
for (const d of walkTree({ ...tree, roots: [node] }))
|
||||
doomed.add(d.id)
|
||||
detach(tree, op.id)
|
||||
tree.links = tree.links.filter(
|
||||
(l) => !doomed.has(l.source) && !doomed.has(l.target),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "move": {
|
||||
if (!exists(op.id)) {
|
||||
errors.push(`move: no node with id "${op.id}"`)
|
||||
break
|
||||
}
|
||||
if (op.parent && !exists(op.parent)) {
|
||||
errors.push(`move: no node with id "${op.parent}"`)
|
||||
break
|
||||
}
|
||||
if (op.parent && wouldCycle(tree, op.id, op.parent)) {
|
||||
errors.push(
|
||||
`move: cannot move "${op.id}" into "${op.parent}" — that is inside itself`,
|
||||
)
|
||||
break
|
||||
}
|
||||
const list = targetList(tree, op.parent)
|
||||
if (typeof list === "string") {
|
||||
errors.push(`move: ${list}`)
|
||||
break
|
||||
}
|
||||
const node = detach(tree, op.id)
|
||||
if (!node) {
|
||||
errors.push(`move: could not detach "${op.id}"`)
|
||||
break
|
||||
}
|
||||
insert(list, node, op.after)
|
||||
break
|
||||
}
|
||||
|
||||
case "set_label": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node) {
|
||||
errors.push(`set_label: no node with id "${op.id}"`)
|
||||
break
|
||||
}
|
||||
if (node.kind === "title") {
|
||||
errors.push(
|
||||
`set_label: use set_title to change the page title`,
|
||||
)
|
||||
break
|
||||
}
|
||||
node.label = op.label
|
||||
break
|
||||
}
|
||||
|
||||
case "set_dir": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node || !isContainer(node)) {
|
||||
errors.push(`set_dir: "${op.id}" is not a container`)
|
||||
break
|
||||
}
|
||||
if (node.kind === "grid") {
|
||||
errors.push(
|
||||
`set_dir: "${op.id}" is a grid — change its column count instead`,
|
||||
)
|
||||
break
|
||||
}
|
||||
node.dir = op.dir
|
||||
break
|
||||
}
|
||||
|
||||
case "set_gap": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node || !isContainer(node)) {
|
||||
errors.push(`set_gap: "${op.id}" is not a container`)
|
||||
break
|
||||
}
|
||||
;(node as ContainerNode).gap = Math.max(0, op.gap)
|
||||
break
|
||||
}
|
||||
|
||||
case "link": {
|
||||
if (!exists(op.source)) {
|
||||
errors.push(`link: no node with id "${op.source}"`)
|
||||
break
|
||||
}
|
||||
if (!exists(op.target)) {
|
||||
errors.push(`link: no node with id "${op.target}"`)
|
||||
break
|
||||
}
|
||||
const dup = tree.links.some(
|
||||
(l) => l.source === op.source && l.target === op.target,
|
||||
)
|
||||
if (dup) {
|
||||
errors.push(
|
||||
`link: "${op.source}" → "${op.target}" already exists`,
|
||||
)
|
||||
break
|
||||
}
|
||||
const link: LinkSpec = { source: op.source, target: op.target }
|
||||
if (op.label) link.label = op.label
|
||||
if (op.dashed) link.dashed = true
|
||||
if (op.step != null) link.step = op.step
|
||||
tree.links.push(link)
|
||||
break
|
||||
}
|
||||
|
||||
case "unlink": {
|
||||
const before = tree.links.length
|
||||
tree.links = tree.links.filter(
|
||||
(l) => !(l.source === op.source && l.target === op.target),
|
||||
)
|
||||
if (tree.links.length === before)
|
||||
errors.push(
|
||||
`unlink: no edge from "${op.source}" to "${op.target}"`,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "set_title":
|
||||
tree.title = op.title
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { tree, errors }
|
||||
}
|
||||
|
||||
/** Every icon/group name in a tree, for validating against the catalog. */
|
||||
export function collectNames(
|
||||
tree: DiagramTree,
|
||||
): { id: string; name: string; kind: "icon" | "group" }[] {
|
||||
const out: { id: string; name: string; kind: "icon" | "group" }[] = []
|
||||
for (const n of walkTree(tree)) {
|
||||
if (n.kind === "icon" && n.name)
|
||||
out.push({ id: n.id, name: n.name, kind: "icon" })
|
||||
else if (isContainer(n) && n.gname)
|
||||
out.push({ id: n.id, name: n.gname, kind: "group" })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact text outline of the tree, for showing the model what is on the canvas.
|
||||
*
|
||||
* Sending the tree as JSON would cost several times more for the same information, and
|
||||
* the model does not need coordinates — it needs to know what exists and how it nests so
|
||||
* it can name ids in the next operation.
|
||||
*/
|
||||
export function outline(tree: DiagramTree): string {
|
||||
const lines: string[] = []
|
||||
if (tree.title) lines.push(`title: ${tree.title}`)
|
||||
const walk = (n: DiagramNode, depth: number) => {
|
||||
const pad = " ".repeat(depth)
|
||||
if (n.kind === "icon")
|
||||
lines.push(
|
||||
`${pad}${n.id}: icon ${n.name}${n.label ? ` "${n.label}"` : ""}`,
|
||||
)
|
||||
else if (n.kind === "box") lines.push(`${pad}${n.id}: box "${n.label}"`)
|
||||
else if (n.kind === "title") lines.push(`${pad}${n.id}: title`)
|
||||
else {
|
||||
const meta = n.kind === "grid" ? `grid cols=${n.cols}` : n.dir
|
||||
lines.push(
|
||||
`${pad}${n.id}: ${meta}${n.label ? ` "${n.label}"` : " (wrapper)"}`,
|
||||
)
|
||||
for (const c of n.children) walk(c, depth + 1)
|
||||
}
|
||||
}
|
||||
for (const r of tree.roots) walk(r, 0)
|
||||
for (const l of tree.links) {
|
||||
const bits = [l.label, l.dashed ? "dashed" : null]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
lines.push(`link ${l.source} -> ${l.target}${bits ? ` (${bits})` : ""}`)
|
||||
}
|
||||
if (tree.foreign.length)
|
||||
lines.push(
|
||||
`${tree.foreign.length} cell(s) kept as-is: ${tree.foreign.map((f) => f.id).join(", ")}`,
|
||||
)
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -51,17 +51,41 @@ parameters: {
|
||||
}
|
||||
---Tool4---
|
||||
tool name: get_shape_library
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||
parameters: {
|
||||
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||
library: string // Library name: azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||
}
|
||||
---Tool5---
|
||||
tool name: restructure_diagram
|
||||
description: Build or edit an AWS architecture diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings.
|
||||
parameters: {
|
||||
operations: Array<Operation> // add_icon | add_box | add_container | add_grid | remove | move | set_label | set_dir | set_gap | link | unlink | set_title
|
||||
}
|
||||
---Tool6---
|
||||
tool name: search_stencils
|
||||
description: Find AWS stencil names for restructure_diagram. Returns real names with their official colours. Call this before naming any AWS icon — a name you invent is rejected.
|
||||
parameters: {
|
||||
query: string
|
||||
kind?: "icon" | "group"
|
||||
limit?: number
|
||||
}
|
||||
---End of tools---
|
||||
|
||||
IMPORTANT: Choose the right tool:
|
||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||
- For an AWS architecture diagram (VPC, subnets, multi-AZ, landing zone, serverless, event-driven): use search_stencils then restructure_diagram. This applies to BOTH creating and editing. Do not hand-write XML for AWS diagrams — the engine gets the layout right and costs a fraction of the tokens.
|
||||
- Use display_diagram for: NON-AWS diagrams — flowcharts, BPMN, sequence diagrams, mind maps, UI mockups, org charts, ER diagrams, Azure/GCP diagrams.
|
||||
- Use edit_diagram for: small changes to a NON-AWS diagram.
|
||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
|
||||
- Use get_shape_library for: discovering icons for a NON-AWS library, before display_diagram.
|
||||
|
||||
Working with restructure_diagram:
|
||||
- Look every icon name up with search_stencils first. Batch the lookups.
|
||||
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
|
||||
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
|
||||
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
|
||||
- Nesting order for AWS: Region → VPC → Availability Zone → Subnet. Managed and global services (CloudFront, Route 53, S3, DynamoDB, SQS, SNS) sit OUTSIDE the VPC.
|
||||
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
|
||||
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
|
||||
|
||||
Core capabilities:
|
||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||
|
||||
Reference in New Issue
Block a user