2025-12-17 14:50:07 +09:00
|
|
|
/**
|
|
|
|
|
* Embedded HTTP Server for MCP
|
2025-12-21 16:11:49 +09:00
|
|
|
* Serves draw.io embed with state sync and history UI
|
2025-12-17 14:50:07 +09:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import http from "node:http"
|
2025-12-21 16:09:14 +09:00
|
|
|
import {
|
2025-12-21 16:11:49 +09:00
|
|
|
addHistory,
|
2025-12-21 16:09:14 +09:00
|
|
|
clearHistory,
|
|
|
|
|
getHistory,
|
2025-12-21 16:11:49 +09:00
|
|
|
getHistoryEntry,
|
2025-12-21 16:58:41 +09:00
|
|
|
updateLastHistorySvg,
|
2025-12-21 16:09:14 +09:00
|
|
|
} from "./history.js"
|
2025-12-17 14:50:07 +09:00
|
|
|
import { log } from "./logger.js"
|
|
|
|
|
|
2026-01-01 13:47:39 +08:00
|
|
|
// Configurable draw.io embed URL for private deployments
|
|
|
|
|
const DRAWIO_BASE_URL =
|
|
|
|
|
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
|
|
|
|
|
|
|
|
|
// Extract origin (scheme + host + port) from URL for postMessage security check
|
|
|
|
|
function getOrigin(url: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url)
|
|
|
|
|
return `${parsed.protocol}//${parsed.host}`
|
|
|
|
|
} catch {
|
|
|
|
|
return url // Fallback if parsing fails
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
|
|
|
|
|
|
2026-01-03 11:05:38 +08:00
|
|
|
// Minimal blank diagram used to bootstrap new sessions.
|
|
|
|
|
// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.
|
|
|
|
|
const DEFAULT_DIAGRAM_XML = `<mxfile host="app.diagrams.net"><diagram id="blank" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
|
2026-01-01 13:47:39 +08:00
|
|
|
// Normalize URL for iframe src - ensure no double slashes
|
|
|
|
|
function normalizeUrl(url: string): string {
|
|
|
|
|
// Remove trailing slash to avoid double slashes
|
2026-01-01 15:20:00 +09:00
|
|
|
return url.replace(/\/$/, "")
|
2026-01-01 13:47:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 11:05:38 +08:00
|
|
|
function isLikelyMcpSessionId(sessionId: string): boolean {
|
|
|
|
|
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
|
|
|
|
|
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:03:58 +09:00
|
|
|
// Find the most recent active session (for auto-redirect when no sessionId provided)
|
|
|
|
|
function getMostRecentSessionId(): string | null {
|
|
|
|
|
let mostRecent: { id: string; lastUpdated: Date } | null = null
|
|
|
|
|
for (const [sessionId, state] of stateStore) {
|
|
|
|
|
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
|
|
|
|
|
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return mostRecent?.id || null
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 11:05:38 +08:00
|
|
|
function ensureSessionStateInitialized(sessionId: string): void {
|
|
|
|
|
if (!sessionId) return
|
|
|
|
|
if (!isLikelyMcpSessionId(sessionId)) return
|
|
|
|
|
if (stateStore.has(sessionId)) return
|
|
|
|
|
|
|
|
|
|
setState(sessionId, DEFAULT_DIAGRAM_XML)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 14:50:07 +09:00
|
|
|
interface SessionState {
|
|
|
|
|
xml: string
|
|
|
|
|
version: number
|
|
|
|
|
lastUpdated: Date
|
2025-12-21 16:58:41 +09:00
|
|
|
svg?: string // Cached SVG from last browser save
|
2025-12-21 19:38:35 +09:00
|
|
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
2026-02-07 12:55:09 +09:00
|
|
|
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
|
|
|
|
exportData?: string // Base64/SVG data returned by browser after export
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const stateStore = new Map<string, SessionState>()
|
|
|
|
|
|
|
|
|
|
let server: http.Server | null = null
|
2025-12-21 16:11:49 +09:00
|
|
|
let serverPort = 6002
|
|
|
|
|
const MAX_PORT = 6020
|
|
|
|
|
const SESSION_TTL = 60 * 60 * 1000
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
export function getState(sessionId: string): SessionState | undefined {
|
|
|
|
|
return stateStore.get(sessionId)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:58:41 +09:00
|
|
|
export function setState(sessionId: string, xml: string, svg?: string): number {
|
2025-12-17 14:50:07 +09:00
|
|
|
const existing = stateStore.get(sessionId)
|
|
|
|
|
const newVersion = (existing?.version || 0) + 1
|
|
|
|
|
stateStore.set(sessionId, {
|
|
|
|
|
xml,
|
|
|
|
|
version: newVersion,
|
|
|
|
|
lastUpdated: new Date(),
|
2025-12-21 16:58:41 +09:00
|
|
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
2025-12-21 19:38:35 +09:00
|
|
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
2026-02-07 12:55:09 +09:00
|
|
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
|
|
|
|
exportData: existing?.exportData, // Preserve export result
|
2025-12-17 14:50:07 +09:00
|
|
|
})
|
|
|
|
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
|
|
|
|
return newVersion
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 19:38:35 +09:00
|
|
|
export function requestSync(sessionId: string): boolean {
|
|
|
|
|
const state = stateStore.get(sessionId)
|
|
|
|
|
if (state) {
|
|
|
|
|
state.syncRequested = Date.now()
|
|
|
|
|
log.debug(`Sync requested for session=${sessionId}`)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
log.debug(`Sync requested for non-existent session=${sessionId}`)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function waitForSync(
|
|
|
|
|
sessionId: string,
|
|
|
|
|
timeoutMs = 3000,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
const start = Date.now()
|
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
|
|
|
const state = stateStore.get(sessionId)
|
|
|
|
|
if (!state?.syncRequested) return true // Sync completed
|
|
|
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
|
|
|
}
|
|
|
|
|
log.warn(`Sync timeout for session=${sessionId}`)
|
|
|
|
|
return false // Timeout
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
export function startHttpServer(port = 6002): Promise<number> {
|
2025-12-17 14:50:07 +09:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
if (server) {
|
|
|
|
|
resolve(serverPort)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
serverPort = port
|
|
|
|
|
server = http.createServer(handleRequest)
|
|
|
|
|
|
|
|
|
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
|
|
|
if (err.code === "EADDRINUSE") {
|
|
|
|
|
if (port >= MAX_PORT) {
|
|
|
|
|
reject(
|
|
|
|
|
new Error(
|
|
|
|
|
`No available ports in range 6002-${MAX_PORT}`,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
log.info(`Port ${port} in use, trying ${port + 1}`)
|
|
|
|
|
server = null
|
|
|
|
|
startHttpServer(port + 1)
|
|
|
|
|
.then(resolve)
|
|
|
|
|
.catch(reject)
|
|
|
|
|
} else {
|
|
|
|
|
reject(err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-06 09:04:38 +09:00
|
|
|
server.listen(port, "127.0.0.1", () => {
|
2025-12-17 14:50:07 +09:00
|
|
|
serverPort = port
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info(`HTTP server running on http://localhost:${port}`)
|
2025-12-17 14:50:07 +09:00
|
|
|
resolve(port)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stopHttpServer(): void {
|
|
|
|
|
if (server) {
|
|
|
|
|
server.close()
|
|
|
|
|
server = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cleanupExpiredSessions(): void {
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
for (const [sessionId, state] of stateStore) {
|
|
|
|
|
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
|
|
|
|
stateStore.delete(sessionId)
|
2025-12-21 16:11:49 +09:00
|
|
|
clearHistory(sessionId)
|
2025-12-17 14:50:07 +09:00
|
|
|
log.info(`Cleaned up expired session: ${sessionId}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 18:38:20 +09:00
|
|
|
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
|
|
|
|
|
|
|
|
|
export function shutdown(): void {
|
|
|
|
|
clearInterval(cleanupIntervalId)
|
|
|
|
|
stopHttpServer()
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
export function getServerPort(): number {
|
|
|
|
|
return serverPort
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleRequest(
|
|
|
|
|
req: http.IncomingMessage,
|
|
|
|
|
res: http.ServerResponse,
|
|
|
|
|
): void {
|
|
|
|
|
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
|
|
|
|
|
2026-03-24 11:50:03 +09:00
|
|
|
const requestOrigin = req.headers.origin
|
|
|
|
|
if (requestOrigin === `http://localhost:${serverPort}`) {
|
|
|
|
|
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
|
|
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
|
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
|
|
|
|
|
if (req.method === "OPTIONS") {
|
|
|
|
|
res.writeHead(204)
|
|
|
|
|
res.end()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
2026-01-03 11:05:38 +08:00
|
|
|
const sessionId = url.searchParams.get("mcp") || ""
|
2026-01-24 11:03:58 +09:00
|
|
|
|
|
|
|
|
// Auto-redirect to most recent session if no sessionId provided
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
const recentSessionId = getMostRecentSessionId()
|
|
|
|
|
if (recentSessionId) {
|
|
|
|
|
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
|
|
|
|
|
res.end()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 11:05:38 +08:00
|
|
|
ensureSessionStateInitialized(sessionId)
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
res.writeHead(200, { "Content-Type": "text/html" })
|
2026-01-03 11:05:38 +08:00
|
|
|
res.end(getHtmlPage(sessionId))
|
2025-12-21 16:11:49 +09:00
|
|
|
} else if (url.pathname === "/api/state") {
|
2025-12-17 14:50:07 +09:00
|
|
|
handleStateApi(req, res, url)
|
2025-12-21 16:11:49 +09:00
|
|
|
} else if (url.pathname === "/api/history") {
|
2025-12-21 16:09:14 +09:00
|
|
|
handleHistoryApi(req, res, url)
|
2025-12-21 16:11:49 +09:00
|
|
|
} else if (url.pathname === "/api/restore") {
|
|
|
|
|
handleRestoreApi(req, res)
|
2025-12-21 16:58:41 +09:00
|
|
|
} else if (url.pathname === "/api/history-svg") {
|
|
|
|
|
handleHistorySvgApi(req, res)
|
2025-12-17 14:50:07 +09:00
|
|
|
} else {
|
|
|
|
|
res.writeHead(404)
|
|
|
|
|
res.end("Not Found")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleStateApi(
|
|
|
|
|
req: http.IncomingMessage,
|
|
|
|
|
res: http.ServerResponse,
|
|
|
|
|
url: URL,
|
|
|
|
|
): void {
|
|
|
|
|
if (req.method === "GET") {
|
|
|
|
|
const sessionId = url.searchParams.get("sessionId")
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-03 11:05:38 +08:00
|
|
|
ensureSessionStateInitialized(sessionId)
|
2025-12-17 14:50:07 +09:00
|
|
|
const state = stateStore.get(sessionId)
|
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
xml: state?.xml || null,
|
|
|
|
|
version: state?.version || 0,
|
2025-12-21 19:38:35 +09:00
|
|
|
syncRequested: !!state?.syncRequested,
|
2026-02-07 12:55:09 +09:00
|
|
|
exportFormat: state?.exportFormat || null,
|
2025-12-17 14:50:07 +09:00
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
} else if (req.method === "POST") {
|
|
|
|
|
let body = ""
|
|
|
|
|
req.on("data", (chunk) => {
|
|
|
|
|
body += chunk
|
|
|
|
|
})
|
|
|
|
|
req.on("end", () => {
|
|
|
|
|
try {
|
2026-02-07 12:55:09 +09:00
|
|
|
const data = JSON.parse(body)
|
|
|
|
|
const { sessionId } = data
|
2025-12-17 14:50:07 +09:00
|
|
|
if (!sessionId) {
|
|
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-07 12:55:09 +09:00
|
|
|
|
|
|
|
|
// Browser is returning export data (png/svg)
|
|
|
|
|
if (data.exportData !== undefined) {
|
|
|
|
|
const state = stateStore.get(sessionId)
|
|
|
|
|
if (state) {
|
|
|
|
|
state.exportData = data.exportData
|
|
|
|
|
state.exportFormat = undefined
|
|
|
|
|
log.debug(
|
|
|
|
|
`Export data received for session=${sessionId}`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ success: true }))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const version = setState(sessionId, data.xml, data.svg)
|
2025-12-17 14:50:07 +09:00
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ success: true, version }))
|
|
|
|
|
} catch {
|
|
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
res.writeHead(405)
|
|
|
|
|
res.end("Method Not Allowed")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:09:14 +09:00
|
|
|
function handleHistoryApi(
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
const history = getHistory(sessionId)
|
2025-12-21 16:09:14 +09:00
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(
|
|
|
|
|
JSON.stringify({
|
2025-12-21 16:11:49 +09:00
|
|
|
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
|
2025-12-21 16:09:14 +09:00
|
|
|
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 {
|
2025-12-21 16:11:49 +09:00
|
|
|
const { sessionId, index } = JSON.parse(body)
|
|
|
|
|
if (!sessionId || index === undefined) {
|
2025-12-21 16:09:14 +09:00
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(
|
2025-12-21 16:11:49 +09:00
|
|
|
JSON.stringify({ error: "sessionId and index required" }),
|
2025-12-21 16:09:14 +09:00
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
const entry = getHistoryEntry(sessionId, index)
|
2025-12-21 16:09:14 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
res.writeHead(404, { "Content-Type": "application/json" })
|
2025-12-21 16:11:49 +09:00
|
|
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
2025-12-21 16:09:14 +09:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const newVersion = setState(sessionId, entry.xml)
|
2025-12-21 16:11:49 +09:00
|
|
|
addHistory(sessionId, entry.xml, entry.svg)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
log.info(`Restored session ${sessionId} to index ${index}`)
|
2025-12-21 16:09:14 +09:00
|
|
|
|
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
2025-12-21 16:11:49 +09:00
|
|
|
res.end(JSON.stringify({ success: true, newVersion }))
|
2025-12-21 16:09:14 +09:00
|
|
|
} catch {
|
|
|
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
|
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:58:41 +09:00
|
|
|
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" }))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 14:50:07 +09:00
|
|
|
function getHtmlPage(sessionId: string): string {
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-01-24 12:43:39 +09:00
|
|
|
<title>Next AI Draw.io</title>
|
2025-12-17 14:50:07 +09:00
|
|
|
<style>
|
2026-01-24 12:43:39 +09:00
|
|
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
|
2025-12-17 14:50:07 +09:00
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
|
|
|
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
|
|
|
|
#header {
|
2026-01-24 12:43:39 +09:00
|
|
|
padding: 0 20px; height: 52px;
|
|
|
|
|
background: linear-gradient(to bottom, #ffffff, #fafbfc);
|
|
|
|
|
border-bottom: 1px solid #e8ecf0;
|
|
|
|
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
2025-12-21 16:11:49 +09:00
|
|
|
display: flex; justify-content: space-between; align-items: center;
|
2026-01-24 12:43:39 +09:00
|
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|
|
|
|
position: relative; z-index: 10;
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
2026-01-24 12:43:39 +09:00
|
|
|
#header .brand {
|
|
|
|
|
display: flex; align-items: center; gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
#header .logo {
|
|
|
|
|
width: 28px; height: 28px; border-radius: 6px;
|
|
|
|
|
background: #18181b;
|
2025-12-21 16:11:49 +09:00
|
|
|
display: flex; align-items: center; justify-content: center;
|
2026-01-24 12:43:39 +09:00
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
|
|
|
|
|
#header .title {
|
|
|
|
|
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
|
|
|
|
letter-spacing: -0.3px;
|
|
|
|
|
}
|
|
|
|
|
#header .session {
|
|
|
|
|
font-size: 11px; color: #8b95a5; font-weight: 400;
|
|
|
|
|
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
|
|
|
|
|
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
|
|
|
|
|
}
|
|
|
|
|
#header .right { display: flex; align-items: center; gap: 12px; }
|
|
|
|
|
#save-btn {
|
|
|
|
|
display: flex; align-items: center; gap: 6px;
|
|
|
|
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
|
|
|
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
|
|
|
|
color: white; border: none; cursor: pointer;
|
|
|
|
|
font-weight: 500; font-family: inherit;
|
|
|
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
#save-btn svg { width: 14px; height: 14px; }
|
|
|
|
|
#save-btn:hover {
|
|
|
|
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
|
|
|
|
|
}
|
|
|
|
|
#save-btn:active { transform: translateY(0); }
|
|
|
|
|
#save-btn:disabled, #history-btn:disabled {
|
|
|
|
|
background: #e5e7eb; color: #9ca3af;
|
|
|
|
|
cursor: not-allowed; transform: none; box-shadow: none;
|
|
|
|
|
}
|
|
|
|
|
#history-btn {
|
|
|
|
|
display: flex; align-items: center; gap: 6px;
|
|
|
|
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
|
|
|
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
|
|
|
|
cursor: pointer; font-weight: 500; font-family: inherit;
|
|
|
|
|
transition: all 0.15s ease;
|
2025-12-21 16:09:14 +09:00
|
|
|
}
|
2026-01-24 12:43:39 +09:00
|
|
|
#history-btn svg { width: 14px; height: 14px; }
|
|
|
|
|
#history-btn:hover {
|
|
|
|
|
background: #e4e4e7; border-color: #d4d4d8;
|
|
|
|
|
}
|
|
|
|
|
#drawio { flex: 1; border: none; }
|
|
|
|
|
#history-modal, #save-modal {
|
2025-12-21 16:11:49 +09:00
|
|
|
display: none; position: fixed; inset: 0;
|
2026-01-24 12:43:39 +09:00
|
|
|
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
|
|
|
|
|
z-index: 2000; align-items: center; justify-content: center;
|
2025-12-21 16:09:14 +09:00
|
|
|
}
|
2026-01-24 12:43:39 +09:00
|
|
|
#history-modal.open, #save-modal.open { display: flex; }
|
2025-12-21 16:09:14 +09:00
|
|
|
.modal-content {
|
2026-01-24 12:43:39 +09:00
|
|
|
background: white; border-radius: 16px;
|
|
|
|
|
width: 90%; max-width: 480px; max-height: 70vh;
|
2025-12-21 16:11:49 +09:00
|
|
|
display: flex; flex-direction: column;
|
2026-01-24 12:43:39 +09:00
|
|
|
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
|
|
|
|
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
|
|
|
|
animation: modalIn 0.2s ease-out;
|
|
|
|
|
}
|
|
|
|
|
@keyframes modalIn {
|
|
|
|
|
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
|
|
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
|
|
|
}
|
|
|
|
|
.modal-header {
|
|
|
|
|
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
|
|
|
|
|
}
|
|
|
|
|
.modal-header h2 {
|
|
|
|
|
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
|
|
|
|
|
letter-spacing: -0.3px;
|
2025-12-21 16:11:49 +09:00
|
|
|
}
|
2026-01-24 12:43:39 +09:00
|
|
|
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
|
|
|
|
.modal-footer {
|
|
|
|
|
padding: 16px 24px; border-top: 1px solid #f1f3f5;
|
|
|
|
|
display: flex; gap: 10px; justify-content: flex-end;
|
|
|
|
|
}
|
|
|
|
|
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
2025-12-21 16:09:14 +09:00
|
|
|
.history-item {
|
2026-01-24 12:43:39 +09:00
|
|
|
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
|
|
|
|
|
cursor: pointer; text-align: center; transition: all 0.15s ease;
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
}
|
|
|
|
|
.history-item:hover { border-color: #a1a1aa; background: white; }
|
|
|
|
|
.history-item.selected {
|
|
|
|
|
border-color: #18181b; background: white;
|
|
|
|
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
|
2025-12-21 16:09:14 +09:00
|
|
|
}
|
|
|
|
|
.history-item .thumb {
|
2026-01-24 12:43:39 +09:00
|
|
|
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
|
2025-12-21 16:11:49 +09:00
|
|
|
display: flex; align-items: center; justify-content: center;
|
2026-01-24 12:43:39 +09:00
|
|
|
margin-bottom: 6px; overflow: hidden;
|
2025-12-21 16:09:14 +09:00
|
|
|
}
|
2025-12-21 16:11:49 +09:00
|
|
|
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
2026-01-24 12:43:39 +09:00
|
|
|
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
|
|
|
|
|
.btn {
|
|
|
|
|
padding: 9px 18px; border-radius: 8px; font-size: 13px;
|
|
|
|
|
cursor: pointer; border: none; font-weight: 500;
|
|
|
|
|
font-family: inherit; transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
.btn-primary {
|
|
|
|
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
|
|
|
|
color: white;
|
|
|
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
|
|
|
|
}
|
|
|
|
|
.btn-primary:hover {
|
|
|
|
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
}
|
|
|
|
|
.btn-primary:disabled {
|
|
|
|
|
background: #e4e4e7; color: #a1a1aa;
|
|
|
|
|
cursor: not-allowed; transform: none; box-shadow: none;
|
|
|
|
|
}
|
|
|
|
|
.btn-secondary {
|
|
|
|
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
|
|
|
|
}
|
|
|
|
|
.btn-secondary:hover { background: #e4e4e7; }
|
|
|
|
|
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
|
|
|
|
|
.form-group { margin-bottom: 18px; }
|
|
|
|
|
.form-group label {
|
|
|
|
|
display: block; font-size: 13px; font-weight: 500;
|
|
|
|
|
margin-bottom: 8px; color: #3f3f46;
|
|
|
|
|
}
|
|
|
|
|
.form-group select, .form-group input {
|
|
|
|
|
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
|
|
|
|
|
border-radius: 8px; font-size: 14px; outline: none;
|
|
|
|
|
font-family: inherit; background: white;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
.form-group select:focus, .form-group input:focus {
|
|
|
|
|
border-color: #18181b;
|
|
|
|
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
|
|
|
|
|
}
|
|
|
|
|
.filename-group { display: flex; }
|
|
|
|
|
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
|
|
|
|
|
.filename-group .ext {
|
|
|
|
|
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
|
|
|
|
|
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
|
|
|
|
|
font-family: 'SF Mono', Monaco, monospace;
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div id="container">
|
|
|
|
|
<div id="header">
|
2026-01-24 12:43:39 +09:00
|
|
|
<div class="brand">
|
|
|
|
|
<div class="logo">
|
|
|
|
|
<svg viewBox="0 0 1536 1536" fill="#ffffff">
|
|
|
|
|
<g transform="translate(0,1536) scale(0.1,-0.1)">
|
|
|
|
|
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
|
|
|
|
|
</g>
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="title">Next AI Draw.io</span>
|
|
|
|
|
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="right">
|
|
|
|
|
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
|
|
|
<polyline points="12 6 12 12 16 14"></polyline>
|
|
|
|
|
</svg>
|
|
|
|
|
History
|
|
|
|
|
</button>
|
|
|
|
|
<button id="save-btn" ${sessionId ? "" : "disabled"}>
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
|
|
|
<polyline points="7 10 12 15 17 10"></polyline>
|
|
|
|
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
|
|
|
</svg>
|
|
|
|
|
Download
|
|
|
|
|
</button>
|
2025-12-17 14:50:07 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-24 12:43:39 +09:00
|
|
|
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
|
2025-12-17 14:50:07 +09:00
|
|
|
</div>
|
2025-12-21 16:09:14 +09:00
|
|
|
<div id="history-modal">
|
|
|
|
|
<div class="modal-content">
|
2025-12-21 16:11:49 +09:00
|
|
|
<div class="modal-header"><h2>History</h2></div>
|
2025-12-21 16:09:14 +09:00
|
|
|
<div class="modal-body">
|
|
|
|
|
<div id="history-grid" class="history-grid"></div>
|
2025-12-21 16:11:49 +09:00
|
|
|
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
|
2025-12-21 16:09:14 +09:00
|
|
|
</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>
|
2026-01-24 12:43:39 +09:00
|
|
|
<div id="save-modal">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header"><h2>Download Diagram</h2></div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label>Format</label>
|
|
|
|
|
<select id="save-format">
|
|
|
|
|
<option value="drawio">Draw.io (.drawio)</option>
|
|
|
|
|
<option value="png">PNG Image (.png)</option>
|
|
|
|
|
<option value="svg">SVG Vector (.svg)</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label>Filename</label>
|
|
|
|
|
<div class="filename-group">
|
|
|
|
|
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
|
|
|
|
|
<span class="ext" id="save-ext">.drawio</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
|
|
|
|
|
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-17 14:50:07 +09:00
|
|
|
<script>
|
|
|
|
|
const sessionId = "${sessionId}";
|
|
|
|
|
const iframe = document.getElementById('drawio');
|
2025-12-21 16:11:49 +09:00
|
|
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
2025-12-21 16:58:41 +09:00
|
|
|
let pendingSvgExport = null;
|
|
|
|
|
let pendingAiSvg = false;
|
2026-02-07 12:55:09 +09:00
|
|
|
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
window.addEventListener('message', (e) => {
|
2026-01-01 13:47:39 +08:00
|
|
|
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
2025-12-17 14:50:07 +09:00
|
|
|
try {
|
2025-12-21 16:11:49 +09:00
|
|
|
const msg = JSON.parse(e.data);
|
|
|
|
|
if (msg.event === 'init') {
|
|
|
|
|
isReady = true;
|
|
|
|
|
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
|
|
|
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
2025-12-21 16:58:41 +09:00
|
|
|
// 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) {
|
2026-02-07 12:55:09 +09:00
|
|
|
// Handle MCP server export request (png/svg)
|
|
|
|
|
// Verify the response matches the requested format to avoid capturing
|
|
|
|
|
// unrelated exports (autosave SVG, sync XML)
|
|
|
|
|
if (pendingMcpExport) {
|
|
|
|
|
const d = msg.data;
|
|
|
|
|
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
|
|
|
|
|
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
|
|
|
|
if (isPng || isSvg) {
|
|
|
|
|
pendingMcpExport = null;
|
|
|
|
|
fetch('/api/state', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ sessionId, exportData: d })
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-24 12:43:39 +09:00
|
|
|
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
|
|
|
|
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
|
|
|
|
const dl = pendingDownload;
|
|
|
|
|
pendingDownload = null;
|
|
|
|
|
let dataUrl = msg.data;
|
|
|
|
|
if (!dataUrl.startsWith('data:')) {
|
|
|
|
|
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
|
|
|
|
|
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
|
|
|
|
|
}
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = dataUrl; a.download = dl.filename;
|
|
|
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
|
|
|
saveModal.classList.remove('open');
|
|
|
|
|
saveConfirmBtn.disabled = false;
|
|
|
|
|
saveConfirmBtn.textContent = 'Save';
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-21 19:38:35 +09:00
|
|
|
// Handle sync export (XML format) - server requested fresh state
|
|
|
|
|
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
|
|
|
|
pendingSyncExport = false;
|
|
|
|
|
pushState(msg.data, '');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Handle SVG export
|
2025-12-21 16:58:41 +09:00
|
|
|
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(() => {});
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
2025-12-21 16:11:49 +09:00
|
|
|
} catch {}
|
|
|
|
|
});
|
2025-12-17 14:50:07 +09:00
|
|
|
|
2025-12-21 16:58:41 +09:00
|
|
|
function loadDiagram(xml, capturePreview = false) {
|
2025-12-21 16:11:49 +09:00
|
|
|
if (!isReady) { pendingXml = xml; return; }
|
|
|
|
|
lastXml = xml;
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
2025-12-21 16:58:41 +09:00
|
|
|
if (capturePreview) {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
pendingAiSvg = true;
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
|
|
|
}, 500);
|
|
|
|
|
}
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:58:41 +09:00
|
|
|
async function pushState(xml, svg = '') {
|
2025-12-17 14:50:07 +09:00
|
|
|
if (!sessionId) return;
|
|
|
|
|
try {
|
2025-12-21 16:11:49 +09:00
|
|
|
const r = await fetch('/api/state', {
|
2025-12-17 14:50:07 +09:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2025-12-21 16:58:41 +09:00
|
|
|
body: JSON.stringify({ sessionId, xml, svg })
|
2025-12-17 14:50:07 +09:00
|
|
|
});
|
2025-12-21 16:11:49 +09:00
|
|
|
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
|
|
|
|
} catch (e) { console.error('Push failed:', e); }
|
2025-12-21 16:09:14 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-21 19:38:35 +09:00
|
|
|
let pendingSyncExport = false;
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
async function poll() {
|
2025-12-21 16:09:14 +09:00
|
|
|
if (!sessionId) return;
|
|
|
|
|
try {
|
2025-12-21 16:11:49 +09:00
|
|
|
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
|
|
|
|
if (!r.ok) return;
|
|
|
|
|
const s = await r.json();
|
2025-12-21 19:38:35 +09:00
|
|
|
// Handle sync request - server needs fresh state
|
|
|
|
|
if (s.syncRequested && !pendingSyncExport) {
|
|
|
|
|
pendingSyncExport = true;
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
|
|
|
|
}
|
2026-02-07 12:55:09 +09:00
|
|
|
// Load new diagram from server (before export, so we export latest)
|
2025-12-21 16:11:49 +09:00
|
|
|
if (s.version > currentVersion && s.xml) {
|
|
|
|
|
currentVersion = s.version;
|
2025-12-21 16:58:41 +09:00
|
|
|
loadDiagram(s.xml, true);
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
2026-02-07 12:55:09 +09:00
|
|
|
// Handle export request from MCP server (png/svg) - after version update
|
|
|
|
|
if (s.exportFormat && !pendingMcpExport && isReady) {
|
|
|
|
|
pendingMcpExport = s.exportFormat;
|
|
|
|
|
const exportOpts = s.exportFormat === 'png'
|
|
|
|
|
? { action: 'export', format: 'png', scale: 2 }
|
|
|
|
|
: { action: 'export', format: 'svg' };
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
|
|
|
|
// Timeout: reset if draw.io never responds
|
|
|
|
|
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
|
|
|
|
}
|
2025-12-21 16:11:49 +09:00
|
|
|
} catch {}
|
2025-12-17 14:50:07 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
if (sessionId) { poll(); setInterval(poll, 2000); }
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2026-01-24 12:43:39 +09:00
|
|
|
// Save modal
|
|
|
|
|
const saveBtn = document.getElementById('save-btn');
|
|
|
|
|
const saveModal = document.getElementById('save-modal');
|
|
|
|
|
const saveFormat = document.getElementById('save-format');
|
|
|
|
|
const saveFilename = document.getElementById('save-filename');
|
|
|
|
|
const saveExt = document.getElementById('save-ext');
|
|
|
|
|
const saveCancelBtn = document.getElementById('save-cancel-btn');
|
|
|
|
|
const saveConfirmBtn = document.getElementById('save-confirm-btn');
|
|
|
|
|
let pendingDownload = null;
|
|
|
|
|
|
|
|
|
|
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
|
|
|
|
|
|
|
|
|
|
saveBtn.onclick = () => {
|
|
|
|
|
if (!sessionId || !isReady) return;
|
|
|
|
|
saveModal.classList.add('open');
|
|
|
|
|
saveFilename.focus();
|
|
|
|
|
saveFilename.select();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
saveFormat.onchange = () => {
|
|
|
|
|
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
|
|
|
|
|
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
|
|
|
|
|
|
|
|
|
|
saveConfirmBtn.onclick = () => {
|
|
|
|
|
const format = saveFormat.value;
|
|
|
|
|
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
|
|
|
|
|
saveConfirmBtn.disabled = true;
|
|
|
|
|
saveConfirmBtn.textContent = 'Exporting...';
|
|
|
|
|
|
|
|
|
|
if (format === 'drawio') {
|
|
|
|
|
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
|
|
|
|
let xmlData = lastXml || '';
|
|
|
|
|
if (xmlData && !xmlData.includes('<mxfile')) {
|
|
|
|
|
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
|
|
|
|
}
|
|
|
|
|
const blob = new Blob([xmlData], { type: 'application/xml' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url; a.download = filename;
|
|
|
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
saveModal.classList.remove('open');
|
|
|
|
|
saveConfirmBtn.disabled = false;
|
|
|
|
|
saveConfirmBtn.textContent = 'Save';
|
|
|
|
|
} else if (format === 'png') {
|
|
|
|
|
pendingDownload = { format: 'png', filename };
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
|
|
|
|
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
|
|
|
|
} else if (format === 'svg') {
|
|
|
|
|
pendingDownload = { format: 'svg', filename };
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
|
|
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
// History UI
|
2025-12-21 16:09:14 +09:00
|
|
|
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');
|
2025-12-21 16:11:49 +09:00
|
|
|
let historyData = [], selectedIdx = null;
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
historyBtn.onclick = async () => {
|
2025-12-21 16:09:14 +09:00
|
|
|
if (!sessionId) return;
|
2025-12-21 16:11:49 +09:00
|
|
|
try {
|
|
|
|
|
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
historyData = d.entries || [];
|
|
|
|
|
renderHistory();
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
2025-12-21 16:09:14 +09:00
|
|
|
historyModal.classList.add('open');
|
2025-12-21 16:11:49 +09:00
|
|
|
};
|
2025-12-21 16:09:14 +09:00
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
|
|
|
|
|
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
|
2025-12-21 16:09:14 +09:00
|
|
|
|
|
|
|
|
function renderHistory() {
|
|
|
|
|
if (historyData.length === 0) {
|
|
|
|
|
historyGrid.style.display = 'none';
|
|
|
|
|
historyEmpty.style.display = 'block';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
historyGrid.style.display = 'grid';
|
|
|
|
|
historyEmpty.style.display = 'none';
|
2025-12-21 16:11:49 +09:00
|
|
|
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('');
|
2025-12-21 16:09:14 +09:00
|
|
|
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
2025-12-21 16:11:49 +09:00
|
|
|
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));
|
|
|
|
|
};
|
2025-12-21 16:09:14 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 16:11:49 +09:00
|
|
|
restoreBtn.onclick = async () => {
|
|
|
|
|
if (selectedIdx === null) return;
|
|
|
|
|
restoreBtn.disabled = true;
|
|
|
|
|
restoreBtn.textContent = 'Restoring...';
|
2025-12-21 16:09:14 +09:00
|
|
|
try {
|
2025-12-21 16:11:49 +09:00
|
|
|
const r = await fetch('/api/restore', {
|
2025-12-21 16:09:14 +09:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2025-12-21 16:11:49 +09:00
|
|
|
body: JSON.stringify({ sessionId, index: selectedIdx })
|
2025-12-21 16:09:14 +09:00
|
|
|
});
|
2025-12-21 16:11:49 +09:00
|
|
|
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
|
|
|
|
else { alert('Restore failed'); }
|
|
|
|
|
} catch { alert('Restore failed'); }
|
|
|
|
|
restoreBtn.textContent = 'Restore';
|
|
|
|
|
};
|
2025-12-17 14:50:07 +09:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`
|
|
|
|
|
}
|