mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29: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
258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
/**
|
|
* Workflow gate for edit_diagram.
|
|
*
|
|
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
|
|
* clients, see #885), we compare content: `lastSeenXml` is the state-store
|
|
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
|
|
* edit_diagram / page CRUD). The store only changes on server writes or
|
|
* browser pushes (user autosave, sync exports), so if the live store still
|
|
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
|
|
* edit is safe no matter how much time passed.
|
|
*
|
|
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
|
|
* document when it pushes state back (different attribute order, pretty-
|
|
* printed whitespace, regenerated diagram ids, viewport attributes like
|
|
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
|
|
* is a user edit, so the fingerprint keeps only what a user can actually
|
|
* change: the set of pages, each page's name, and each page's cell tree
|
|
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
|
|
*/
|
|
import {
|
|
findPageElement,
|
|
isMxGraphModel,
|
|
normalizeToMxfile,
|
|
type PageSelector,
|
|
parseMxfile,
|
|
serializeMxfile,
|
|
} from "./pages.ts"
|
|
|
|
export type EditGateResult =
|
|
| { ok: true }
|
|
| { ok: false; reason: "no-context" | "stale" }
|
|
|
|
/**
|
|
* Canonical serialisation of an element subtree: tag + attributes sorted by
|
|
* name + child elements in order + non-whitespace text. Whitespace-only text
|
|
* nodes (pretty-printing) are dropped.
|
|
*/
|
|
function canonicalizeElement(el: Element): string {
|
|
const attrs = Array.from(el.attributes)
|
|
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
|
|
.sort()
|
|
.join(" ")
|
|
let children = ""
|
|
for (const child of Array.from(el.childNodes)) {
|
|
if (child.nodeType === 1) {
|
|
children += canonicalizeElement(child as Element)
|
|
} else if (child.nodeType === 3 || child.nodeType === 4) {
|
|
const text = (child.textContent ?? "").trim()
|
|
if (text) children += JSON.stringify(text)
|
|
}
|
|
}
|
|
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
|
|
}
|
|
|
|
/**
|
|
* Structural fingerprint of a diagram document: page names + each page's
|
|
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
|
|
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
|
|
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
|
|
* Unparseable input falls back to the trimmed raw string, degrading to the
|
|
* plain string comparison.
|
|
*
|
|
* `includeNames=false` drops page names from the fingerprint — used when the
|
|
* other side of a comparison is a bare <mxGraphModel>, which carries no page
|
|
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
|
|
* any real page name).
|
|
*/
|
|
export function contentFingerprint(xml: string, includeNames = true): string {
|
|
const normalized = normalizeToMxfile(xml)
|
|
const doc = normalized ? parseMxfile(normalized) : null
|
|
if (!doc) return xml.trim()
|
|
const pages: string[] = []
|
|
doc.querySelectorAll("diagram").forEach((d) => {
|
|
const name = includeNames ? d.getAttribute("name") || "" : ""
|
|
const root = d.querySelector("root")
|
|
// No <root> means the page content is not plain XML (e.g. draw.io's
|
|
// compressed format) — fingerprint the raw text instead.
|
|
const body = root
|
|
? canonicalizeElement(root)
|
|
: (d.textContent || "").trim()
|
|
pages.push(`${name}=${body}`)
|
|
})
|
|
return pages.join("\n")
|
|
}
|
|
|
|
export function checkEditGate(
|
|
lastSeenXml: string,
|
|
liveXml: string,
|
|
): EditGateResult {
|
|
// Model never fetched or produced any diagram state in this session.
|
|
if (!lastSeenXml) return { ok: false, reason: "no-context" }
|
|
// Browser state moved since the model last looked (e.g. manual user
|
|
// edits): force a re-fetch so update/delete operations don't build on
|
|
// stale cell contents. An empty liveXml means the store has no entry to
|
|
// compare against, so there is nothing newer to have missed.
|
|
if (liveXml && liveXml !== lastSeenXml) {
|
|
// A bare <mxGraphModel> on either side carries no page name, so
|
|
// comparing names would mismatch against anything not called
|
|
// "Page-1". Compare cell trees only in that case.
|
|
const includeNames =
|
|
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
|
|
if (
|
|
contentFingerprint(liveXml, includeNames) !==
|
|
contentFingerprint(lastSeenXml, includeNames)
|
|
)
|
|
return { ok: false, reason: "stale" }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
/**
|
|
* The model was shown only the selected page of liveXml (get_diagram with a
|
|
* page selector, or a rejected edit's error). It has seen the whole document
|
|
* if the other pages are as it last saw them, or if it saw nothing before
|
|
* (it then has no old copy of them to edit from). Returns the new
|
|
* lastSeenXml: liveXml, or lastSeenXml unchanged.
|
|
*/
|
|
export function markPageSeen(
|
|
lastSeenXml: string,
|
|
liveXml: string,
|
|
selector: PageSelector,
|
|
): string {
|
|
const parse = (xml: string) => {
|
|
const normalized = normalizeToMxfile(xml)
|
|
return normalized ? parseMxfile(normalized) : null
|
|
}
|
|
// An empty record also follows load_diagram and page tools that wrote
|
|
// over unseen changes, when the model may remember older pages: only
|
|
// a one-page document is then fully seen
|
|
if (!lastSeenXml) {
|
|
const pages = parse(liveXml)?.querySelectorAll("diagram").length ?? 1
|
|
return pages <= 1 ? liveXml : lastSeenXml
|
|
}
|
|
const otherPages = (xml: string) => {
|
|
const doc = parse(xml)
|
|
if (!doc) return null
|
|
findPageElement(doc, selector)?.element.remove()
|
|
return contentFingerprint(serializeMxfile(doc))
|
|
}
|
|
const before = otherPages(lastSeenXml)
|
|
return before !== null && before === otherPages(liveXml)
|
|
? liveXml
|
|
: lastSeenXml
|
|
}
|
|
|
|
const CELL_TAGS = new Set(["mxCell", "UserObject", "object"])
|
|
|
|
/** The cells of a page by id: the direct children of its <root>. */
|
|
function pageCells(page: Element): Map<string, Element> {
|
|
const cells = new Map<string, Element>()
|
|
for (const el of Array.from(page.querySelector("root")?.children ?? [])) {
|
|
const id = el.getAttribute("id")
|
|
if (id && CELL_TAGS.has(el.tagName)) cells.set(id, el)
|
|
}
|
|
return cells
|
|
}
|
|
|
|
/** `id ("label")`: the cell's value without HTML, cut at 30 characters. */
|
|
function describeCell(id: string, cell: Element): string {
|
|
// getAttributeNode gives the decoded value (linkedom's getAttribute
|
|
// escapes it again); an HTML label then still holds tags and entities
|
|
const label = (
|
|
(cell.getAttributeNode("value") ?? cell.getAttributeNode("label"))
|
|
?.value ?? ""
|
|
)
|
|
.replace(/<[^>]*>/g, " ")
|
|
.replace(/ /g, " ")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/&/g, "&")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
if (!label) return id
|
|
return `${id} ("${label.length > 30 ? `${label.slice(0, 30)}...` : label}")`
|
|
}
|
|
|
|
/** At most 20 entries, then "and N more". */
|
|
function listCells(cells: string[]): string {
|
|
const shown = cells.slice(0, 20).join(", ")
|
|
return cells.length > 20 ? `${shown} and ${cells.length - 20} more` : shown
|
|
}
|
|
|
|
/**
|
|
* One paragraph naming what the user changed between the XML the model last
|
|
* saw and the live store, for stale rejections and get_diagram. Pages are
|
|
* paired by index (draw.io regenerates diagram ids on re-serialisation) and
|
|
* cells by id. Returns "" when there is nothing to compare or the two sides
|
|
* have the same content.
|
|
*/
|
|
export function describeChanges(lastSeenXml: string, liveXml: string): string {
|
|
if (!lastSeenXml || lastSeenXml === liveXml) return ""
|
|
const parse = (xml: string) => {
|
|
const normalized = normalizeToMxfile(xml)
|
|
return normalized ? parseMxfile(normalized) : null
|
|
}
|
|
const before = parse(lastSeenXml)
|
|
const live = parse(liveXml)
|
|
if (!before || !live) return ""
|
|
// Same rule as checkEditGate: a bare <mxGraphModel> carries no page name
|
|
const includeNames =
|
|
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
|
|
if (
|
|
contentFingerprint(lastSeenXml, includeNames) ===
|
|
contentFingerprint(liveXml, includeNames)
|
|
)
|
|
return ""
|
|
|
|
const pagesBefore = Array.from(before.querySelectorAll("diagram"))
|
|
const pagesLive = Array.from(live.querySelectorAll("diagram"))
|
|
const pageCount = Math.max(pagesBefore.length, pagesLive.length)
|
|
const clauses: string[] = []
|
|
const renames: string[] = []
|
|
for (let i = 0; i < pageCount; i++) {
|
|
const pageBefore = pagesBefore[i]
|
|
const pageLive = pagesLive[i]
|
|
if (!pageBefore || !pageLive) {
|
|
clauses.push(`page ${i + 1} ${pageLive ? "added" : "removed"}`)
|
|
continue
|
|
}
|
|
const cellsBefore = pageCells(pageBefore)
|
|
const cellsLive = pageCells(pageLive)
|
|
const modified: string[] = []
|
|
const added: string[] = []
|
|
const removed: string[] = []
|
|
for (const [id, cell] of cellsLive) {
|
|
const old = cellsBefore.get(id)
|
|
if (!old) added.push(describeCell(id, cell))
|
|
else if (canonicalizeElement(old) !== canonicalizeElement(cell))
|
|
modified.push(describeCell(id, cell))
|
|
}
|
|
for (const [id, cell] of cellsBefore) {
|
|
if (!cellsLive.has(id)) removed.push(describeCell(id, cell))
|
|
}
|
|
// Name the page only when the document has several
|
|
const where = pageCount > 1 ? `page ${i + 1} ` : ""
|
|
if (modified.length)
|
|
clauses.push(`${where}modified ${listCells(modified)}`)
|
|
if (added.length) clauses.push(`${where}added ${listCells(added)}`)
|
|
if (removed.length)
|
|
clauses.push(`${where}removed ${listCells(removed)}`)
|
|
if (includeNames) {
|
|
const name = pageLive.getAttribute("name") ?? ""
|
|
if (name !== (pageBefore.getAttribute("name") ?? ""))
|
|
renames.push(`Page ${i + 1} renamed to "${name}".`)
|
|
}
|
|
}
|
|
// The fingerprint also covers the cell order (z-order) and anything in
|
|
// <root> that is not a cell
|
|
if (!clauses.length && !renames.length)
|
|
clauses.push("the order of the cells changed")
|
|
const intro = "Since you last saw it, the user changed the diagram"
|
|
const body = clauses.length
|
|
? `${intro}: ${clauses.join("; ")}.`
|
|
: `${intro}.`
|
|
return [body, ...renames].join(" ")
|
|
}
|