mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 09:30:23 +08:00
* fix(mcp): keep diagram context valid during edits Closes #885 * fix(mcp): replace edit_diagram time gate with content comparison The 30s wall-clock gate rejected slow-but-correct clients (#885). Instead of a timeout, remember the exact state-store XML the model last saw (get_diagram / create_new_diagram / edit_diagram / page CRUD) and reject edit_diagram only when the live browser state differs - i.e. the user made edits the model hasn't seen yet. Slow reasoning no longer trips the gate, while unseen manual edits still do. * docs(mcp): align edit_diagram/get_diagram descriptions with content-based gate The 'You MUST call get_diagram BEFORE this tool' requirement and the 'Skipping get_diagram WILL cause user's changes to be LOST' warning no longer match server behavior: a stale edit is rejected with no side effects, never silently applied. Describe the freshness check instead, and direct get_diagram usage at its real purpose - learning the current diagram content when the model doesn't already know it. * fix(mcp): compare diagram content structurally in the edit gate draw.io re-serialises the document when pushing state back (attribute order, pretty-printing, regenerated diagram ids, viewport attributes, mxfile host), so byte comparison could flag an unchanged diagram as stale. Fingerprint what a user can actually change instead - page set, page names, and each page's root cell tree with sorted attributes - keeping byte equality as the fast path. A bare mxGraphModel now also fingerprints identically to its single-page mxfile wrapping. * fix(mcp): don't compare page names against bare mxGraphModel pushes A bare <mxGraphModel> pushed by the embed/sync path carries no page name, so normalizeToMxfile invents "Page-1" — falsely reading any custom page name as a content change and re-triggering the stale rejection on every edit. When either side of the gate comparison is a bare mxGraphModel, fingerprint cell trees only; full-mxfile comparisons still detect renames. * chore(mcp): bump version to 0.2.2 * chore(mcp): sync package-lock.json version to 0.2.2 --------- Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
103 lines
4.5 KiB
TypeScript
103 lines
4.5 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 { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
|
|
|
|
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 }
|
|
}
|