feat(mcp-server): the shell syncs with the server like the classic page

shell/mcp-sync-core.ts ports preview.js's protocol without the page or
React: polling GET /api/state every 2 s with the state id and version,
pushes of the user's edits with their base version and state, 409 and 413
handling, recovery of a recreated session (the tab's copy goes to History),
the token refresh after a 403, sync and export requests (PNG by page id,
SVG of another page through a transient one-page projection with autosave
ignored, the 600 ms wait for icons), thumbnails for History, and History
reads and restores that name the state the list belongs to.

A server write goes on the canvas as one undo step (loadDiagram commit,
with the change marked) when decideLoad finds it changes only the page on
screen, keeps the file variables and needs no load-only setting; the other
pages are compared with the MCP core's contentFingerprint. draw.io's own
re-serialized copy of such a write is recognized with isSameDocument and
not pushed back as an edit.

shell/use-mcp-sync.ts mounts the sync once inside DiagramProvider, reads
draw.io's autosave and load messages, and reports the connection state,
which the shell's status bar shows; notices come as toasts from the
dictionaries.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent b4c34c9361
commit a2ca229830
9 changed files with 913 additions and 6 deletions
+9 -1
View File
@@ -702,6 +702,14 @@
},
"shell": {
"waiting": "Waiting for the MCP server…",
"toggleTheme": "Switch between light and dark"
"toggleTheme": "Switch between light and dark",
"connected": "Connected to the MCP server",
"offline": "Can't reach the MCP server",
"tooLarge": "This diagram is too large to save to the MCP server (over 10 MB).",
"unreachable": "Can't reach the MCP server. Your changes are only in this tab for now.",
"restoredFromFile": "The diagram was restored from its saved file. What this tab showed before is in History.",
"aiChanged": "The AI changed the diagram while you were editing. Your last change was saved in History.",
"historyChanged": "History changed on the server. Please open it again.",
"restoreFailed": "Restore failed. Please try again."
}
}
+9 -1
View File
@@ -702,6 +702,14 @@
},
"shell": {
"waiting": "MCP サーバーを待っています…",
"toggleTheme": "ライトとダークを切り替え"
"toggleTheme": "ライトとダークを切り替え",
"connected": "MCP サーバーに接続しました",
"offline": "MCP サーバーに接続できません",
"tooLarge": "この図は大きすぎて MCP サーバーに保存できません(10 MB 超)。",
"unreachable": "MCP サーバーに接続できません。変更は今のところこのタブにだけあります。",
"restoredFromFile": "図は保存済みファイルから復元されました。このタブに表示されていた内容は履歴にあります。",
"aiChanged": "編集中に AI が図を変更しました。最後の変更は履歴に保存されました。",
"historyChanged": "サーバー上の履歴が変わりました。もう一度開いてください。",
"restoreFailed": "復元に失敗しました。もう一度お試しください。"
}
}
+9 -1
View File
@@ -702,6 +702,14 @@
},
"shell": {
"waiting": "正在等待 MCP 伺服器…",
"toggleTheme": "切換淺色或深色"
"toggleTheme": "切換淺色或深色",
"connected": "已連接 MCP 伺服器",
"offline": "無法連接 MCP 伺服器",
"tooLarge": "這張圖太大,無法儲存到 MCP 伺服器(超過 10 MB)。",
"unreachable": "無法連接 MCP 伺服器。您的修改暫時只保存在這個分頁裡。",
"restoredFromFile": "圖表已從儲存的檔案還原。這個分頁之前顯示的內容已放入歷史記錄。",
"aiChanged": "您編輯時 AI 修改了圖表。您的最後一次修改已儲存到歷史記錄。",
"historyChanged": "伺服器上的歷史記錄已變化,請重新開啟。",
"restoreFailed": "還原失敗,請再試一次。"
}
}
+9 -1
View File
@@ -702,6 +702,14 @@
},
"shell": {
"waiting": "正在等待 MCP 服务器…",
"toggleTheme": "切换浅色或深色"
"toggleTheme": "切换浅色或深色",
"connected": "已连接 MCP 服务器",
"offline": "无法连接 MCP 服务器",
"tooLarge": "这张图太大,无法保存到 MCP 服务器(超过 10 MB)。",
"unreachable": "无法连接 MCP 服务器。您的修改暂时只保存在这个标签页里。",
"restoredFromFile": "图表已从保存的文件恢复。这个标签页之前显示的内容已放入历史记录。",
"aiChanged": "您编辑时 AI 修改了图表。您的最后一次修改已保存到历史记录。",
"historyChanged": "服务器上的历史记录已变化,请重新打开。",
"restoreFailed": "恢复失败,请重试。"
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instruct
| `DRAWIO_LANG` | unset | Language of the draw.io editor. Unset, draw.io chooses: the browser language on `embed.diagrams.net`, English on a self-hosted draw.io until the user picks one under **Extras > Language**. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes it and hides that submenu. |
| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. |
| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light. The page header keeps following the system. |
| `DRAWIO_PREVIEW_UI` | `classic` | The preview page `start_session` opens. `shell` opens the new canvas page built from the web app's canvas (in progress: it does not sync with the server yet). |
| `DRAWIO_PREVIEW_UI` | `classic` | The preview page `start_session` opens. `shell` opens the new canvas page built from the web app's canvas: it syncs with the server like the classic page, and an AI change of the page on screen is marked and undone with one Ctrl+Z (in progress: no History panel or download yet). |
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result, so the AI checks each drawing. Costs 2 to 10 s per call; the preview tab must be open and in front. A call can still pass `screenshot: false`. |
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
+6 -1
View File
@@ -7,11 +7,13 @@ import {
VersionsProvider,
type VersionsSource,
} from "@/components/canvas/versions-context"
import { AppToaster } from "@/components/workspace/app-toaster"
import { useDictionary } from "@/hooks/use-dictionary"
import type { DrawioSource } from "@/lib/drawio/drawio-config"
import { useSettingsStore } from "@/stores/settings-store"
import type { ShellConfig } from "./runtime-config"
import { setDark } from "./theme"
import { useMcpSync } from "./use-mcp-sync"
// No versions until the shell reads the server's History (a later step)
const NO_VERSIONS: VersionsSource = {
@@ -34,6 +36,7 @@ const NO_VERSIONS: VersionsSource = {
export function CanvasShell({ config }: { config: ShellConfig }) {
const dict = useDictionary()
const isDark = useSettingsStore((s) => s.isDark)
const status = useMcpSync(config)
const drawioSource = useMemo<DrawioSource>(
() => ({
editorUrl: config.drawioBaseUrl,
@@ -62,8 +65,9 @@ export function CanvasShell({ config }: { config: ShellConfig }) {
<span
className="truncate text-xs text-muted-foreground"
data-testid="shell-status"
data-status={status}
>
{dict.shell.waiting}
{dict.shell[status]}
</span>
<button
type="button"
@@ -84,6 +88,7 @@ export function CanvasShell({ config }: { config: ShellConfig }) {
/>
</div>
<CompareDialog />
<AppToaster />
</div>
</VersionsProvider>
)
+744
View File
@@ -0,0 +1,744 @@
/**
* 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 { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
import {
normalizeToMxfile,
parseMxfile,
serializeMxfile,
} from "@/packages/mcp-server/src/pages.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
}
/** An entry of GET /api/history */
export interface HistoryEntryInfo {
id: number
index: number
svg: string
}
/**
* 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
}
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 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 */
fetchHistory(): Promise<{
entries: HistoryEntryInfo[]
stateId: string | null
} | null>
/** Put a History entry back, naming the state its list belongs to */
restoreEntry(
id: number,
listStateId: string | null,
): Promise<"ok" | "stale" | "failed">
/** 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)))}`
}
// Page settings draw.io applies on a full load only (the editor bridge's
// hasLoadOnlySettings): replacing the page in place would keep the old ones
function hasLoadOnlySettings(page: Element): boolean {
const model = page.querySelector("mxGraphModel")
return (
!!model &&
(model.hasAttribute("backgroundImage") ||
model.hasAttribute("extFonts") ||
model.getAttribute("math") === "1" ||
model.getAttribute("shadow") === "1")
)
}
/**
* 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, else the first), 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
}
const index = Math.max(
0,
pagesNow.findIndex((page) => page.getAttribute("id") === currentPageId),
)
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
}
if (hasLoadOnlySettings(target)) 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
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
// Load the server state on the next poll even at the same version
let forceReload = false
let pendingSyncExport = false
let syncExportSeq = 0
let status: SyncStatus = "waiting"
let interval: ReturnType<typeof setInterval> | null = null
const setStatus = (next: SyncStatus) => {
if (status === next) return
status = next
onStatus?.(next)
}
// 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 decision: LoadDecision =
how === "decide"
? decideLoad(canvas.currentXml(), xml, canvas.currentPageId())
: { mode: "load" }
canvas.load(xml, decision)
// currentVersion is the write's version
thumbExport = { stateId, version: currentVersion, xml }
setTimeout(captureThumbnail, 500)
return decision
}
// 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,
}).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
// 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",
)
}
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
projectionExportActive = false
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
const decision = loadFromServer(s.xml, "decide")
// A commit replaces the page in place: draw.io reports no load
if (decision.mode === "load") 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()
}
}
async function poll(): Promise<void> {
if (!sessionId) return
const seq = ++pollSeq
try {
const r = await api(
`/state?sessionId=${encodeURIComponent(sessionId)}`,
)
if (!r.ok) 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
projectionExportActive = false
currentVersion = s.version
loadFromServer(s.xml, how)
justLoaded = true
}
// 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()
}
if (s.exportFormat && !pendingMcpExport && isReady) {
startMcpExport(s, justLoaded)
}
// Extension point (plan step 6, the get_selection tool): a
// selection request in the state would be answered here, like
// the sync request above, with the ids the editor bridge reads
} 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
// 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)
latestXml = xml
if (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
},
async fetchHistory() {
// A list for a state the server recreated meanwhile has old ids
const sid = stateId
try {
const r = await api(
`/history?sessionId=${encodeURIComponent(sessionId)}`,
)
if (!r.ok) return null
const d = await r.json()
if (sid !== stateId) return null
return { entries: d.entries ?? [], stateId: sid }
} catch {
return null
}
},
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"
},
read() {
return {
stateId,
currentVersion,
lastXml,
latestXml,
projectionActive: projectionExportActive,
status,
}
},
}
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams } from "@/lib/diagram-diff"
import { highlightCells } from "@/lib/drawio/editor-bridge"
import { useCanvasStore } from "@/stores/canvas-store"
import { createMcpSync, type McpSync, type SyncStatus } from "./mcp-sync-core"
import type { ShellConfig } from "./runtime-config"
/**
* Keeps the canvas in step with the MCP server (mcp-sync-core.ts has the
* protocol): mounted once inside DiagramProvider. Server writes go on the
* canvas through loadDiagram, as one undo step when they can; the user's
* edits are pushed; the server's export and sync requests are answered.
* Returns the connection state for the status bar.
*/
export function useMcpSync(config: ShellConfig): SyncStatus {
const dict = useDictionary()
const diagram = useDiagram()
const [status, setStatus] = useState<SyncStatus>("waiting")
const syncRef = useRef<McpSync | null>(null)
// The provider's functions are new on every render; the sync (created
// once) reads the latest through refs
const diagramRef = useRef(diagram)
diagramRef.current = diagram
const dictRef = useRef(dict)
dictRef.current = dict
useEffect(() => {
if (!config.sessionId) return
const sync = createMcpSync({
sessionId: config.sessionId,
apiBase: config.apiBase,
token: config.token,
canvas: {
load: (xml, decision) =>
diagramRef.current.loadDiagram(
xml,
true,
decision.mode,
decision.mode === "commit"
? { pageId: decision.pageId }
: undefined,
),
showTransient: (xml) => diagramRef.current.showTransient(xml),
export: (request, timeoutMs) =>
diagramRef.current.requestExport(request, timeoutMs),
currentXml: () => diagramRef.current.chartXMLRef.current,
currentPageId: () => useCanvasStore.getState().currentPageId,
},
onNotice: (notice) =>
toast(dictRef.current.shell[notice], { duration: 8000 }),
onStatus: setStatus,
})
syncRef.current = sync
sync.setReady(diagramRef.current.isDrawioReady)
sync.start()
// draw.io's autosave and load events, as the classic page reads
// them: the frame component handles them for the canvas, the sync
// needs them for the protocol (an edit to push, a load done)
const drawioOrigin = new URL(config.drawioBaseUrl, location.href).origin
const onMessage = (event: MessageEvent) => {
if (event.source === window || event.origin !== drawioOrigin) {
return
}
if (typeof event.data !== "string") return
let message: { event?: string; xml?: unknown }
try {
message = JSON.parse(event.data)
} catch {
return
}
if (
message.event === "autosave" &&
typeof message.xml === "string"
) {
sync.onAutoSave(message.xml)
} else if (message.event === "load") {
sync.onDrawioLoad()
}
}
window.addEventListener("message", onMessage)
return () => {
window.removeEventListener("message", onMessage)
sync.stop()
syncRef.current = null
}
}, [config])
useEffect(() => {
syncRef.current?.setReady(diagram.isDrawioReady)
}, [diagram.isDrawioReady])
// Mark what a server write changed on the page, as the web app does
// for its versions (use-versions.ts); any action on the canvas clears it
useEffect(() => {
diagram.setCommitHandler(({ beforeXml, afterXml, pageId }) => {
const { touchedIds, fromScratch } = diffDiagrams(
beforeXml,
afterXml,
pageId,
)
if (fromScratch) return
// Once draw.io has drawn the replaced cells
setTimeout(() => {
const marker = getComputedStyle(document.documentElement)
.getPropertyValue("--marker")
.trim()
highlightCells(touchedIds, marker || "#ffd84d")
}, 60)
})
return () => diagram.setCommitHandler(null)
}, [])
return status
}
+8
View File
@@ -84,7 +84,15 @@ describe("shell language", () => {
for (const locale of i18n.locales) {
const dict = getShellDictionary(locale)
expect(Object.keys(dict.shell).sort(), locale).toEqual([
"aiChanged",
"connected",
"historyChanged",
"offline",
"restoreFailed",
"restoredFromFile",
"toggleTheme",
"tooLarge",
"unreachable",
"waiting",
])
expect(dict.shell.waiting, locale).toBeTruthy()