mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Merge pull request #341 from DayuanJiang/feat/mcp-history
feat(mcp): add diagram version history with SVG previews
This commit is contained in:
@@ -86,6 +86,7 @@ Use the standard MCP configuration with:
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
||||||
|
- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states
|
||||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||||
- **Export**: Save diagrams as `.drawio` files
|
- **Export**: Save diagrams as `.drawio` files
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
62
packages/mcp-server/src/history.ts
Normal file
62
packages/mcp-server/src/history.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Simple diagram history - matches Next.js app pattern
|
||||||
|
* Stores {xml, svg} entries in a circular buffer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { log } from "./logger.js"
|
||||||
|
|
||||||
|
const MAX_HISTORY = 20
|
||||||
|
const historyStore = new Map<string, Array<{ xml: string; svg: string }>>()
|
||||||
|
|
||||||
|
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||||
|
let history = historyStore.get(sessionId)
|
||||||
|
if (!history) {
|
||||||
|
history = []
|
||||||
|
historyStore.set(sessionId, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe: skip if same as last entry
|
||||||
|
const last = history[history.length - 1]
|
||||||
|
if (last?.xml === xml) {
|
||||||
|
return history.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({ xml, svg })
|
||||||
|
|
||||||
|
// Circular buffer
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`History: session=${sessionId}, entries=${history.length}`)
|
||||||
|
return history.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistory(
|
||||||
|
sessionId: string,
|
||||||
|
): Array<{ xml: string; svg: string }> {
|
||||||
|
return historyStore.get(sessionId) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryEntry(
|
||||||
|
sessionId: string,
|
||||||
|
index: number,
|
||||||
|
): { xml: string; svg: string } | undefined {
|
||||||
|
const history = historyStore.get(sessionId)
|
||||||
|
return history?.[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(sessionId: string): void {
|
||||||
|
historyStore.delete(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLastHistorySvg(sessionId: string, svg: string): boolean {
|
||||||
|
const history = historyStore.get(sessionId)
|
||||||
|
if (!history || history.length === 0) return false
|
||||||
|
const last = history[history.length - 1]
|
||||||
|
if (!last.svg) {
|
||||||
|
last.svg = svg
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,55 +1,50 @@
|
|||||||
/**
|
/**
|
||||||
* Embedded HTTP Server for MCP
|
* Embedded HTTP Server for MCP
|
||||||
*
|
* Serves draw.io embed with state sync and history UI
|
||||||
* Serves a static HTML page with draw.io embed and handles state sync.
|
|
||||||
* This eliminates the need for an external Next.js app.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
|
import {
|
||||||
|
addHistory,
|
||||||
|
clearHistory,
|
||||||
|
getHistory,
|
||||||
|
getHistoryEntry,
|
||||||
|
updateLastHistorySvg,
|
||||||
|
} from "./history.js"
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
xml: string
|
xml: string
|
||||||
version: number
|
version: number
|
||||||
lastUpdated: Date
|
lastUpdated: Date
|
||||||
|
svg?: string // Cached SVG from last browser save
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory state store (shared with MCP server in same process)
|
|
||||||
export const stateStore = new Map<string, SessionState>()
|
export const stateStore = new Map<string, SessionState>()
|
||||||
|
|
||||||
let server: http.Server | null = null
|
let server: http.Server | null = null
|
||||||
let serverPort: number = 6002
|
let serverPort = 6002
|
||||||
const MAX_PORT = 6020 // Don't retry beyond this port
|
const MAX_PORT = 6020
|
||||||
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
|
const SESSION_TTL = 60 * 60 * 1000
|
||||||
|
|
||||||
/**
|
|
||||||
* Get state for a session
|
|
||||||
*/
|
|
||||||
export function getState(sessionId: string): SessionState | undefined {
|
export function getState(sessionId: string): SessionState | undefined {
|
||||||
return stateStore.get(sessionId)
|
return stateStore.get(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||||
* Set state for a session
|
|
||||||
*/
|
|
||||||
export function setState(sessionId: string, xml: string): number {
|
|
||||||
const existing = stateStore.get(sessionId)
|
const existing = stateStore.get(sessionId)
|
||||||
const newVersion = (existing?.version || 0) + 1
|
const newVersion = (existing?.version || 0) + 1
|
||||||
|
|
||||||
stateStore.set(sessionId, {
|
stateStore.set(sessionId, {
|
||||||
xml,
|
xml,
|
||||||
version: newVersion,
|
version: newVersion,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||||
})
|
})
|
||||||
|
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
return newVersion
|
return newVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function startHttpServer(port = 6002): Promise<number> {
|
||||||
* Start the embedded HTTP server
|
|
||||||
*/
|
|
||||||
export function startHttpServer(port: number = 6002): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (server) {
|
if (server) {
|
||||||
resolve(serverPort)
|
resolve(serverPort)
|
||||||
@@ -81,15 +76,12 @@ export function startHttpServer(port: number = 6002): Promise<number> {
|
|||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
serverPort = port
|
serverPort = port
|
||||||
log.info(`Embedded HTTP server running on http://localhost:${port}`)
|
log.info(`HTTP server running on http://localhost:${port}`)
|
||||||
resolve(port)
|
resolve(port)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the HTTP server
|
|
||||||
*/
|
|
||||||
export function stopHttpServer(): void {
|
export function stopHttpServer(): void {
|
||||||
if (server) {
|
if (server) {
|
||||||
server.close()
|
server.close()
|
||||||
@@ -97,39 +89,29 @@ export function stopHttpServer(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up expired sessions
|
|
||||||
*/
|
|
||||||
function cleanupExpiredSessions(): void {
|
function cleanupExpiredSessions(): void {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [sessionId, state] of stateStore) {
|
for (const [sessionId, state] of stateStore) {
|
||||||
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
||||||
stateStore.delete(sessionId)
|
stateStore.delete(sessionId)
|
||||||
|
clearHistory(sessionId)
|
||||||
log.info(`Cleaned up expired session: ${sessionId}`)
|
log.info(`Cleaned up expired session: ${sessionId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup every 5 minutes
|
|
||||||
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current server port
|
|
||||||
*/
|
|
||||||
export function getServerPort(): number {
|
export function getServerPort(): number {
|
||||||
return serverPort
|
return serverPort
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle HTTP requests
|
|
||||||
*/
|
|
||||||
function handleRequest(
|
function handleRequest(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
): void {
|
): void {
|
||||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||||
|
|
||||||
// CORS headers for local development
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
@@ -140,43 +122,23 @@ function handleRequest(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route handling
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
serveHtml(req, res, url)
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
} else if (
|
res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
|
||||||
url.pathname === "/api/state" ||
|
} else if (url.pathname === "/api/state") {
|
||||||
url.pathname === "/api/mcp/state"
|
|
||||||
) {
|
|
||||||
handleStateApi(req, res, url)
|
handleStateApi(req, res, url)
|
||||||
} else if (
|
} else if (url.pathname === "/api/history") {
|
||||||
url.pathname === "/api/health" ||
|
handleHistoryApi(req, res, url)
|
||||||
url.pathname === "/api/mcp/health"
|
} else if (url.pathname === "/api/restore") {
|
||||||
) {
|
handleRestoreApi(req, res)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
} else if (url.pathname === "/api/history-svg") {
|
||||||
res.end(JSON.stringify({ status: "ok", mcp: true }))
|
handleHistorySvgApi(req, res)
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404)
|
res.writeHead(404)
|
||||||
res.end("Not Found")
|
res.end("Not Found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Serve the HTML page with draw.io embed
|
|
||||||
*/
|
|
||||||
function serveHtml(
|
|
||||||
req: http.IncomingMessage,
|
|
||||||
res: http.ServerResponse,
|
|
||||||
url: URL,
|
|
||||||
): void {
|
|
||||||
const sessionId = url.searchParams.get("mcp") || ""
|
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
|
||||||
res.end(getHtmlPage(sessionId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle state API requests
|
|
||||||
*/
|
|
||||||
function handleStateApi(
|
function handleStateApi(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
@@ -189,14 +151,12 @@ function handleStateApi(
|
|||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
xml: state?.xml || null,
|
xml: state?.xml || null,
|
||||||
version: state?.version || 0,
|
version: state?.version || 0,
|
||||||
lastUpdated: state?.lastUpdated?.toISOString() || null,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -206,14 +166,13 @@ function handleStateApi(
|
|||||||
})
|
})
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, xml } = JSON.parse(body)
|
const { sessionId, xml, svg } = JSON.parse(body)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const version = setState(sessionId, xml, svg)
|
||||||
const version = setState(sessionId, xml)
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, version }))
|
res.end(JSON.stringify({ success: true, version }))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -227,35 +186,179 @@ function handleStateApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function handleHistoryApi(
|
||||||
* Generate the HTML page with draw.io embed
|
req: http.IncomingMessage,
|
||||||
*/
|
res: http.ServerResponse,
|
||||||
|
url: URL,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = url.searchParams.get("sessionId")
|
||||||
|
if (!sessionId) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = getHistory(sessionId)
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
|
||||||
|
count: history.length,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRestoreApi(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const { sessionId, index } = JSON.parse(body)
|
||||||
|
if (!sessionId || index === undefined) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({ error: "sessionId and index required" }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = getHistoryEntry(sessionId, index)
|
||||||
|
if (!entry) {
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVersion = setState(sessionId, entry.xml)
|
||||||
|
addHistory(sessionId, entry.xml, entry.svg)
|
||||||
|
|
||||||
|
log.info(`Restored session ${sessionId} to index ${index}`)
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ success: true, newVersion }))
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistorySvgApi(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
): void {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end("Method Not Allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const { sessionId, svg } = JSON.parse(body)
|
||||||
|
if (!sessionId || !svg) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "sessionId and svg required" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastHistorySvg(sessionId, svg)
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ success: true }))
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getHtmlPage(sessionId: string): string {
|
function getHtmlPage(sessionId: string): string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
|
<title>Draw.io MCP</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||||
#header {
|
#header {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px; background: #1a1a2e; color: #eee;
|
||||||
background: #1a1a2e;
|
font-family: system-ui, sans-serif; font-size: 14px;
|
||||||
color: #eee;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
#header .session { color: #888; font-size: 12px; }
|
#header .session { color: #888; font-size: 12px; }
|
||||||
#header .status { font-size: 12px; }
|
#header .status { font-size: 12px; }
|
||||||
#header .status.connected { color: #4ade80; }
|
#header .status.connected { color: #4ade80; }
|
||||||
#header .status.disconnected { color: #f87171; }
|
#header .status.disconnected { color: #f87171; }
|
||||||
#drawio { flex: 1; border: none; }
|
#drawio { flex: 1; border: none; }
|
||||||
|
#history-btn {
|
||||||
|
position: fixed; bottom: 24px; right: 24px;
|
||||||
|
width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
background: #3b82f6; color: white; border: none; cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
#history-btn:hover { background: #2563eb; }
|
||||||
|
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
|
||||||
|
#history-btn svg { width: 24px; height: 24px; }
|
||||||
|
#history-modal {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5); z-index: 2000;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#history-modal.open { display: flex; }
|
||||||
|
.modal-content {
|
||||||
|
background: white; border-radius: 12px;
|
||||||
|
width: 90%; max-width: 500px; max-height: 70vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
.modal-header h2 { font-size: 18px; margin: 0; }
|
||||||
|
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
|
.history-item {
|
||||||
|
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
|
||||||
|
cursor: pointer; text-align: center;
|
||||||
|
}
|
||||||
|
.history-item:hover { border-color: #3b82f6; }
|
||||||
|
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
|
||||||
|
.history-item .thumb {
|
||||||
|
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-bottom: 4px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
|
.history-item .label { font-size: 12px; color: #666; }
|
||||||
|
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||||
|
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||||
|
.empty { text-align: center; padding: 40px; color: #666; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -263,121 +366,176 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
<div id="header">
|
<div id="header">
|
||||||
<div>
|
<div>
|
||||||
<strong>Draw.io MCP</strong>
|
<strong>Draw.io MCP</strong>
|
||||||
<span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
|
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status disconnected">Connecting...</div>
|
<div id="status" class="status disconnected">Connecting...</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="history-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header"><h2>History</h2></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="history-grid" class="history-grid"></div>
|
||||||
|
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const sessionId = "${sessionId}";
|
const sessionId = "${sessionId}";
|
||||||
const iframe = document.getElementById('drawio');
|
const iframe = document.getElementById('drawio');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
|
let pendingSvgExport = null;
|
||||||
|
let pendingAiSvg = false;
|
||||||
|
|
||||||
let currentVersion = 0;
|
window.addEventListener('message', (e) => {
|
||||||
let isDrawioReady = false;
|
if (e.origin !== 'https://embed.diagrams.net') return;
|
||||||
let pendingXml = null;
|
|
||||||
let lastLoadedXml = null;
|
|
||||||
|
|
||||||
// Listen for messages from draw.io
|
|
||||||
window.addEventListener('message', (event) => {
|
|
||||||
if (event.origin !== 'https://embed.diagrams.net') return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(e.data);
|
||||||
handleDrawioMessage(msg);
|
if (msg.event === 'init') {
|
||||||
} catch (e) {
|
isReady = true;
|
||||||
// Ignore non-JSON messages
|
statusEl.textContent = 'Ready';
|
||||||
}
|
statusEl.className = 'status connected';
|
||||||
|
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||||
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||||
|
// Request SVG export, then push state with SVG
|
||||||
|
pendingSvgExport = msg.xml;
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
|
// Fallback if export doesn't respond
|
||||||
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||||
|
} else if (msg.event === 'export' && msg.data) {
|
||||||
|
let svg = msg.data;
|
||||||
|
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||||
|
if (pendingSvgExport) {
|
||||||
|
const xml = pendingSvgExport;
|
||||||
|
pendingSvgExport = null;
|
||||||
|
pushState(xml, svg);
|
||||||
|
} else if (pendingAiSvg) {
|
||||||
|
pendingAiSvg = false;
|
||||||
|
fetch('/api/history-svg', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, svg })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleDrawioMessage(msg) {
|
function loadDiagram(xml, capturePreview = false) {
|
||||||
if (msg.event === 'init') {
|
if (!isReady) { pendingXml = xml; return; }
|
||||||
isDrawioReady = true;
|
lastXml = xml;
|
||||||
statusEl.textContent = 'Ready';
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
||||||
statusEl.className = 'status connected';
|
if (capturePreview) {
|
||||||
|
setTimeout(() => {
|
||||||
// Load pending XML if any
|
pendingAiSvg = true;
|
||||||
if (pendingXml) {
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
loadDiagram(pendingXml);
|
}, 500);
|
||||||
pendingXml = null;
|
|
||||||
}
|
|
||||||
} else if (msg.event === 'save') {
|
|
||||||
// User saved - push to state
|
|
||||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
|
||||||
pushState(msg.xml);
|
|
||||||
}
|
|
||||||
} else if (msg.event === 'export') {
|
|
||||||
// Export completed
|
|
||||||
if (msg.data) {
|
|
||||||
pushState(msg.data);
|
|
||||||
}
|
|
||||||
} else if (msg.event === 'autosave') {
|
|
||||||
// Autosave - push to state
|
|
||||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
|
||||||
pushState(msg.xml);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadDiagram(xml) {
|
async function pushState(xml, svg = '') {
|
||||||
if (!isDrawioReady) {
|
|
||||||
pendingXml = xml;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLoadedXml = xml;
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({
|
|
||||||
action: 'load',
|
|
||||||
xml: xml,
|
|
||||||
autosave: 1
|
|
||||||
}), '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushState(xml) {
|
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/state', {
|
const r = await fetch('/api/state', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId, xml })
|
body: JSON.stringify({ sessionId, xml, svg })
|
||||||
});
|
});
|
||||||
|
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||||
if (response.ok) {
|
} catch (e) { console.error('Push failed:', e); }
|
||||||
const result = await response.json();
|
|
||||||
currentVersion = result.version;
|
|
||||||
lastLoadedXml = xml;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to push state:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollState() {
|
async function poll() {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||||
if (!response.ok) return;
|
if (!r.ok) return;
|
||||||
|
const s = await r.json();
|
||||||
const state = await response.json();
|
if (s.version > currentVersion && s.xml) {
|
||||||
|
currentVersion = s.version;
|
||||||
if (state.version && state.version > currentVersion && state.xml) {
|
loadDiagram(s.xml, true);
|
||||||
currentVersion = state.version;
|
|
||||||
loadDiagram(state.xml);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {}
|
||||||
console.error('Failed to poll state:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start polling if we have a session
|
if (sessionId) { poll(); setInterval(poll, 2000); }
|
||||||
if (sessionId) {
|
|
||||||
pollState();
|
// History UI
|
||||||
setInterval(pollState, 2000);
|
const historyBtn = document.getElementById('history-btn');
|
||||||
|
const historyModal = document.getElementById('history-modal');
|
||||||
|
const historyGrid = document.getElementById('history-grid');
|
||||||
|
const historyEmpty = document.getElementById('history-empty');
|
||||||
|
const restoreBtn = document.getElementById('restore-btn');
|
||||||
|
const cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
let historyData = [], selectedIdx = null;
|
||||||
|
|
||||||
|
historyBtn.onclick = async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
historyData = d.entries || [];
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
historyModal.classList.add('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
|
||||||
|
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
if (historyData.length === 0) {
|
||||||
|
historyGrid.style.display = 'none';
|
||||||
|
historyEmpty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyGrid.style.display = 'grid';
|
||||||
|
historyEmpty.style.display = 'none';
|
||||||
|
historyGrid.innerHTML = historyData.map((e, i) => \`
|
||||||
|
<div class="history-item" data-idx="\${e.index}">
|
||||||
|
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
|
||||||
|
<div class="label">#\${e.index}</div>
|
||||||
|
</div>
|
||||||
|
\`).join('');
|
||||||
|
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
||||||
|
item.onclick = () => {
|
||||||
|
const idx = parseInt(item.dataset.idx);
|
||||||
|
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
|
||||||
|
else { selectedIdx = idx; restoreBtn.disabled = false; }
|
||||||
|
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreBtn.onclick = async () => {
|
||||||
|
if (selectedIdx === null) return;
|
||||||
|
restoreBtn.disabled = true;
|
||||||
|
restoreBtn.textContent = 'Restoring...';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, index: selectedIdx })
|
||||||
|
});
|
||||||
|
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||||
|
else { alert('Restore failed'); }
|
||||||
|
} catch { alert('Restore failed'); }
|
||||||
|
restoreBtn.textContent = 'Restore';
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -34,12 +34,8 @@ import {
|
|||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
type DiagramOperation,
|
type DiagramOperation,
|
||||||
} from "./diagram-operations.js"
|
} from "./diagram-operations.js"
|
||||||
import {
|
import { addHistory } from "./history.js"
|
||||||
getServerPort,
|
import { getState, setState, startHttpServer } from "./http-server.js"
|
||||||
getState,
|
|
||||||
setState,
|
|
||||||
startHttpServer,
|
|
||||||
} from "./http-server.js"
|
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
import { validateAndFixXml } from "./xml-validation.js"
|
import { validateAndFixXml } from "./xml-validation.js"
|
||||||
|
|
||||||
@@ -197,6 +193,21 @@ server.registerTool(
|
|||||||
|
|
||||||
log.info(`Displaying diagram, ${xml.length} chars`)
|
log.info(`Displaying diagram, ${xml.length} chars`)
|
||||||
|
|
||||||
|
// Sync from browser state first
|
||||||
|
const browserState = getState(currentSession.id)
|
||||||
|
if (browserState?.xml) {
|
||||||
|
currentSession.xml = browserState.xml
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user's state before AI overwrites (with cached SVG)
|
||||||
|
if (currentSession.xml) {
|
||||||
|
addHistory(
|
||||||
|
currentSession.id,
|
||||||
|
currentSession.xml,
|
||||||
|
browserState?.svg || "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Update session state
|
// Update session state
|
||||||
currentSession.xml = xml
|
currentSession.xml = xml
|
||||||
currentSession.version++
|
currentSession.version++
|
||||||
@@ -204,6 +215,9 @@ server.registerTool(
|
|||||||
// Push to embedded server state
|
// Push to embedded server state
|
||||||
setState(currentSession.id, xml)
|
setState(currentSession.id, xml)
|
||||||
|
|
||||||
|
// Save AI result (no SVG yet - will be captured by browser)
|
||||||
|
addHistory(currentSession.id, xml, "")
|
||||||
|
|
||||||
log.info(`Diagram displayed successfully`)
|
log.info(`Diagram displayed successfully`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -295,6 +309,13 @@ server.registerTool(
|
|||||||
|
|
||||||
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
||||||
|
|
||||||
|
// Save before editing (with cached SVG from browser)
|
||||||
|
addHistory(
|
||||||
|
currentSession.id,
|
||||||
|
currentSession.xml,
|
||||||
|
browserState?.svg || "",
|
||||||
|
)
|
||||||
|
|
||||||
// Validate and auto-fix new_xml for each operation
|
// Validate and auto-fix new_xml for each operation
|
||||||
const validatedOps = operations.map((op) => {
|
const validatedOps = operations.map((op) => {
|
||||||
if (op.new_xml) {
|
if (op.new_xml) {
|
||||||
@@ -336,6 +357,9 @@ server.registerTool(
|
|||||||
// Push to embedded server
|
// Push to embedded server
|
||||||
setState(currentSession.id, result)
|
setState(currentSession.id, result)
|
||||||
|
|
||||||
|
// Save AI result (no SVG yet - will be captured by browser)
|
||||||
|
addHistory(currentSession.id, result, "")
|
||||||
|
|
||||||
log.info(`Diagram edited successfully`)
|
log.info(`Diagram edited successfully`)
|
||||||
|
|
||||||
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
||||||
|
|||||||
Reference in New Issue
Block a user