mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 03:59:58 +08:00
feat(mcp-server): auto-save each session's diagram to a .drawio file
- Save the latest diagram of every session 1 second after each change (AI write, browser edit, history restore) to ~/.next-ai-drawio/<id>.drawio, keep the newest 50, flush on shutdown; DRAWIO_DATA_DIR changes the folder and "off" disables it, like the web app's IndexedDB sessions - start_session names the file, so a resumed conversation can reopen the diagram with load_diagram after the MCP process restarted - Fix PNG/SVG exports randomly timing out: a previous export's 10 second timer cleared the export in progress, and a late reply could be taken for the current one; exports are now numbered
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -87,7 +87,8 @@ function ensureSessionStateInitialized(sessionId: string): void {
|
||||
if (!isValidSessionId(sessionId)) return
|
||||
if (stateStore.has(sessionId)) return
|
||||
|
||||
setState(sessionId, DEFAULT_DIAGRAM_XML)
|
||||
// Not a change worth saving: the browser fills it on its next push
|
||||
setState(sessionId, DEFAULT_DIAGRAM_XML, undefined, false, false)
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
@@ -126,11 +127,21 @@ export function getState(sessionId: string): SessionState | undefined {
|
||||
return stateStore.get(sessionId)
|
||||
}
|
||||
|
||||
// Called after every state change (AI write, browser push, restore)
|
||||
let stateListener: ((sessionId: string, xml: string) => void) | null = null
|
||||
|
||||
export function onStateChange(
|
||||
listener: (sessionId: string, xml: string) => void,
|
||||
): void {
|
||||
stateListener = listener
|
||||
}
|
||||
|
||||
export function setState(
|
||||
sessionId: string,
|
||||
xml: string,
|
||||
svg?: string,
|
||||
fromBrowser = false,
|
||||
notify = true,
|
||||
): number {
|
||||
const existing = stateStore.get(sessionId)
|
||||
const newVersion = (existing?.version || 0) + 1
|
||||
@@ -148,6 +159,7 @@ export function setState(
|
||||
exportData: existing?.exportData, // Preserve export result
|
||||
})
|
||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||
if (notify) stateListener?.(sessionId, xml)
|
||||
return newVersion
|
||||
}
|
||||
|
||||
@@ -833,6 +845,7 @@ function getHtmlPage(sessionId: string): string {
|
||||
let pendingSvgBase = 0; // version the pending autosave was based on
|
||||
let pendingAiSvg = false;
|
||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||
let mcpExportSeq = 0; // number of the latest MCP export
|
||||
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
||||
let forceReload = false; // reload the server state on the next poll even if the version is unchanged
|
||||
let noticeTimer = null;
|
||||
@@ -873,6 +886,8 @@ function getHtmlPage(sessionId: string): string {
|
||||
// back in msg.message, which tells it apart from autosave and
|
||||
// preview SVG exports.
|
||||
if (msg.message && msg.message.mcpExport) {
|
||||
// A late reply to an export that already timed out
|
||||
if (msg.message.mcpExport !== mcpExportSeq) return;
|
||||
const d = msg.data;
|
||||
const isPng = pendingMcpExport === 'png' && d.startsWith('data:image/png');
|
||||
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
||||
@@ -1025,14 +1040,16 @@ function getHtmlPage(sessionId: string): string {
|
||||
// projection is showing (see projectionExportActive guard).
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const seq = ++mcpExportSeq;
|
||||
const extra = s.exportOptions || {};
|
||||
const fireExport = () => {
|
||||
// mcpExport is echoed back in msg.message (see the
|
||||
// handler). PNG: width caps the size, pageId picks a
|
||||
// page; without one draw.io would use the first page.
|
||||
// mcpExport carries this export's number and is echoed
|
||||
// back in msg.message (see the handler). PNG: width
|
||||
// caps the size, pageId picks a page; without one
|
||||
// draw.io would use the first page.
|
||||
const exportOpts = pendingMcpExport === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: true }
|
||||
: { action: 'export', format: 'svg', mcpExport: true };
|
||||
? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: seq }
|
||||
: { action: 'export', format: 'svg', mcpExport: seq };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
@@ -1046,9 +1063,10 @@ function getHtmlPage(sessionId: string): string {
|
||||
fireExport();
|
||||
}
|
||||
// Timeout: reset if draw.io never responds, and restore the
|
||||
// real document if a projection was left showing.
|
||||
// real document if a projection was left showing. Only for
|
||||
// this export: a later one may be running by then.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport) {
|
||||
if (pendingMcpExport && seq === mcpExportSeq) {
|
||||
pendingMcpExport = null;
|
||||
restoreFromProjection();
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type ExportOptions,
|
||||
getServerPort,
|
||||
getState,
|
||||
onStateChange,
|
||||
requestExport,
|
||||
requestSync,
|
||||
setState,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
serializeMxfile,
|
||||
wrapCellsInModel,
|
||||
} from "./pages.js"
|
||||
import { Autosaver, defaultDataDir } from "./persistence.js"
|
||||
import { getShapeLibrary, SHAPE_LIBRARY_GROUPS } from "./shape-library.js"
|
||||
import { validateAndFixXml } from "./xml-validation.js"
|
||||
|
||||
@@ -68,6 +70,10 @@ const config = {
|
||||
port: parseInt(process.env.PORT || "6002", 10),
|
||||
}
|
||||
|
||||
// Keep each session's latest diagram on disk, so it survives this process
|
||||
const autosaver = new Autosaver(defaultDataDir())
|
||||
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
|
||||
|
||||
// Session state (single session for simplicity)
|
||||
let currentSession: {
|
||||
id: string
|
||||
@@ -267,13 +273,18 @@ server.registerTool(
|
||||
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
||||
await open(browserUrl)
|
||||
|
||||
const savePath = autosaver.pathFor(sessionId)
|
||||
const saveNote = savePath
|
||||
? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue it in a later conversation, call start_session, then load_diagram with this path.`
|
||||
: ""
|
||||
|
||||
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.\n\n${DRAWING_GUIDE}`,
|
||||
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}\n\n${DRAWING_GUIDE}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1731,6 +1742,7 @@ function gracefulShutdown(reason: string) {
|
||||
if (isShuttingDown) return
|
||||
isShuttingDown = true
|
||||
log.info(`Shutting down: ${reason}`)
|
||||
autosaver.flush()
|
||||
shutdown()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Auto-save of each session's latest diagram as a plain .drawio file, so a
|
||||
* diagram survives the MCP process (hosts start a new one when a
|
||||
* conversation is resumed). Like the web app's IndexedDB sessions
|
||||
* (lib/session-storage.ts): saved 1 second after the last change, at most
|
||||
* 50 kept. History is not saved.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { log } from "./logger.js"
|
||||
|
||||
const DELAY_MS = 1000
|
||||
const MAX_FILES = 50
|
||||
|
||||
/** DRAWIO_DATA_DIR, default ~/.next-ai-drawio; "off" disables saving. */
|
||||
export function defaultDataDir(): string | null {
|
||||
const dir = process.env.DRAWIO_DATA_DIR
|
||||
if (dir === "off") return null
|
||||
return dir || join(homedir(), ".next-ai-drawio")
|
||||
}
|
||||
|
||||
/** Any cell besides the root cells "0" and "1" */
|
||||
const hasCells = (xml: string) =>
|
||||
/<(mxCell\b[^>]*\bid="(?![01]")|UserObject\b|object\b)/.test(xml)
|
||||
|
||||
export class Autosaver {
|
||||
private pending = new Map<
|
||||
string,
|
||||
{ xml: string; timer: ReturnType<typeof setTimeout> }
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private dir: string | null,
|
||||
private delayMs = DELAY_MS,
|
||||
private maxFiles = MAX_FILES,
|
||||
) {}
|
||||
|
||||
/** Path of a session's file, or null when saving is off. */
|
||||
pathFor(sessionId: string): string | null {
|
||||
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
||||
}
|
||||
|
||||
schedule(sessionId: string, xml: string): void {
|
||||
if (!this.dir) return
|
||||
const previous = this.pending.get(sessionId)
|
||||
if (previous) clearTimeout(previous.timer)
|
||||
const timer = setTimeout(() => this.write(sessionId), this.delayMs)
|
||||
timer.unref?.()
|
||||
this.pending.set(sessionId, { xml, timer })
|
||||
}
|
||||
|
||||
/** Write every pending save now (on shutdown). */
|
||||
flush(): void {
|
||||
for (const [sessionId, { timer }] of this.pending) {
|
||||
clearTimeout(timer)
|
||||
this.write(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private write(sessionId: string): void {
|
||||
const entry = this.pending.get(sessionId)
|
||||
this.pending.delete(sessionId)
|
||||
const path = this.pathFor(sessionId)
|
||||
if (!entry || !this.dir || !path) return
|
||||
try {
|
||||
const isNew = !existsSync(path)
|
||||
// A blank page the browser shows before any drawing: nothing to keep
|
||||
if (isNew && !hasCells(entry.xml)) return
|
||||
mkdirSync(this.dir, { recursive: true })
|
||||
// Write to a temporary file first so a crash never leaves half a file
|
||||
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
|
||||
renameSync(`${path}.tmp`, path)
|
||||
if (isNew) this.removeOldest()
|
||||
} catch (error) {
|
||||
log.warn(`Auto-save failed for ${path}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private removeOldest(): void {
|
||||
if (!this.dir) return
|
||||
const dir = this.dir
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".drawio"))
|
||||
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
for (const { f } of files.slice(this.maxFiles)) {
|
||||
unlinkSync(join(dir, f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Tests for the per-session .drawio auto-save (src/persistence.ts).
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { Autosaver, defaultDataDir } from "../src/persistence.js"
|
||||
|
||||
const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const BLANK = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
const tempDir = () => mkdtempSync(join(tmpdir(), "mcp-autosave-"))
|
||||
|
||||
describe("Autosaver", () => {
|
||||
it("writes the latest XML once changes settle", async () => {
|
||||
const dir = tempDir()
|
||||
const saver = new Autosaver(dir, 30)
|
||||
saver.schedule("mcp-a", DIAGRAM.replace('id="a"', 'id="first"'))
|
||||
saver.schedule("mcp-a", DIAGRAM)
|
||||
const path = saver.pathFor("mcp-a") as string
|
||||
expect(existsSync(path)).toBe(false)
|
||||
await sleep(80)
|
||||
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
|
||||
expect(readdirSync(dir)).toEqual(["mcp-a.drawio"])
|
||||
})
|
||||
|
||||
it("skips a blank page that was never saved, but saves clearing a diagram", async () => {
|
||||
const dir = tempDir()
|
||||
const saver = new Autosaver(dir, 10)
|
||||
saver.schedule("mcp-blank", BLANK)
|
||||
await sleep(40)
|
||||
expect(existsSync(saver.pathFor("mcp-blank") as string)).toBe(false)
|
||||
|
||||
saver.schedule("mcp-b", DIAGRAM)
|
||||
saver.flush()
|
||||
saver.schedule("mcp-b", BLANK)
|
||||
saver.flush()
|
||||
expect(readFileSync(saver.pathFor("mcp-b") as string, "utf-8")).toBe(
|
||||
BLANK,
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps only the newest files", () => {
|
||||
const dir = tempDir()
|
||||
const saver = new Autosaver(dir, 10, 2)
|
||||
for (const [i, id] of ["mcp-old", "mcp-mid"].entries()) {
|
||||
writeFileSync(join(dir, `${id}.drawio`), DIAGRAM)
|
||||
utimesSync(join(dir, `${id}.drawio`), 1000 + i, 1000 + i)
|
||||
}
|
||||
saver.schedule("mcp-new", DIAGRAM)
|
||||
saver.flush()
|
||||
expect(readdirSync(dir).sort()).toEqual([
|
||||
"mcp-mid.drawio",
|
||||
"mcp-new.drawio",
|
||||
])
|
||||
})
|
||||
|
||||
it("does nothing when saving is off", () => {
|
||||
const saver = new Autosaver(null)
|
||||
expect(saver.pathFor("mcp-x")).toBeNull()
|
||||
saver.schedule("mcp-x", DIAGRAM)
|
||||
saver.flush()
|
||||
})
|
||||
})
|
||||
|
||||
describe("defaultDataDir", () => {
|
||||
const original = process.env.DRAWIO_DATA_DIR
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.DRAWIO_DATA_DIR
|
||||
else process.env.DRAWIO_DATA_DIR = original
|
||||
})
|
||||
|
||||
it("reads DRAWIO_DATA_DIR, where off disables saving", () => {
|
||||
process.env.DRAWIO_DATA_DIR = "off"
|
||||
expect(defaultDataDir()).toBeNull()
|
||||
process.env.DRAWIO_DATA_DIR = "/tmp/x"
|
||||
expect(defaultDataDir()).toBe("/tmp/x")
|
||||
delete process.env.DRAWIO_DATA_DIR
|
||||
expect(defaultDataDir()).toMatch(/\.next-ai-drawio$/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user