mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
* feat(mcp-server): load .drawio.svg (Editable SVG) files with load_diagram
* feat(mcp-server): summarize the user's manual changes per cell in stale rejections and get_diagram
* feat(mcp-server): XML reference for tables, layers and groups via get_drawing_guide topic
* feat(mcp-server): DRAWIO_LANG, DRAWIO_UI and DRAWIO_DARK for the preview page
Three host-config environment variables fix the language, theme and
dark mode of the draw.io editor embedded in the preview page. The new
drawio-themes module holds the theme list and draw.io's locale names
(zh-hant becomes zh-tw); drawioEmbedParams() in http-server.ts builds
the variable tail of the iframe query and getHtmlPage fills the new
{{DRAWIO_PARAMS}} placeholder. Without the variables the page keeps
sending dark=auto as before. Both READMEs document the variables.
* feat(mcp-server): report XML cut off inside a cell and explain drawing in parts
create_new_diagram and add_page now detect bare-cell XML that ends inside an
unfinished mxCell (XML comments stripped first) and return an error with the
last 300 characters of the input, asking the model to resend from that cell or
continue with edit_diagram add operations. isMxCellXmlComplete moves from
lib/utils.ts into packages/mcp-server/src/new-diagram.ts and is re-exported
from lib/utils.ts for the web app. The drawing guide gains a "Large diagrams"
paragraph and the INSTRUCTIONS edit_diagram line mentions drawing in parts.
* fix(mcp-server): review fixes for load .drawio.svg, change summary, XML reference, draw.io embed options, truncation message
* fix(mcp-server): Codex review fixes for .drawio.svg loading, change summary, references, embed options and truncation
- Truncation check: a closing tag such as </mxCell/> that the auto-fix
repairs is no cut, and input without any cell keeps the validator's
message
- Guide: a call rejected as cut off drew nothing, so all of its cells are
sent again; the topic pointer is its own paragraph
- Change summary: decoded labels (no or merged words from <br>),
a fast path for equal XML, and "the order of the cells changed" as
the fallback
- DRAWIO_UI in any case, DRAWIO_LANG=zh-Hans maps to draw.io's zh
- load_diagram and export_diagram describe which files load again
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
/**
|
|
* File-loading helpers for the load_diagram tool.
|
|
*
|
|
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
|
|
* <mxGraphModel> either as plain XML or — draw.io's default save format —
|
|
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
|
|
* diagram's text content. The rest of the server assumes plain XML inside
|
|
* every <diagram>, so loading decompresses all pages up front.
|
|
*/
|
|
import { inflateRaw } from "pako"
|
|
import {
|
|
isMxFile,
|
|
isMxGraphModel,
|
|
normalizeToMxfile,
|
|
parseMxfile,
|
|
serializeMxfile,
|
|
} from "./pages.ts"
|
|
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
|
|
|
export type LoadResult =
|
|
| { ok: true; xml: string; hadCompressedPages: boolean }
|
|
| { ok: false; error: string }
|
|
|
|
/**
|
|
* Decode one compressed page body (base64 → raw deflate → URI-decode).
|
|
* Returns null if the text isn't in that format.
|
|
*/
|
|
export function decompressPageContent(compressed: string): string | null {
|
|
try {
|
|
// atob and pako work in Node and in the browser
|
|
const bytes = Uint8Array.from(atob(compressed.trim()), (c) =>
|
|
c.charCodeAt(0),
|
|
)
|
|
const inflated = inflateRaw(bytes, { to: "string" })
|
|
try {
|
|
return decodeURIComponent(inflated)
|
|
} catch {
|
|
// Not URI-encoded (older files) — the inflated text is the XML.
|
|
return inflated
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the diagram XML a .drawio.svg (Editable SVG) carries in its root
|
|
* element's content attribute. Returns undefined when the text is not an
|
|
* SVG, and "" when the SVG has no embedded diagram.
|
|
*
|
|
* Uses getAttributeNode().value rather than getAttribute(): linkedom's
|
|
* getAttribute re-escapes <, > and & in XML documents, which would turn
|
|
* the embedded "<mxfile" into "<mxfile".
|
|
*/
|
|
export function extractSvgEmbeddedXml(text: string): string | undefined {
|
|
const doc = new DOMParser().parseFromString(text, "text/xml")
|
|
const root = doc.documentElement
|
|
if (root?.tagName !== "svg") return undefined
|
|
return (root.getAttributeNode("content")?.value ?? "").trim()
|
|
}
|
|
|
|
/**
|
|
* Parse the content of a .drawio file into the canonical session shape:
|
|
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
|
|
* bare <mxGraphModel> (wrapped into a one-page mxfile), a .drawio.svg with
|
|
* the diagram embedded, and decompresses any compressed pages.
|
|
*/
|
|
export function parseDrawioFileContent(content: string): LoadResult {
|
|
let trimmed = content.trim()
|
|
if (!trimmed) return { ok: false, error: "File is empty." }
|
|
|
|
if (isMxGraphModel(trimmed)) {
|
|
const normalized = normalizeToMxfile(trimmed)
|
|
if (!normalized) {
|
|
return { ok: false, error: "Failed to parse <mxGraphModel> XML." }
|
|
}
|
|
// Parsed below like any <mxfile>, so a broken model is an error
|
|
trimmed = normalized
|
|
}
|
|
if (!isMxFile(trimmed)) {
|
|
const embedded = extractSvgEmbeddedXml(trimmed)
|
|
if (embedded === undefined) {
|
|
return {
|
|
ok: false,
|
|
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element, or a .drawio.svg with the diagram embedded.",
|
|
}
|
|
}
|
|
if (!embedded) {
|
|
return {
|
|
ok: false,
|
|
error: "This SVG has no embedded diagram. Only .drawio.svg (Editable SVG) files can be loaded; ask for the .drawio file or redraw from the image.",
|
|
}
|
|
}
|
|
if (isMxFile(embedded) || isMxGraphModel(embedded)) {
|
|
return parseDrawioFileContent(embedded)
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: "The SVG's embedded content is not draw.io XML.",
|
|
}
|
|
}
|
|
const doc = parseMxfile(trimmed)
|
|
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
|
|
|
|
let decompressedAny = false
|
|
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
|
|
if (d.querySelector("mxGraphModel")) continue
|
|
const text = (d.textContent || "").trim()
|
|
if (!text) continue // an empty page is valid
|
|
const pageLabel =
|
|
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
|
|
const xml = decompressPageContent(text)
|
|
if (!xml || !isMxGraphModel(xml)) {
|
|
return {
|
|
ok: false,
|
|
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
|
|
}
|
|
}
|
|
const inner = new DOMParser().parseFromString(xml, "text/xml")
|
|
if (
|
|
getXmlSyntaxError(xml) ||
|
|
inner.documentElement?.tagName !== "mxGraphModel"
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
|
|
}
|
|
}
|
|
d.textContent = ""
|
|
d.appendChild(
|
|
doc.importNode(inner.documentElement as unknown as Node, true),
|
|
)
|
|
decompressedAny = true
|
|
}
|
|
// Nothing changed — keep the file's own serialisation.
|
|
return {
|
|
ok: true,
|
|
xml: decompressedAny ? serializeMxfile(doc) : trimmed,
|
|
hadCompressedPages: decompressedAny,
|
|
}
|
|
}
|