diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index c6256960..72bc2fc1 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -55,6 +55,7 @@ import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { getSystemPrompt } from "@/lib/system-prompts" import { normalizeBaseUrl } from "@/lib/types/model-config" import { getUserIdFromRequest } from "@/lib/user-id" +import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts" import { hasCells } from "@/packages/mcp-server/src/pages.ts" import { getShapeLibrary, @@ -523,18 +524,20 @@ ${userInputText} SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint const selectionContext = formatSelectionContext(body.selectedCells) + // The model reads the diagram in the compact notation it writes + // (compact-cells.ts); the canvas itself keeps the full XML const xmlContext = `${ previousXml ? `Previous diagram XML (before user's last message): """xml -${previousXml} +${foldCells(previousXml)} """ ` : "" }Current diagram XML (AUTHORITATIVE - the source of truth): """xml -${xml || ""} +${foldCells(xml || "")} """ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}` @@ -669,9 +672,11 @@ VALIDATION RULES (XML will be rejected if violated): 2. Do NOT include root cells (id="0" or id="1") - they are added automatically 3. All mxCell elements must be siblings - never nested 4. Every mxCell needs a unique id (start from "2") -5. Every mxCell needs a valid parent attribute (use "1" for top-level) +5. parent defaults to "1"; write parent="" only for shapes inside a container 6. Escape special chars in values: < > & " +A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically, so write them only when needed: parent for a shape inside a container, an mxGeometry element for edge waypoints or a label placed on an edge. + Example (generate ONLY this - no wrapper tags): ${SWIMLANE_EXAMPLE} @@ -696,12 +701,12 @@ Operations: - add: Add a new cell. Provide cell_id (new unique id) and new_xml. - delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id. -For update/add, new_xml must be a complete mxCell element including mxGeometry, with its complete style (named styles are not available in edit_diagram). +For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram). ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" Example - Add a rectangle: -{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]} +{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]} Example - Delete container (children & edges auto-deleted): {"operations": [{"operation": "delete", "cell_id": "2"}]}`, diff --git a/components/chat/use-diagram-streaming.ts b/components/chat/use-diagram-streaming.ts index c7381dab..d9a12fb5 100644 --- a/components/chat/use-diagram-streaming.ts +++ b/components/chat/use-diagram-streaming.ts @@ -10,6 +10,7 @@ import { extractCompleteMxCells, replaceNodes, } from "@/lib/utils" +import { expandCompactCells } from "@/packages/mcp-server/src/compact-cells.ts" import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts" import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts" import { @@ -83,7 +84,10 @@ export function useDiagramStreaming({ const completeCells = extractCompleteMxCells(cellsXml) if (!completeCells) return const convertedXml = addDefaultStyles( - applyStyleClasses(convertToLegalXml(completeCells), classes), + applyStyleClasses( + expandCompactCells(convertToLegalXml(completeCells)), + classes, + ), ) if (convertedXml === previousXML.current) return @@ -220,7 +224,10 @@ export function useDiagramStreaming({ op.new_xml ? { ...op, - new_xml: addDefaultStyles(op.new_xml, edges), + new_xml: addDefaultStyles( + expandCompactCells(op.new_xml), + edges, + ), } : op, ), diff --git a/hooks/use-diagram-tool-handlers.ts b/hooks/use-diagram-tool-handlers.ts index 4f2c5ec8..31c20f0d 100644 --- a/hooks/use-diagram-tool-handlers.ts +++ b/hooks/use-diagram-tool-handlers.ts @@ -8,6 +8,7 @@ import type { LoadMode } from "@/contexts/diagram-context" import type { ValidationResult } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator" import { isMxCellXmlComplete } from "@/lib/utils" +import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts" import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts" import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" import { hasCells } from "@/packages/mcp-server/src/pages.ts" @@ -517,7 +518,7 @@ ${finalXml} Current diagram XML: \`\`\`xml -${currentXml} +${foldCells(currentXml)} \`\`\` Please check the cell IDs and retry.`, @@ -546,7 +547,7 @@ Please check the cell IDs and retry.`, Current diagram XML: \`\`\`xml -${currentXml || "No XML available"} +${currentXml ? foldCells(currentXml) : "No XML available"} \`\`\` Please check cell IDs and retry, or use display_diagram to regenerate.`, diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts index 8bc1f2d5..141f1416 100644 --- a/lib/system-prompts.ts +++ b/lib/system-prompts.ts @@ -101,25 +101,23 @@ Note that: When using edit_diagram tool: - Use operations: update (modify cell by id), add (new cell), delete (remove cell by id) -- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry) +- For update/add: provide cell_id and the complete new_xml in the same compact form (a shape with x, y, w, h; an edge with source and target) - For delete: only cell_id is needed - Named styles are not available in edit_diagram: write each cell's complete style - Find the cell_id from "Current diagram XML" in system context -- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "\\n \\n"}]} +- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]} - Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]} -- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "\\n \\n"}]} +- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": ""}]} ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" ## Draw.io XML Structure Reference -**IMPORTANT:** You only generate the named styles and the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically. A named style is written before the cells as ; a cell uses it by putting the name among its style tokens (see Styles). +**IMPORTANT:** You only generate the named styles and the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically. A named style is written before the cells as ; a cell uses it by putting the name among its style tokens (see Styles). A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically, so write them only when needed: parent for a shape inside a container, an mxGeometry element for edge waypoints or a label placed on an edge. Example - generate ONLY this: \`\`\`xml - - - + \`\`\` CRITICAL RULES: @@ -127,20 +125,16 @@ CRITICAL RULES: 2. Do NOT include root cells (id="0" or id="1") - they are added automatically 3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell 4. Use unique sequential IDs starting from "2" -5. Set parent="1" for top-level shapes, or parent="" for grouped elements +5. Write parent="" only for shapes inside a container; top-level cells need no parent Shape (vertex) example: \`\`\`xml - - - + \`\`\` Connector (edge) example: \`\`\`xml - - - + ### Edge Routing Rules: When creating edges/connectors, you MUST follow these rules to avoid overlapping lines: @@ -244,7 +238,7 @@ const EXTENDED_ADDITIONS = ` 1. Generate ONLY mxStyle definitions and mxCell elements - wrapper tags and root cells are added automatically 2. All mxCell elements must be siblings - never nested inside other mxCell elements 3. Every mxCell needs a unique id attribute (start from "2") -4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped) +4. parent defaults to "1"; write it only for a shape inside a container (the container's id) 5. Edge source/target attributes must reference existing cell IDs 6. Escape special characters in values: < for <, > for >, & for &, " for " @@ -263,7 +257,7 @@ ${SWIMLANE_EXAMPLE} 3. Complete the remaining mxCell elements 4. If still truncated, call append_diagram again with the next fragment -**Example:** If previous output ended with \`...\` and complete the remaining elements. +**Example:** If previous output ended with \`\` and complete the remaining elements. ### edit_diagram Details @@ -289,12 +283,12 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri Change label: \`\`\`json -{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "\\n \\n"}]} +{"operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]} \`\`\` Add new shape: \`\`\`json -{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "\\n \\n"}]} +{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": ""}]} \`\`\` Delete container (children & edges auto-deleted): diff --git a/packages/mcp-server/src/compact-cells.ts b/packages/mcp-server/src/compact-cells.ts new file mode 100644 index 00000000..aeb24f6e --- /dev/null +++ b/packages/mcp-server/src/compact-cells.ts @@ -0,0 +1,166 @@ +/** + * Compact cells: the short way the model writes shapes and edges. + * + * A shape is one self-closing mxCell with its position and size as x, y, w + * and h attributes; an edge is one with source and target. The fixed parts + * of draw.io's XML are left out and filled in here: + * + * + * + * + * becomes + * + * + * + * + * + * + * + * + * The long form stays accepted, and anything beyond the plain case (edge + * waypoints, a label on an edge, a parent other than "1") is written the + * long way, so a cell may mix both: compact attributes with an explicit + * parent, for example. + * + * foldCells is the reverse, for the diagram shown to the model: it writes + * the plain shapes and edges compactly and leaves everything else as it is, + * so the model reads the notation it is asked to write. Folding then + * expanding gives the same cells back. + */ +import { readAttributes, type TagAttribute } from "./xml-attributes.ts" + +// A whole mxCell: self-closing, or with its children (cells never nest) +const CELL_BLOCK = + /"']|"[^"]*"|'[^']*')*?)\s*(?:\/>|>([\s\S]*?)<\/mxCell>)/g + +// The four compact attributes and the names draw.io uses for them +const SIZE_ATTRS: Record = { + x: "x", + y: "y", + w: "width", + h: "height", + width: "width", + height: "height", +} + +const attributeText = (attrs: TagAttribute[]) => + attrs.map((a) => ` ${a.name}="${a.value}"`).join("") + +// Attributes a cell is written with, in draw.io's usual order +const FIRST = ["id", "value", "style"] +const LAST = ["vertex", "edge", "parent", "source", "target"] + +/** Turn compact shapes and edges into standard draw.io cells */ +export function expandCompactCells(xml: string): string { + return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => { + const attrs = readAttributes(attrText) + const byName = new Map(attrs.map((a) => [a.name, a.value])) + const size = attrs.filter((a) => a.name in SIZE_ATTRS) + const isEdge = + byName.get("edge") === "1" || + (size.length === 0 && + byName.get("vertex") !== "1" && + byName.has("source") && + byName.has("target")) + const isVertex = + !isEdge && (byName.get("vertex") === "1" || size.length > 0) + // Root cells and anything else the model wrote in full are left alone + if (!isEdge && !isVertex) return block + const inner = body ?? "" + const hasGeometry = / + !FIRST.includes(a.name) && + !LAST.includes(a.name) && + !(a.name in SIZE_ATTRS), + ) + const ordered = [ + ...FIRST.flatMap((n) => + byName.has(n) ? [[n, byName.get(n) ?? ""]] : [], + ), + ...rest.map((a) => [a.name, a.value]), + [isEdge ? "edge" : "vertex", "1"], + ["parent", byName.get("parent") ?? "1"], + ...["source", "target"].flatMap((n) => + byName.has(n) ? [[n, byName.get(n) ?? ""]] : [], + ), + ] + const written = ordered.map(([n, v]) => ` ${n}="${v}"`).join("") + let geometry = "" + if (!hasGeometry) { + if (isEdge) { + geometry = '' + } else { + const get = (short: string, long: string, fallback: string) => + byName.get(short) ?? byName.get(long) ?? fallback + geometry = `` + } + } + return `${geometry}${inner}` + }) +} + +/** Attributes of a single self-closing mxGeometry, or null for anything else */ +function plainGeometry(body: string): Map | null { + const trimmed = body.trim() + const m = trimmed.match( + /^"']|"[^"]*"|'[^']*')*?)\s*\/>$/, + ) + if (!m) return null + const attrs = new Map(readAttributes(m[1]).map((a) => [a.name, a.value])) + // Anything odd, such as a wrong "as", stays as written so folding and + // expanding give the same cells back + return attrs.get("as") === "geometry" ? attrs : null +} + +/** + * Write plain shapes and edges compactly, for the diagram shown to the + * model. A shape is plain when its only child is an mxGeometry with x, y, + * width and height; an edge when its only child is the relative geometry. + * Everything else, including wrapped cells' extra data and edge labels, + * is left as written. + */ +export function foldCells(xml: string): string { + return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => { + if (body === undefined) return block + const attrs = readAttributes(attrText) + const byName = new Map(attrs.map((a) => [a.name, a.value])) + if (attrs.some((a) => a.name in SIZE_ATTRS)) return block + const geometry = plainGeometry(body) + if (!geometry) return block + const geometryKeys = [...geometry.keys()] + .filter((k) => k !== "as") + .sort() + const kept = attrs.filter( + (a) => + a.name !== "vertex" && + a.name !== "edge" && + !(a.name === "parent" && a.value === "1"), + ) + if (byName.get("vertex") === "1" && byName.get("edge") !== "1") { + if (geometryKeys.join() !== "height,width,x,y") return block + const size = ` x="${geometry.get("x")}" y="${geometry.get("y")}" w="${geometry.get("width")}" h="${geometry.get("height")}"` + return `` + } + if (byName.get("edge") === "1" && byName.get("vertex") !== "1") { + if ( + geometryKeys.join() !== "relative" || + geometry.get("relative") !== "1" + ) { + return block + } + if (!byName.has("source") || !byName.has("target")) return block + return `` + } + return block + }) +} diff --git a/packages/mcp-server/src/drawing-guide.ts b/packages/mcp-server/src/drawing-guide.ts index 32b7cbd4..cdffe1b3 100644 --- a/packages/mcp-server/src/drawing-guide.ts +++ b/packages/mcp-server/src/drawing-guide.ts @@ -27,21 +27,17 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide - The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost. ## The XML you send -Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds , , and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes. +Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds , , and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes, and fills in vertex, edge, parent="1" and the mxGeometry element. A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or a label placed on an edge. - - - - - - + + Several pages at once (create_new_diagram only): send a full with one per page. Every page's must start with . Rules (XML that breaks them is rejected): 1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell. 2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells). -3. parent="1" for top-level shapes, parent="" for shapes inside a container. +3. parent="" for shapes inside a container; top-level cells need no parent. 4. Edge source and target must reference existing cell ids. 5. Escape special characters in attribute values: < for <, > for >, & for &, " for ". 6. NEVER include XML comments (). draw.io strips them. @@ -100,14 +96,14 @@ When the user asks for a minimal, plain, black-and-white or unstyled diagram, us - Keep at least 50px between elements, and follow all edge routing rules strictly. ## Editing with edit_diagram -- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id. Named styles are not available here: write each cell's complete style. +- update replaces a cell: send the complete mxCell in the same compact form (x, y, w, h for a shape), with the same id as cell_id. Named styles are not available here: write each cell's complete style. - add inserts a new cell with a new id. One cell per operation. - delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id. - All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry. - If the diagram is large, change it with edit_diagram instead of redrawing it. - {"operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]} - {"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": ""}]} + {"operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]} + {"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": ""}]} {"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]} Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page. diff --git a/packages/mcp-server/src/edit-diagram.ts b/packages/mcp-server/src/edit-diagram.ts index b81a4926..c6fe1b09 100644 --- a/packages/mcp-server/src/edit-diagram.ts +++ b/packages/mcp-server/src/edit-diagram.ts @@ -7,6 +7,7 @@ * so the model never builds on a half-applied edit. */ +import { expandCompactCells } from "./compact-cells.ts" import { applyDiagramOperations, type DiagramOperation, @@ -84,7 +85,10 @@ export function editDiagram( // added after the fixes so a repaired cell gets them too prepared.push({ ...op, - new_xml: addDefaultStyles(check.fixed ?? op.new_xml, edges), + new_xml: addDefaultStyles( + expandCompactCells(check.fixed ?? op.new_xml), + edges, + ), }) } if (errors.length > 0) return { ok: false, errors, pageError: false } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 4e408dae..a29fb3e0 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -23,6 +23,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import open from "open" import { z } from "zod" +import { expandCompactCells, foldCells } from "./compact-cells.ts" import type { DiagramOperation } from "./diagram-operations.ts" import { installDomPolyfill } from "./dom.ts" import { DRAWING_GUIDE } from "./drawing-guide.ts" @@ -341,7 +342,7 @@ Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape Accepted xml: 1) Only the mxCell elements of one page (recommended). The server adds , , and the root cells "0" and "1": - + 2) A bare with (one page). 3) A full with one or more pages. Every page's must start with . @@ -594,10 +595,10 @@ registerWriteTool( "- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + "- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" + - "For add/update, new_xml must be a complete mxCell element including mxGeometry. No XML comments. " + + "For add/update, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target). No XML comments. " + 'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' + "Example - Add a rectangle on the default (first) page:\n" + - '{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]}\n\n' + + '{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]}\n\n' + "Example - Delete a cell on the default page:\n" + '{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}', inputSchema: { @@ -874,7 +875,7 @@ server.registerTool( content: [ { type: "text", - text: `Current diagram XML:\n\n${session.xml}\n\n${pageList}${staleNote}`, + text: `Current diagram XML:\n\n${foldCells(session.xml)}\n\n${pageList}${staleNote}`, }, ], } @@ -1635,7 +1636,7 @@ registerWriteTool( } } cleanXml = addDefaultStyles( - applyStyleClasses(cleanXml, classes), + applyStyleClasses(expandCompactCells(cleanXml), classes), ) } diff --git a/packages/mcp-server/src/new-diagram.ts b/packages/mcp-server/src/new-diagram.ts index f65cd8d6..2f812345 100644 --- a/packages/mcp-server/src/new-diagram.ts +++ b/packages/mcp-server/src/new-diagram.ts @@ -2,6 +2,7 @@ * A whole new diagram written by the model, for the create_new_diagram tool * and the web app's display_diagram tool. */ +import { expandCompactCells } from "./compact-cells.ts" import { hasCells, normalizeToMxfile, wrapCellsInModel } from "./pages.ts" import { addDefaultStyles, @@ -51,9 +52,10 @@ export function reservedIdError(input: string): string | null { * The named style definitions are taken out first (style-classes.ts). Bare * cells then get the wrapper and root cells, since the strict parser rejects * several top-level elements. Then the XML is validated and auto-fixed while - * it is still a bare model, where duplicate ids are renamed. The names are - * expanded and the default styles added on the fixed XML, so repaired cells - * get them too, and finally it is turned into an . + * it is still a bare model, where duplicate ids are renamed. Compact cells + * (compact-cells.ts) are expanded, the names expanded and the default styles + * added on the fixed XML, so repaired cells get them too, and finally it is + * turned into an . */ /** * Take the named style definitions out of the model's XML (style-classes.ts). @@ -95,7 +97,7 @@ export function prepareNewDiagram( if (!valid) { return { ok: false, error: `XML validation failed - ${error}` } } - xml = addDefaultStyles(applyStyleClasses(xml, classes)) + xml = addDefaultStyles(applyStyleClasses(expandCompactCells(xml), classes)) const normalized = normalizeToMxfile(xml, page) if (!normalized) { return { diff --git a/packages/mcp-server/src/xml-examples.ts b/packages/mcp-server/src/xml-examples.ts index 29ef0a94..e9af6ca4 100644 --- a/packages/mcp-server/src/xml-examples.ts +++ b/packages/mcp-server/src/xml-examples.ts @@ -3,28 +3,14 @@ * prompt and tool descriptions, so both teach the model the same thing. */ -export const SWIMLANE_EXAMPLE = ` - - - - - - - - - - - - - -` +export const SWIMLANE_EXAMPLE = ` + + + +` -export const TWO_EDGES_EXAMPLE = ` - - - - -` +export const TWO_EDGES_EXAMPLE = ` +` export const WAYPOINT_EXAMPLE = ` @@ -38,11 +24,11 @@ export const WAYPOINT_EXAMPLE = ` - - - - -` + + + + +` /** Indent every line, for an indented code block in Markdown */ export const indent = (text: string, prefix = " ") => diff --git a/packages/mcp-server/src/xml-validation.ts b/packages/mcp-server/src/xml-validation.ts index 6962a59d..890b25bb 100644 --- a/packages/mcp-server/src/xml-validation.ts +++ b/packages/mcp-server/src/xml-validation.ts @@ -657,6 +657,15 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push("Fixed malformed attribute quotes") } + // 6b. Missing closing quote on a numeric attribute right before the + // self-closing slash (h="112/>), which a compact cell ends with. Numbers + // only, so a quote that belongs to a label is never touched. + const quoteBeforeSlash = /=("-?\d+(?:\.\d+)?)\/>/g + if (quoteBeforeSlash.test(fixed)) { + fixed = fixed.replace(quoteBeforeSlash, '=$1"/>') + fixes.push("Added a missing closing quote before />") + } + // 7. Fix malformed closing tags const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g if (malformedClosingTag.test(fixed)) { diff --git a/tests/unit/compact-cells.test.ts b/tests/unit/compact-cells.test.ts new file mode 100644 index 00000000..63e07dc8 --- /dev/null +++ b/tests/unit/compact-cells.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest" +import { + expandCompactCells, + foldCells, +} from "@/packages/mcp-server/src/compact-cells.ts" +import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts" +import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" +import { + normalizeToMxfile, + wrapCellsInModel, +} from "@/packages/mcp-server/src/pages.ts" + +const shape = + '' +const shapeLong = + '' +const edge = '' +const edgeLong = + '' + +/** Cells compared by what they mean, not by attribute order */ +function canonical(xml: string): string { + const doc = new DOMParser().parseFromString( + `${xml}`, + "text/xml", + ) + const describe = (el: Element): string => { + const attrs = Array.from(el.attributes) + .map((a) => `${a.name}=${a.value}`) + .sort() + .join(" ") + const children = Array.from(el.children).map(describe).join("") + return `<${el.tagName} ${attrs}>${children}` + } + return Array.from(doc.documentElement.children).map(describe).join("\n") +} + +describe("expandCompactCells", () => { + it("gives a compact shape its flags, parent and geometry", () => { + expect(expandCompactCells(shape)).toBe(shapeLong) + }) + + it("gives a compact edge its flags, parent and relative geometry", () => { + expect(expandCompactCells(edge)).toBe(edgeLong) + }) + + it("keeps an explicit parent and accepts width/height as names", () => { + const inLane = + '' + expect(expandCompactCells(inLane)).toBe( + '', + ) + }) + + it("fills in a default position and size when some are missing", () => { + expect(expandCompactCells('')).toBe( + '', + ) + }) + + it("lets an explicit geometry win over compact attributes", () => { + const both = + '' + expect(expandCompactCells(both)).toBe( + shapeLong.replace(' value="Start" style="rounded=1;"', ""), + ) + }) + + it("adds the relative geometry to a long-form edge that has none", () => { + const noGeometry = + '' + expect(expandCompactCells(noGeometry)).toBe(edgeLong) + }) + + it("leaves root cells, long-form cells and edge labels alone", () => { + const roots = '' + const label = + '' + expect(expandCompactCells(roots + shapeLong + edgeLong + label)).toBe( + roots + shapeLong + edgeLong + label, + ) + }) + + it("works on a cell inside a UserObject wrapper", () => { + const wrapped = + '' + expect(expandCompactCells(wrapped)).toBe( + '', + ) + }) +}) + +describe("foldCells", () => { + it("writes plain shapes and edges compactly", () => { + expect(foldCells(shapeLong)).toBe(shape) + expect(foldCells(edgeLong)).toBe(edge) + }) + + it("keeps a parent other than 1 and leaves special cells as written", () => { + const inLane = + '' + expect(foldCells(inLane)).toBe( + '', + ) + const waypoints = + '' + const label = + '' + const sourcePoint = + '' + const roots = '' + const wrongAs = + '' + for (const xml of [waypoints, label, sourcePoint, roots, wrongAs]) { + expect(foldCells(xml)).toBe(xml) + } + }) + + it("round-trips through expandCompactCells", () => { + const page = `${shapeLong}${edgeLong}` + const folded = foldCells(page) + expect(folded).not.toContain("vertex=") + expect(canonical(expandCompactCells(folded))).toBe(canonical(page)) + }) +}) + +describe("compact cells through the pipeline", () => { + const geometryOf = (xml: string, id: string) => + xml.match( + new RegExp(`]*>(]*/>)`), + )?.[1] + + it("prepareNewDiagram expands them and still adds the defaults", () => { + const prepared = prepareNewDiagram(`${shape}\n${edge}`) + expect(prepared.ok).toBe(true) + if (!prepared.ok) return + expect(prepared.xml).toContain( + 'style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"', + ) + expect(geometryOf(prepared.xml, "2")).toBe( + '', + ) + expect(prepared.xml).toContain( + '', + ) + }) + + it("editDiagram accepts a compact cell in new_xml", () => { + const file = + normalizeToMxfile(wrapCellsInModel(shapeLong), { + pageId: "p1", + pageName: "Page-1", + }) ?? "" + const outcome = editDiagram( + file, + [ + { + operation: "add", + cell_id: "3", + new_xml: + '', + }, + { + operation: "update", + cell_id: "2", + new_xml: + '', + }, + ], + {}, + ) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + expect(outcome.xml).toContain('value="Next"') + expect(outcome.xml).toContain('value="Begin"') + expect(geometryOf(outcome.xml, "3")).toBe( + '', + ) + }) +}) + +describe("a compact cell with a slip", () => { + it("repairs a missing closing quote before the slash", () => { + const prepared = prepareNewDiagram( + '', + ) + }) +})