mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
Server (src/http-server.ts, src/index.ts, src/selection.ts, src/new-diagram.ts): - overlapping get_selection calls take turns (readSelection, one slot per session as the export slot) instead of replacing each other's request, which left one of them with a false "tab not in front" timeout - an answer is taken only while a request is pending (both ids undefined compared equal) - the tool sees the page start_session actually opens: with dist/shell missing the classic page is in use, which never answers, so the tool says so instead of timing out - the result lists at most 100 cells and counts the rest: a whole large diagram selected would fill the model's context - every <diagram> the model sends without an id gets one, so the page id the shell reports exists in the server's document Shell (shell/mcp-sync-core.ts, shell/use-mcp-sync.ts, contexts/diagram-context.tsx, lib/drawio/editor-bridge.ts): - an answer whose POST failed is sent again at the next poll - no answer while a full load has yet to reach the editor: it still shows the previous document, whose cells and pages the answer would name - a hidden tab (the same session open twice) answers a poll later, so the tab in front answers first; alone, it still answers within the timeout - a cell's container is reported by the model's isLayer, not by comparing with the default parent, which is the group the user entered
925 lines
36 KiB
TypeScript
925 lines
36 KiB
TypeScript
/**
|
|
* The shell's side of the MCP server's state protocol (src/http-server.ts),
|
|
* ported from the classic preview page (src/preview/preview.js) without the
|
|
* page: the server is reached through fetch, the canvas through the
|
|
* SyncCanvas functions the hook (use-mcp-sync.ts) passes in. No React here,
|
|
* so the state machine can be driven step by step in tests.
|
|
*
|
|
* The protocol in short: the tab polls GET /api/state every 2 s and loads a
|
|
* newer version; a user edit is pushed with the version and state it is
|
|
* based on, and the server refuses (409) a push based on a version the AI
|
|
* has since written over, or on a state it has lost (it expired, the
|
|
* process restarted); the next poll then decides whose diagram wins
|
|
* (recoverState). get_diagram asks for a sync (the canvas as draw.io has
|
|
* it) and screenshots and file exports for an image, both through the poll.
|
|
*/
|
|
import { isSameDocument, sameFileVars } from "@/lib/diagram-diff"
|
|
import { hasLoadOnlySettings } from "@/lib/drawio/editor-bridge"
|
|
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
|
|
import {
|
|
normalizeToMxfile,
|
|
parseMxfile,
|
|
serializeMxfile,
|
|
} from "@/packages/mcp-server/src/pages.ts"
|
|
import type { SelectionAnswer } from "@/packages/mcp-server/src/selection.ts"
|
|
|
|
export type ExportFormat = "png" | "svg" | "xmlsvg"
|
|
|
|
/** GET /api/state as the server answers it */
|
|
export interface ServerState {
|
|
xml: string | null
|
|
version: number
|
|
stateId: string | null
|
|
blank: boolean
|
|
syncRequested: boolean
|
|
exportFormat: ExportFormat | null
|
|
exportXml: string | null
|
|
exportOptions: { width?: number; pageId?: string } | null
|
|
exportId: string | null
|
|
/** A get_selection request waiting for this tab's answer */
|
|
selectionId?: string | null
|
|
/** Changes when History or the entry on the canvas does (the version
|
|
* cards read History again then); older servers send none */
|
|
historyKey?: string
|
|
}
|
|
|
|
/** An entry of GET /api/history */
|
|
export interface HistoryEntryInfo {
|
|
id: number
|
|
index: number
|
|
svg: string
|
|
xml: string
|
|
/** Who made it: "user", "recover", "restore"; null for the AI's writes */
|
|
source: string | null
|
|
/** The oldest entry with the same content (itself when it is the first) */
|
|
firstId: number
|
|
}
|
|
|
|
/** GET /api/history as the server answers it, for the state the list is of */
|
|
export interface HistoryList {
|
|
entries: HistoryEntryInfo[]
|
|
stateId: string | null
|
|
/** The entry whose content the canvas shows (the server's rule), or null */
|
|
currentId: number | null
|
|
}
|
|
|
|
/**
|
|
* How a server write goes on the canvas: as one undo step on the page the
|
|
* user is viewing (the editor highlights it and Ctrl+Z takes it back), or
|
|
* as a full load of the document (see decideLoad)
|
|
*/
|
|
export type LoadDecision =
|
|
| { mode: "commit"; pageId: string | null }
|
|
| { mode: "load" }
|
|
|
|
export interface ExportRequest {
|
|
format: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export interface ExportResult {
|
|
data?: string
|
|
xml?: string
|
|
}
|
|
|
|
/** The canvas as the sync drives it (the hook maps it onto DiagramProvider) */
|
|
export interface SyncCanvas {
|
|
load(xml: string, decision: LoadDecision): void
|
|
/** Show a one-page projection for an export only: not recorded, its
|
|
* autosaves ignored until the next load */
|
|
showTransient(xml: string): void
|
|
/** draw.io's export; null when it does not answer in time */
|
|
export(
|
|
request: ExportRequest,
|
|
timeoutMs: number,
|
|
): Promise<ExportResult | null>
|
|
/** The document on the canvas now ("" before the first load) */
|
|
currentXml(): string
|
|
/** The page on screen; null when unknown (external draw.io) */
|
|
currentPageId(): string | null
|
|
/** A full load was sent and draw.io has not reported it yet: the
|
|
* editor still shows the previous document */
|
|
loadPending(): boolean
|
|
/** The cells selected in the editor, for get_selection; `unavailable`
|
|
* when the page cannot reach the editor (external draw.io) */
|
|
readSelection(): SelectionAnswer
|
|
}
|
|
|
|
export type SyncStatus = "waiting" | "connected" | "offline"
|
|
|
|
/** Texts shown to the user (the shell takes them from its dictionary) */
|
|
export type SyncNotice =
|
|
| "tooLarge"
|
|
| "unreachable"
|
|
| "restoredFromFile"
|
|
| "aiChanged"
|
|
| "historyChanged"
|
|
| "restoreFailed"
|
|
|
|
export interface SyncOptions {
|
|
sessionId: string
|
|
/** Prefix of the server's API paths ("/api") */
|
|
apiBase: string
|
|
/** Every request carries it in the X-Drawio-Token header */
|
|
token: string
|
|
canvas: SyncCanvas
|
|
onNotice: (notice: SyncNotice) => void
|
|
onStatus?: (status: SyncStatus) => void
|
|
/** The tab is hidden; another tab of this session may be in front */
|
|
isHidden?: () => boolean
|
|
/** The server made a new state for the session: History entries got
|
|
* new ids, so a list on screen is stale */
|
|
onStateRecreated?: () => void
|
|
/** The token of the process now answering on this port, read from a
|
|
* fresh copy of this page; default: tokenFromPage of location.href */
|
|
refreshToken?: () => Promise<string | null>
|
|
fetch?: typeof fetch
|
|
}
|
|
|
|
export interface McpSync {
|
|
/** Poll now and every 2 s */
|
|
start(): void
|
|
stop(): void
|
|
poll(): Promise<void>
|
|
/** draw.io is ready for exports (DiagramProvider's isDrawioReady) */
|
|
setReady(ready: boolean): void
|
|
/** draw.io sent an autosave: the canvas changed to this document */
|
|
onAutoSave(xml: string): void
|
|
/** draw.io reported a load done */
|
|
onDrawioLoad(): void
|
|
/** The server's History, with the state its ids belong to; null when
|
|
* it could not be read, or the state changed meanwhile (the version
|
|
* cards, shell/node-versions-source.ts) */
|
|
fetchHistory(): Promise<HistoryList | null>
|
|
/** Put a History entry back, naming the state its list belongs to */
|
|
restoreEntry(
|
|
id: number,
|
|
listStateId: string | null,
|
|
): Promise<"ok" | "stale" | "failed">
|
|
/** Called when the server's diagram or History changed (a write
|
|
* loaded, an edit saved, a thumbnail sent, the state recreated): the
|
|
* version cards read History again. Returns the unsubscribe */
|
|
onServerChange(listener: () => void): () => void
|
|
/** For tests and the status bar */
|
|
read(): {
|
|
stateId: string | null
|
|
currentVersion: number
|
|
lastXml: string | null
|
|
latestXml: string | null
|
|
projectionActive: boolean
|
|
status: SyncStatus
|
|
}
|
|
}
|
|
|
|
export const POLL_INTERVAL_MS = 2000
|
|
|
|
/** The API token the server wrote into a shell page (window.__MCP_CONFIG__) */
|
|
export function tokenFromPage(html: string): string | null {
|
|
const match = html.match(/window\.__MCP_CONFIG__ = (\{.*?\});/)
|
|
if (!match) return null
|
|
try {
|
|
const token = JSON.parse(match[1]).token
|
|
return typeof token === "string" && token ? token : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The same diagram, as text or as documents (draw.io re-serializes what it
|
|
* loads: another attribute order, filled-in defaults). Only documents with
|
|
* a page or a model are compared as documents: two without any would
|
|
* otherwise count as the same.
|
|
*/
|
|
function sameDiagram(a: string | null, b: string | null): boolean {
|
|
if (a === b) return true
|
|
if (a === null || b === null) return false
|
|
const hasModel = (xml: string) => /<(mxGraphModel|diagram)[\s>]/.test(xml)
|
|
return hasModel(a) && hasModel(b) && isSameDocument(a, b)
|
|
}
|
|
|
|
/** SVG as a data URL, as the server stores thumbnails */
|
|
function svgDataUrl(svg: string): string {
|
|
if (svg.startsWith("data:")) return svg
|
|
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
|
|
}
|
|
|
|
/**
|
|
* Whether a server write can go on the canvas as one undo step: it has the
|
|
* same pages as the canvas, changes only the one the user is viewing (the
|
|
* one with currentPageId; a one-page document needs no id, several pages
|
|
* with the page unknown load in full), keeps the file variables and needs
|
|
* no load-only page setting. Pages are compared with the MCP core's
|
|
* contentFingerprint (names and cells). The editor bridge checks the same
|
|
* against the live editor and falls back to a full load on its own.
|
|
*/
|
|
export function decideLoad(
|
|
currentXml: string,
|
|
nextXml: string,
|
|
currentPageId: string | null,
|
|
): LoadDecision {
|
|
const parse = (xml: string) => {
|
|
const normalized = normalizeToMxfile(xml)
|
|
return normalized ? parseMxfile(normalized) : null
|
|
}
|
|
const full: LoadDecision = { mode: "load" }
|
|
const current = parse(currentXml)
|
|
const next = parse(nextXml)
|
|
if (!current || !next) return full
|
|
if (
|
|
!sameFileVars(
|
|
current.documentElement.getAttribute("vars"),
|
|
next.documentElement.getAttribute("vars"),
|
|
)
|
|
) {
|
|
return full
|
|
}
|
|
const pagesNow = Array.from(current.querySelectorAll("diagram"))
|
|
const pagesNext = Array.from(next.querySelectorAll("diagram"))
|
|
if (pagesNow.length === 0 || pagesNow.length !== pagesNext.length) {
|
|
return full
|
|
}
|
|
let index = pagesNow.findIndex(
|
|
(page) => page.getAttribute("id") === currentPageId,
|
|
)
|
|
if (index < 0) {
|
|
// The page on screen is unknown (an external draw.io cannot tell):
|
|
// with several pages the target cannot be told, load in full
|
|
if (pagesNow.length > 1) return full
|
|
index = 0
|
|
}
|
|
const target = pagesNext[index]
|
|
// The page is found by its id when the document has several
|
|
if (
|
|
pagesNow.length > 1 &&
|
|
target.getAttribute("id") !== pagesNow[index].getAttribute("id")
|
|
) {
|
|
return full
|
|
}
|
|
const targetModel = target.querySelector("mxGraphModel")
|
|
if (targetModel && hasLoadOnlySettings(targetModel)) return full
|
|
// The other pages, as the MCP core compares them (edit-gate.ts)
|
|
const others = (doc: Document, pages: Element[]) => {
|
|
pages[index].remove()
|
|
return contentFingerprint(serializeMxfile(doc))
|
|
}
|
|
if (others(current, pagesNow) !== others(next, pagesNext)) return full
|
|
return { mode: "commit", pageId: target.getAttribute("id") }
|
|
}
|
|
|
|
export function createMcpSync(options: SyncOptions): McpSync {
|
|
const { sessionId, apiBase, canvas, onNotice, onStatus } = options
|
|
const fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args))
|
|
let token = options.token
|
|
|
|
let currentVersion = 0
|
|
let isReady = false
|
|
// What the server has (the text it sent or we pushed) and the newest
|
|
// diagram on the canvas, saved to the server or not
|
|
let lastXml: string | null = null
|
|
let latestXml: string | null = null
|
|
// The server state this tab is in step with; null until the first poll
|
|
let stateId: string | null = null
|
|
let pushFailing = false
|
|
const pushesInFlight: string[] = []
|
|
// After a recovery replaced the canvas, until draw.io reports the load:
|
|
// an autosave still on its way belongs to the canvas being replaced
|
|
let awaitingLoad = false
|
|
let awaitingLoadTimer: ReturnType<typeof setTimeout> | null = null
|
|
// A write taken in place (a commit) makes draw.io autosave its own copy
|
|
// of it, serialized its way: that one autosave is not an edit
|
|
let awaitingCommitCopy = false
|
|
let pollSeq = 0
|
|
let lastHandledPoll = 0 // polls overlap; older answers are dropped
|
|
// The edit whose SVG export is pending, and what it was based on
|
|
let pendingSvgExport: string | null = null
|
|
// The latest thumbnail export of a loaded server write: the state and
|
|
// version it showed, and the XML loaded
|
|
let thumbExport: {
|
|
stateId: string | null
|
|
version: number
|
|
xml: string
|
|
} | null = null
|
|
let pendingMcpExport: ExportFormat | null = null
|
|
let mcpExportSeq = 0
|
|
let mcpExportId: string | null = null
|
|
// A one-page projection is on screen for a page-targeted export
|
|
let projectionExportActive = false
|
|
// The restore load of the real document was sent; until draw.io reports
|
|
// it (or 5 s), the projection still counts as on screen
|
|
let projectionRestoreTimer: ReturnType<typeof setTimeout> | null = null
|
|
const endProjection = () => {
|
|
projectionExportActive = false
|
|
if (projectionRestoreTimer) clearTimeout(projectionRestoreTimer)
|
|
projectionRestoreTimer = null
|
|
}
|
|
// Load the server state on the next poll even at the same version
|
|
let forceReload = false
|
|
let pendingSyncExport = false
|
|
let syncExportSeq = 0
|
|
// The selection request answered last: the server shows a request until
|
|
// the answer arrives, and polls overlap
|
|
let answeredSelectionId: string | null = null
|
|
let status: SyncStatus = "waiting"
|
|
let interval: ReturnType<typeof setInterval> | null = null
|
|
|
|
const setStatus = (next: SyncStatus) => {
|
|
if (status === next) return
|
|
status = next
|
|
onStatus?.(next)
|
|
}
|
|
|
|
// Listeners of onServerChange, told once per History key seen (the
|
|
// server sends it with every state and push answer; a write, a restore
|
|
// and the first hand edit change it, further hand edits do not) and
|
|
// whenever History changed without one (a rejected edit kept, a
|
|
// thumbnail stored)
|
|
const changeListeners = new Set<() => void>()
|
|
let notedKey: string | null = null
|
|
let notedStateId: string | null = null
|
|
const emitChange = () => {
|
|
for (const listener of changeListeners) listener()
|
|
}
|
|
const noteHistory = (key: string, sid: string | null) => {
|
|
if (key === notedKey && sid === notedStateId) return
|
|
notedKey = key
|
|
notedStateId = sid
|
|
emitChange()
|
|
}
|
|
|
|
// A 403 means another MCP process (with its own token) now answers on
|
|
// this port: read its token and retry once, so the next poll can recover
|
|
// the session instead of being refused forever
|
|
let tokenRefresh: Promise<void> | null = null
|
|
const refreshToken = () => {
|
|
if (!tokenRefresh) {
|
|
const read =
|
|
options.refreshToken ??
|
|
(() =>
|
|
fetchFn(location.href, { cache: "no-store" })
|
|
.then((r) => r.text())
|
|
.then(tokenFromPage))
|
|
tokenRefresh = read()
|
|
.then((next) => {
|
|
if (next) token = next
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
tokenRefresh = null
|
|
})
|
|
}
|
|
return tokenRefresh
|
|
}
|
|
|
|
const api = async (path: string, init: RequestInit = {}) => {
|
|
const send = () =>
|
|
fetchFn(`${apiBase}${path}`, {
|
|
...init,
|
|
headers: { ...(init.headers ?? {}), "X-Drawio-Token": token },
|
|
})
|
|
let r = await send()
|
|
if (r.status === 403) {
|
|
await refreshToken()
|
|
r = await send()
|
|
}
|
|
return r
|
|
}
|
|
|
|
const postJson = (path: string, body: unknown) =>
|
|
api(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
/** Put a server write on the canvas and take its thumbnail */
|
|
const loadFromServer = (xml: string, how: "decide" | "full") => {
|
|
lastXml = xml
|
|
latestXml = xml
|
|
const before = canvas.currentXml()
|
|
const decision: LoadDecision =
|
|
how === "decide"
|
|
? decideLoad(before, xml, canvas.currentPageId())
|
|
: { mode: "load" }
|
|
canvas.load(xml, decision)
|
|
// draw.io autosaves its copy only when the write changed its
|
|
// document: after one that left the canvas as it was, the next
|
|
// autosave is the user's
|
|
awaitingCommitCopy =
|
|
decision.mode === "commit" && !sameDiagram(before, xml)
|
|
// currentVersion is the write's version
|
|
thumbExport = { stateId, version: currentVersion, xml }
|
|
setTimeout(captureThumbnail, 500)
|
|
}
|
|
|
|
// The image of the server write on the canvas, for its History entry:
|
|
// only for the latest load, once draw.io is ready, and only if the
|
|
// canvas still shows it when the export answers
|
|
const captureThumbnail = () => {
|
|
const t = thumbExport
|
|
if (!t || !isReady) return
|
|
canvas.export({ format: "svg" }, 5000).then((result) => {
|
|
if (!result?.data || thumbExport !== t) return
|
|
if (!sameDiagram(latestXml, t.xml)) return
|
|
thumbExport = null
|
|
postJson("/history-svg", {
|
|
sessionId,
|
|
svg: svgDataUrl(result.data),
|
|
stateId: t.stateId,
|
|
version: t.version,
|
|
})
|
|
// The write's History entry has its picture now
|
|
.then(emitChange)
|
|
.catch(() => {})
|
|
})
|
|
}
|
|
|
|
// Until draw.io reports the load (its messages come in order), an
|
|
// autosave is from the canvas being replaced; in case no report comes,
|
|
// not for long
|
|
const expectLoad = () => {
|
|
awaitingLoad = true
|
|
if (awaitingLoadTimer) clearTimeout(awaitingLoadTimer)
|
|
awaitingLoadTimer = setTimeout(() => {
|
|
awaitingLoad = false
|
|
}, 5000)
|
|
}
|
|
|
|
// Restore the user's real document after a page-targeted projection
|
|
// export by reloading the server state (it also has any autosave that
|
|
// was still in flight when the projection started). projectionExportActive
|
|
// stays set until the poll loads the document: an edit on the
|
|
// projection before that must not be pushed.
|
|
const restoreFromProjection = () => {
|
|
if (!projectionExportActive) return
|
|
forceReload = true
|
|
poll()
|
|
}
|
|
|
|
// source is "sync" for replies to a server sync request, "recover" for
|
|
// the tab's copy after the server recovered the session, else "edit".
|
|
// sid is the server state the push is based on.
|
|
async function pushState(
|
|
xml: string,
|
|
svg = "",
|
|
baseVersion = currentVersion,
|
|
source: "edit" | "sync" | "recover" = "edit",
|
|
sid = stateId,
|
|
): Promise<void> {
|
|
if (!sessionId) return
|
|
pushesInFlight.push(xml)
|
|
try {
|
|
const r = await postJson("/state", {
|
|
sessionId,
|
|
xml,
|
|
svg,
|
|
baseVersion,
|
|
source,
|
|
stateId: sid,
|
|
})
|
|
pushFailing = false
|
|
if (r.ok) {
|
|
const d = await r.json()
|
|
// An answer about a state this tab has left since, or one
|
|
// that comes after a newer version was loaded or saved
|
|
if (sid !== stateId || d.version < currentVersion) return
|
|
currentVersion = d.version
|
|
lastXml = xml
|
|
noteHistory(d.historyKey ?? String(d.version), stateId)
|
|
// The canvas changed while this edit was on its way, to
|
|
// something no pending autosave will send (an undo back to
|
|
// the previous version): send it now. A sync reply is
|
|
// draw.io's export of the canvas, in another format than its
|
|
// autosave.
|
|
if (
|
|
latestXml &&
|
|
latestXml !== xml &&
|
|
pendingSvgExport !== latestXml &&
|
|
source === "edit"
|
|
) {
|
|
pushState(latestXml)
|
|
}
|
|
} else if (r.status === 413) {
|
|
// Over the server's size limit: the image is most of it, so
|
|
// try once without it
|
|
if (svg) pushState(xml, "", baseVersion, source, sid)
|
|
else onNotice("tooLarge")
|
|
} else if (r.status === 409) {
|
|
// The AI wrote a newer version, or the server lost the state
|
|
// this push was based on; the next poll sorts it out
|
|
const d = await r.json().catch(() => ({}))
|
|
if (d.savedToHistory) {
|
|
onNotice(
|
|
source === "recover" ? "restoredFromFile" : "aiChanged",
|
|
)
|
|
// History grew without a new version
|
|
emitChange()
|
|
}
|
|
poll()
|
|
}
|
|
} catch (error) {
|
|
console.error("Push failed:", error)
|
|
setStatus("offline")
|
|
if (!pushFailing) {
|
|
pushFailing = true
|
|
onNotice("unreachable")
|
|
}
|
|
} finally {
|
|
pushesInFlight.splice(pushesInFlight.indexOf(xml), 1)
|
|
}
|
|
}
|
|
|
|
// The server made a new state for this session: it expired, or the MCP
|
|
// process restarted. Decide whose diagram wins.
|
|
const recoverState = (s: ServerState) => {
|
|
stateId = s.stateId
|
|
options.onStateRecreated?.()
|
|
// The old state's pending work is gone with it
|
|
const projectionShown = projectionExportActive
|
|
endProjection()
|
|
forceReload = false
|
|
pendingMcpExport = null
|
|
pendingSyncExport = false
|
|
const mine = latestXml
|
|
currentVersion = s.version
|
|
if (s.blank || s.xml === lastXml) {
|
|
// The server knows nothing, or exactly what this tab last saved:
|
|
// the canvas can only be newer, so it wins (edits made while the
|
|
// server was down are saved now)
|
|
if (projectionShown && mine) {
|
|
canvas.load(mine, { mode: "load" })
|
|
expectLoad()
|
|
}
|
|
if (mine && !sameDiagram(mine, s.xml)) {
|
|
pushState(mine, "", s.version)
|
|
}
|
|
} else if (s.xml) {
|
|
// The server has a diagram this tab never showed (an AI write it
|
|
// missed, a saved file): show that, and keep this tab's copy in
|
|
// History unless it is the same. A full load, as the classic
|
|
// page does: whether the canvas can take the write in place is
|
|
// decided inside DiagramProvider, and the sync cannot tell; until
|
|
// draw.io reports the load, an autosave is from the canvas being
|
|
// replaced
|
|
loadFromServer(s.xml, "full")
|
|
expectLoad()
|
|
if (mine && !sameDiagram(mine, s.xml)) {
|
|
pushState(mine, "", s.version, "recover")
|
|
}
|
|
}
|
|
}
|
|
|
|
const startSyncExport = () => {
|
|
pendingSyncExport = true
|
|
// The version and state the export is taken at: a newer AI write may
|
|
// load meanwhile, and this older XML must not overwrite it
|
|
const base = currentVersion
|
|
const sid = stateId
|
|
const seq = ++syncExportSeq
|
|
canvas.export({ format: "xml" }, 5000).then((result) => {
|
|
// A late reply to an earlier request was taken at another version
|
|
if (seq !== syncExportSeq) return
|
|
pendingSyncExport = false
|
|
if (result?.xml) pushState(result.xml, "", base, "sync", sid)
|
|
})
|
|
}
|
|
|
|
// Handle an export request from the MCP server (png/svg).
|
|
//
|
|
// Plain export: capture whatever page is on screen (PNG: pageId picks a
|
|
// page, width caps the size).
|
|
//
|
|
// Page-targeted export: the server sends a single-page <mxfile>
|
|
// projection in exportXml. It goes on the canvas as a transient
|
|
// document, draw.io renders it, exports, then the real document is
|
|
// loaded back from the server. The session state is never changed.
|
|
const startMcpExport = (s: ServerState, justLoaded: boolean) => {
|
|
const format = s.exportFormat as ExportFormat
|
|
pendingMcpExport = format
|
|
const seq = ++mcpExportSeq
|
|
mcpExportId = s.exportId
|
|
const extra = s.exportOptions ?? {}
|
|
const fire = () => {
|
|
const request: ExportRequest =
|
|
format === "png"
|
|
? {
|
|
format: "png",
|
|
scale: 2,
|
|
currentPage: !extra.pageId,
|
|
...extra,
|
|
}
|
|
: { format }
|
|
canvas.export(request, 10000).then((result) => {
|
|
// A later export is running by now, or this one ended
|
|
if (seq !== mcpExportSeq || pendingMcpExport === null) return
|
|
const d = result?.data ?? ""
|
|
const isPng = format === "png" && d.startsWith("data:image/png")
|
|
const isSvg =
|
|
format !== "png" &&
|
|
(d.startsWith("data:image/svg") || d.startsWith("<svg"))
|
|
const done = () => {
|
|
if (seq !== mcpExportSeq) return
|
|
pendingMcpExport = null
|
|
// Page-targeted export: restore the user's real document
|
|
restoreFromProjection()
|
|
}
|
|
if (!isPng && !isSvg) {
|
|
done()
|
|
return
|
|
}
|
|
// Keep pendingMcpExport set until the server has the result:
|
|
// a poll answered before that still sees the request and
|
|
// would start the same export again
|
|
postJson("/state", {
|
|
sessionId,
|
|
exportData: d,
|
|
exportId: mcpExportId,
|
|
})
|
|
.catch(() => {})
|
|
.finally(done)
|
|
})
|
|
}
|
|
if (s.exportXml) {
|
|
projectionExportActive = true
|
|
canvas.showTransient(s.exportXml)
|
|
// Let draw.io render the loaded page before exporting
|
|
setTimeout(fire, 600)
|
|
} else if (justLoaded) {
|
|
// A write with a screenshot: give the new diagram's external
|
|
// icon images a moment to load before the PNG is taken
|
|
setTimeout(fire, 600)
|
|
} else {
|
|
fire()
|
|
}
|
|
// The whole export, result delivery included, ends in time: a result
|
|
// POST that never answers must not keep the projection on screen
|
|
// past the server's deadline (15 s). Only for this export: a later
|
|
// one may be running by then. The export is over: its result POST
|
|
// answering after this must not restore the document a second time.
|
|
setTimeout(() => {
|
|
if (pendingMcpExport && seq === mcpExportSeq) {
|
|
mcpExportSeq++
|
|
pendingMcpExport = null
|
|
restoreFromProjection()
|
|
}
|
|
}, 15000)
|
|
}
|
|
|
|
async function poll(): Promise<void> {
|
|
if (!sessionId) return
|
|
const seq = ++pollSeq
|
|
try {
|
|
const r = await api(
|
|
`/state?sessionId=${encodeURIComponent(sessionId)}`,
|
|
)
|
|
if (!r.ok) {
|
|
// Refused (another process answers on this port and its
|
|
// token could not be read) or failing: edits are not saved
|
|
setStatus("offline")
|
|
return
|
|
}
|
|
const s: ServerState = await r.json()
|
|
// An older answer than one already handled (the interval, the
|
|
// 409 handler and the projection restore each poll): it could
|
|
// name a state that is gone
|
|
if (seq < lastHandledPoll) return
|
|
lastHandledPoll = seq
|
|
setStatus("connected")
|
|
if (stateId === null) stateId = s.stateId
|
|
else if (s.stateId && s.stateId !== stateId) recoverState(s)
|
|
// Load a new version (before an export, so it pictures the
|
|
// latest). While a projection is on screen, only the restore
|
|
// (forceReload) replaces it, so a new version doesn't fight the
|
|
// projection; currentVersion stays unadvanced until then. The
|
|
// tab's own push still on its way is not loaded back: the canvas
|
|
// may have moved on since (an undo), and its answer follows.
|
|
const ownPush = s.xml !== null && pushesInFlight.includes(s.xml)
|
|
let justLoaded = false
|
|
if (
|
|
(forceReload ||
|
|
(s.version > currentVersion &&
|
|
!projectionExportActive &&
|
|
!ownPush)) &&
|
|
s.xml
|
|
) {
|
|
// The restore after a projection is a full load of the
|
|
// document the user had
|
|
const how = forceReload ? "full" : "decide"
|
|
forceReload = false
|
|
currentVersion = s.version
|
|
loadFromServer(s.xml, how)
|
|
// The projection stays on screen until draw.io reports this
|
|
// load (its messages come in order): an autosave before that
|
|
// is of the projection, not an edit
|
|
if (projectionExportActive && !projectionRestoreTimer) {
|
|
projectionRestoreTimer = setTimeout(endProjection, 5000)
|
|
}
|
|
justLoaded = true
|
|
}
|
|
// Also the tab's own push, and a version a projection holds back
|
|
noteHistory(s.historyKey ?? String(s.version), stateId)
|
|
// A sync request (get_diagram): after the load above, so draw.io
|
|
// exports what it just loaded; never while a one-page projection
|
|
// is on screen, which would be sent as the whole document
|
|
if (
|
|
s.syncRequested &&
|
|
!pendingSyncExport &&
|
|
isReady &&
|
|
!projectionExportActive
|
|
) {
|
|
startSyncExport()
|
|
}
|
|
// Nor an export while the projection is still on screen: it
|
|
// would picture the projection
|
|
if (
|
|
s.exportFormat &&
|
|
!pendingMcpExport &&
|
|
isReady &&
|
|
!projectionExportActive
|
|
) {
|
|
startMcpExport(s, justLoaded)
|
|
}
|
|
// A selection request (get_selection): the editor's selection
|
|
// on the page the user is viewing, once draw.io is up (the
|
|
// bridge attaches to it at its first load), not while a
|
|
// projection replaces that page, and not while a full load (the
|
|
// one above, or one still on its way) has yet to reach the
|
|
// editor: it still shows the previous document
|
|
if (
|
|
s.selectionId &&
|
|
s.selectionId !== answeredSelectionId &&
|
|
isReady &&
|
|
!projectionExportActive &&
|
|
!canvas.loadPending()
|
|
) {
|
|
const id = s.selectionId
|
|
answeredSelectionId = id
|
|
const answer = () =>
|
|
postJson("/state", {
|
|
sessionId,
|
|
selectionId: id,
|
|
selection: canvas.readSelection(),
|
|
})
|
|
.then((r) => {
|
|
if (!r.ok) throw new Error(String(r.status))
|
|
})
|
|
.catch(() => {
|
|
// Not delivered: the next poll answers it again
|
|
if (answeredSelectionId === id) {
|
|
answeredSelectionId = null
|
|
}
|
|
})
|
|
// A hidden tab lets a tab in front (the same session open
|
|
// twice) answer first; the server takes the first answer
|
|
// and ignores the later one
|
|
if (options.isHidden?.()) {
|
|
setTimeout(answer, POLL_INTERVAL_MS + 500)
|
|
} else {
|
|
answer()
|
|
}
|
|
}
|
|
} catch {
|
|
setStatus("offline")
|
|
}
|
|
}
|
|
|
|
return {
|
|
start() {
|
|
if (!sessionId || interval) return
|
|
poll()
|
|
interval = setInterval(poll, POLL_INTERVAL_MS)
|
|
},
|
|
stop() {
|
|
if (interval) clearInterval(interval)
|
|
interval = null
|
|
},
|
|
poll,
|
|
setReady(ready) {
|
|
isReady = ready
|
|
// draw.io is starting over (an external editor reloads on a
|
|
// theme switch): an export in flight gets no answer, and a
|
|
// projection on the canvas is gone with the old frame. The next
|
|
// poll puts the document back and starts the server's export
|
|
// again
|
|
if (!ready && pendingMcpExport !== null) {
|
|
mcpExportSeq++
|
|
pendingMcpExport = null
|
|
if (projectionExportActive) forceReload = true
|
|
}
|
|
// A write loaded before draw.io was ready gets its thumbnail now
|
|
if (ready && thumbExport) captureThumbnail()
|
|
},
|
|
onAutoSave(xml) {
|
|
// Ignore autosave while a single-page projection is on screen
|
|
// for a page-targeted export; otherwise the projection would be
|
|
// pushed as the session's document
|
|
if (projectionExportActive) return
|
|
// An edit of the canvas that recovery is replacing: kept in
|
|
// History, never over the recovered diagram
|
|
if (awaitingLoad) {
|
|
pushState(xml, "", currentVersion, "recover")
|
|
return
|
|
}
|
|
// Also an edit undone back to what the server has, or draw.io's
|
|
// own copy of a write it took in place (a commit). Compared as
|
|
// documents only for that copy: a change of the paper size alone
|
|
// is the same document to isSameDocument when the server's copy
|
|
// names none
|
|
latestXml = xml
|
|
const commitCopy = awaitingCommitCopy
|
|
awaitingCommitCopy = false
|
|
if (xml === lastXml || (commitCopy && sameDiagram(xml, lastXml))) {
|
|
return
|
|
}
|
|
// Request an SVG export, then push the edit with it; remember
|
|
// the version and state it is based on. Without an answer in
|
|
// time, push it without the image.
|
|
pendingSvgExport = xml
|
|
const base = currentVersion
|
|
const sid = stateId
|
|
canvas.export({ format: "svg" }, 2000).then((result) => {
|
|
// A later edit took over the slot, or this one was sent
|
|
if (pendingSvgExport !== xml) return
|
|
pendingSvgExport = null
|
|
const svg = result?.data ? svgDataUrl(result.data) : ""
|
|
pushState(xml, svg, base, "edit", sid)
|
|
})
|
|
},
|
|
onDrawioLoad() {
|
|
awaitingLoad = false
|
|
// A commit that fell back to a full load sends no copy
|
|
awaitingCommitCopy = false
|
|
// The restore load after a projection export is done
|
|
if (projectionRestoreTimer) endProjection()
|
|
},
|
|
async fetchHistory() {
|
|
// A list for a state the server recreated meanwhile has old ids
|
|
const sid = stateId
|
|
// A failed read is told again at the next poll: no other event
|
|
// comes for the same History
|
|
const failed = () => {
|
|
notedKey = null
|
|
return null
|
|
}
|
|
try {
|
|
const r = await api(
|
|
`/history?sessionId=${encodeURIComponent(sessionId)}`,
|
|
)
|
|
if (!r.ok) return failed()
|
|
const d = await r.json()
|
|
if (sid !== stateId) return null
|
|
// A list from a state the poll has not seen yet (another
|
|
// process answers): its poll tells the cards to read again
|
|
if (typeof d.stateId === "string" && d.stateId !== sid) {
|
|
return null
|
|
}
|
|
return {
|
|
entries: d.entries ?? [],
|
|
stateId: sid,
|
|
currentId:
|
|
typeof d.currentId === "number" ? d.currentId : null,
|
|
}
|
|
} catch {
|
|
return failed()
|
|
}
|
|
},
|
|
async restoreEntry(id, listStateId) {
|
|
try {
|
|
const r = await postJson("/restore", {
|
|
sessionId,
|
|
id,
|
|
stateId: listStateId,
|
|
})
|
|
if (r.ok) {
|
|
await poll()
|
|
return "ok"
|
|
}
|
|
if (r.status === 409) {
|
|
onNotice("historyChanged")
|
|
return "stale"
|
|
}
|
|
} catch {
|
|
// reported below
|
|
}
|
|
onNotice("restoreFailed")
|
|
return "failed"
|
|
},
|
|
onServerChange(listener) {
|
|
changeListeners.add(listener)
|
|
return () => {
|
|
changeListeners.delete(listener)
|
|
}
|
|
},
|
|
read() {
|
|
return {
|
|
stateId,
|
|
currentVersion,
|
|
lastXml,
|
|
latestXml,
|
|
projectionActive: projectionExportActive,
|
|
status,
|
|
}
|
|
},
|
|
}
|
|
}
|