@next-ai-drawio/mcp-server 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,40 +1,52 @@
1
1
  /**
2
2
  * Embedded HTTP Server for MCP
3
- *
4
- * Serves a static HTML page with draw.io embed and handles state sync.
5
- * This eliminates the need for an external Next.js app.
3
+ * Serves draw.io embed with state sync and history UI
6
4
  */
7
5
  import http from "node:http";
6
+ import { addHistory, clearHistory, getHistory, getHistoryEntry, updateLastHistorySvg, } from "./history.js";
8
7
  import { log } from "./logger.js";
9
- // In-memory state store (shared with MCP server in same process)
10
8
  export const stateStore = new Map();
11
9
  let server = null;
12
10
  let serverPort = 6002;
13
- const MAX_PORT = 6020; // Don't retry beyond this port
14
- const SESSION_TTL = 60 * 60 * 1000; // 1 hour
15
- /**
16
- * Get state for a session
17
- */
11
+ const MAX_PORT = 6020;
12
+ const SESSION_TTL = 60 * 60 * 1000;
18
13
  export function getState(sessionId) {
19
14
  return stateStore.get(sessionId);
20
15
  }
21
- /**
22
- * Set state for a session
23
- */
24
- export function setState(sessionId, xml) {
16
+ export function setState(sessionId, xml, svg) {
25
17
  const existing = stateStore.get(sessionId);
26
18
  const newVersion = (existing?.version || 0) + 1;
27
19
  stateStore.set(sessionId, {
28
20
  xml,
29
21
  version: newVersion,
30
22
  lastUpdated: new Date(),
23
+ svg: svg || existing?.svg, // Preserve cached SVG if not provided
24
+ syncRequested: undefined, // Clear sync request when browser pushes state
31
25
  });
32
26
  log.debug(`State updated: session=${sessionId}, version=${newVersion}`);
33
27
  return newVersion;
34
28
  }
35
- /**
36
- * Start the embedded HTTP server
37
- */
29
+ export function requestSync(sessionId) {
30
+ const state = stateStore.get(sessionId);
31
+ if (state) {
32
+ state.syncRequested = Date.now();
33
+ log.debug(`Sync requested for session=${sessionId}`);
34
+ return true;
35
+ }
36
+ log.debug(`Sync requested for non-existent session=${sessionId}`);
37
+ return false;
38
+ }
39
+ export async function waitForSync(sessionId, timeoutMs = 3000) {
40
+ const start = Date.now();
41
+ while (Date.now() - start < timeoutMs) {
42
+ const state = stateStore.get(sessionId);
43
+ if (!state?.syncRequested)
44
+ return true; // Sync completed
45
+ await new Promise((r) => setTimeout(r, 100));
46
+ }
47
+ log.warn(`Sync timeout for session=${sessionId}`);
48
+ return false; // Timeout
49
+ }
38
50
  export function startHttpServer(port = 6002) {
39
51
  return new Promise((resolve, reject) => {
40
52
  if (server) {
@@ -61,46 +73,33 @@ export function startHttpServer(port = 6002) {
61
73
  });
62
74
  server.listen(port, () => {
63
75
  serverPort = port;
64
- log.info(`Embedded HTTP server running on http://localhost:${port}`);
76
+ log.info(`HTTP server running on http://localhost:${port}`);
65
77
  resolve(port);
66
78
  });
67
79
  });
68
80
  }
69
- /**
70
- * Stop the HTTP server
71
- */
72
81
  export function stopHttpServer() {
73
82
  if (server) {
74
83
  server.close();
75
84
  server = null;
76
85
  }
77
86
  }
78
- /**
79
- * Clean up expired sessions
80
- */
81
87
  function cleanupExpiredSessions() {
82
88
  const now = Date.now();
83
89
  for (const [sessionId, state] of stateStore) {
84
90
  if (now - state.lastUpdated.getTime() > SESSION_TTL) {
85
91
  stateStore.delete(sessionId);
92
+ clearHistory(sessionId);
86
93
  log.info(`Cleaned up expired session: ${sessionId}`);
87
94
  }
88
95
  }
89
96
  }
90
- // Run cleanup every 5 minutes
91
97
  setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
92
- /**
93
- * Get the current server port
94
- */
95
98
  export function getServerPort() {
96
99
  return serverPort;
97
100
  }
98
- /**
99
- * Handle HTTP requests
100
- */
101
101
  function handleRequest(req, res) {
102
102
  const url = new URL(req.url || "/", `http://localhost:${serverPort}`);
103
- // CORS headers for local development
104
103
  res.setHeader("Access-Control-Allow-Origin", "*");
105
104
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
106
105
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
@@ -109,35 +108,27 @@ function handleRequest(req, res) {
109
108
  res.end();
110
109
  return;
111
110
  }
112
- // Route handling
113
111
  if (url.pathname === "/" || url.pathname === "/index.html") {
114
- serveHtml(req, res, url);
112
+ res.writeHead(200, { "Content-Type": "text/html" });
113
+ res.end(getHtmlPage(url.searchParams.get("mcp") || ""));
115
114
  }
116
- else if (url.pathname === "/api/state" ||
117
- url.pathname === "/api/mcp/state") {
115
+ else if (url.pathname === "/api/state") {
118
116
  handleStateApi(req, res, url);
119
117
  }
120
- else if (url.pathname === "/api/health" ||
121
- url.pathname === "/api/mcp/health") {
122
- res.writeHead(200, { "Content-Type": "application/json" });
123
- res.end(JSON.stringify({ status: "ok", mcp: true }));
118
+ else if (url.pathname === "/api/history") {
119
+ handleHistoryApi(req, res, url);
120
+ }
121
+ else if (url.pathname === "/api/restore") {
122
+ handleRestoreApi(req, res);
123
+ }
124
+ else if (url.pathname === "/api/history-svg") {
125
+ handleHistorySvgApi(req, res);
124
126
  }
125
127
  else {
126
128
  res.writeHead(404);
127
129
  res.end("Not Found");
128
130
  }
129
131
  }
130
- /**
131
- * Serve the HTML page with draw.io embed
132
- */
133
- function serveHtml(req, res, url) {
134
- const sessionId = url.searchParams.get("mcp") || "";
135
- res.writeHead(200, { "Content-Type": "text/html" });
136
- res.end(getHtmlPage(sessionId));
137
- }
138
- /**
139
- * Handle state API requests
140
- */
141
132
  function handleStateApi(req, res, url) {
142
133
  if (req.method === "GET") {
143
134
  const sessionId = url.searchParams.get("sessionId");
@@ -151,7 +142,7 @@ function handleStateApi(req, res, url) {
151
142
  res.end(JSON.stringify({
152
143
  xml: state?.xml || null,
153
144
  version: state?.version || 0,
154
- lastUpdated: state?.lastUpdated?.toISOString() || null,
145
+ syncRequested: !!state?.syncRequested,
155
146
  }));
156
147
  }
157
148
  else if (req.method === "POST") {
@@ -161,13 +152,13 @@ function handleStateApi(req, res, url) {
161
152
  });
162
153
  req.on("end", () => {
163
154
  try {
164
- const { sessionId, xml } = JSON.parse(body);
155
+ const { sessionId, xml, svg } = JSON.parse(body);
165
156
  if (!sessionId) {
166
157
  res.writeHead(400, { "Content-Type": "application/json" });
167
158
  res.end(JSON.stringify({ error: "sessionId required" }));
168
159
  return;
169
160
  }
170
- const version = setState(sessionId, xml);
161
+ const version = setState(sessionId, xml, svg);
171
162
  res.writeHead(200, { "Content-Type": "application/json" });
172
163
  res.end(JSON.stringify({ success: true, version }));
173
164
  }
@@ -182,35 +173,155 @@ function handleStateApi(req, res, url) {
182
173
  res.end("Method Not Allowed");
183
174
  }
184
175
  }
185
- /**
186
- * Generate the HTML page with draw.io embed
187
- */
176
+ function handleHistoryApi(req, res, url) {
177
+ if (req.method !== "GET") {
178
+ res.writeHead(405);
179
+ res.end("Method Not Allowed");
180
+ return;
181
+ }
182
+ const sessionId = url.searchParams.get("sessionId");
183
+ if (!sessionId) {
184
+ res.writeHead(400, { "Content-Type": "application/json" });
185
+ res.end(JSON.stringify({ error: "sessionId required" }));
186
+ return;
187
+ }
188
+ const history = getHistory(sessionId);
189
+ res.writeHead(200, { "Content-Type": "application/json" });
190
+ res.end(JSON.stringify({
191
+ entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
192
+ count: history.length,
193
+ }));
194
+ }
195
+ function handleRestoreApi(req, res) {
196
+ if (req.method !== "POST") {
197
+ res.writeHead(405);
198
+ res.end("Method Not Allowed");
199
+ return;
200
+ }
201
+ let body = "";
202
+ req.on("data", (chunk) => {
203
+ body += chunk;
204
+ });
205
+ req.on("end", () => {
206
+ try {
207
+ const { sessionId, index } = JSON.parse(body);
208
+ if (!sessionId || index === undefined) {
209
+ res.writeHead(400, { "Content-Type": "application/json" });
210
+ res.end(JSON.stringify({ error: "sessionId and index required" }));
211
+ return;
212
+ }
213
+ const entry = getHistoryEntry(sessionId, index);
214
+ if (!entry) {
215
+ res.writeHead(404, { "Content-Type": "application/json" });
216
+ res.end(JSON.stringify({ error: "Entry not found" }));
217
+ return;
218
+ }
219
+ const newVersion = setState(sessionId, entry.xml);
220
+ addHistory(sessionId, entry.xml, entry.svg);
221
+ log.info(`Restored session ${sessionId} to index ${index}`);
222
+ res.writeHead(200, { "Content-Type": "application/json" });
223
+ res.end(JSON.stringify({ success: true, newVersion }));
224
+ }
225
+ catch {
226
+ res.writeHead(400, { "Content-Type": "application/json" });
227
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
228
+ }
229
+ });
230
+ }
231
+ function handleHistorySvgApi(req, res) {
232
+ if (req.method !== "POST") {
233
+ res.writeHead(405);
234
+ res.end("Method Not Allowed");
235
+ return;
236
+ }
237
+ let body = "";
238
+ req.on("data", (chunk) => {
239
+ body += chunk;
240
+ });
241
+ req.on("end", () => {
242
+ try {
243
+ const { sessionId, svg } = JSON.parse(body);
244
+ if (!sessionId || !svg) {
245
+ res.writeHead(400, { "Content-Type": "application/json" });
246
+ res.end(JSON.stringify({ error: "sessionId and svg required" }));
247
+ return;
248
+ }
249
+ updateLastHistorySvg(sessionId, svg);
250
+ res.writeHead(200, { "Content-Type": "application/json" });
251
+ res.end(JSON.stringify({ success: true }));
252
+ }
253
+ catch {
254
+ res.writeHead(400, { "Content-Type": "application/json" });
255
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
256
+ }
257
+ });
258
+ }
188
259
  function getHtmlPage(sessionId) {
189
260
  return `<!DOCTYPE html>
190
261
  <html lang="en">
191
262
  <head>
192
263
  <meta charset="UTF-8">
193
264
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
194
- <title>Draw.io MCP - ${sessionId || "No Session"}</title>
265
+ <title>Draw.io MCP</title>
195
266
  <style>
196
267
  * { margin: 0; padding: 0; box-sizing: border-box; }
197
268
  html, body { width: 100%; height: 100%; overflow: hidden; }
198
269
  #container { width: 100%; height: 100%; display: flex; flex-direction: column; }
199
270
  #header {
200
- padding: 8px 16px;
201
- background: #1a1a2e;
202
- color: #eee;
203
- font-family: system-ui, sans-serif;
204
- font-size: 14px;
205
- display: flex;
206
- justify-content: space-between;
207
- align-items: center;
271
+ padding: 8px 16px; background: #1a1a2e; color: #eee;
272
+ font-family: system-ui, sans-serif; font-size: 14px;
273
+ display: flex; justify-content: space-between; align-items: center;
208
274
  }
209
275
  #header .session { color: #888; font-size: 12px; }
210
276
  #header .status { font-size: 12px; }
211
277
  #header .status.connected { color: #4ade80; }
212
278
  #header .status.disconnected { color: #f87171; }
213
279
  #drawio { flex: 1; border: none; }
280
+ #history-btn {
281
+ position: fixed; bottom: 24px; right: 24px;
282
+ width: 48px; height: 48px; border-radius: 50%;
283
+ background: #3b82f6; color: white; border: none; cursor: pointer;
284
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
285
+ display: flex; align-items: center; justify-content: center;
286
+ z-index: 1000;
287
+ }
288
+ #history-btn:hover { background: #2563eb; }
289
+ #history-btn:disabled { background: #6b7280; cursor: not-allowed; }
290
+ #history-btn svg { width: 24px; height: 24px; }
291
+ #history-modal {
292
+ display: none; position: fixed; inset: 0;
293
+ background: rgba(0,0,0,0.5); z-index: 2000;
294
+ align-items: center; justify-content: center;
295
+ }
296
+ #history-modal.open { display: flex; }
297
+ .modal-content {
298
+ background: white; border-radius: 12px;
299
+ width: 90%; max-width: 500px; max-height: 70vh;
300
+ display: flex; flex-direction: column;
301
+ }
302
+ .modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
303
+ .modal-header h2 { font-size: 18px; margin: 0; }
304
+ .modal-body { flex: 1; overflow-y: auto; padding: 16px; }
305
+ .modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
306
+ .history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
307
+ .history-item {
308
+ border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
309
+ cursor: pointer; text-align: center;
310
+ }
311
+ .history-item:hover { border-color: #3b82f6; }
312
+ .history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
313
+ .history-item .thumb {
314
+ aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
315
+ display: flex; align-items: center; justify-content: center;
316
+ margin-bottom: 4px; overflow: hidden;
317
+ }
318
+ .history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
319
+ .history-item .label { font-size: 12px; color: #666; }
320
+ .btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
321
+ .btn-primary { background: #3b82f6; color: white; }
322
+ .btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
323
+ .btn-secondary { background: #f3f4f6; color: #374151; }
324
+ .empty { text-align: center; padding: 40px; color: #666; }
214
325
  </style>
215
326
  </head>
216
327
  <body>
@@ -218,121 +329,191 @@ function getHtmlPage(sessionId) {
218
329
  <div id="header">
219
330
  <div>
220
331
  <strong>Draw.io MCP</strong>
221
- <span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
332
+ <span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
222
333
  </div>
223
334
  <div id="status" class="status disconnected">Connecting...</div>
224
335
  </div>
225
336
  <iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
226
337
  </div>
227
-
338
+ <button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
339
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
340
+ <circle cx="12" cy="12" r="10"></circle>
341
+ <polyline points="12 6 12 12 16 14"></polyline>
342
+ </svg>
343
+ </button>
344
+ <div id="history-modal">
345
+ <div class="modal-content">
346
+ <div class="modal-header"><h2>History</h2></div>
347
+ <div class="modal-body">
348
+ <div id="history-grid" class="history-grid"></div>
349
+ <div id="history-empty" class="empty" style="display:none;">No history yet</div>
350
+ </div>
351
+ <div class="modal-footer">
352
+ <button class="btn btn-secondary" id="cancel-btn">Cancel</button>
353
+ <button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
354
+ </div>
355
+ </div>
356
+ </div>
228
357
  <script>
229
358
  const sessionId = "${sessionId}";
230
359
  const iframe = document.getElementById('drawio');
231
360
  const statusEl = document.getElementById('status');
361
+ let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
362
+ let pendingSvgExport = null;
363
+ let pendingAiSvg = false;
232
364
 
233
- let currentVersion = 0;
234
- let isDrawioReady = false;
235
- let pendingXml = null;
236
- let lastLoadedXml = null;
237
-
238
- // Listen for messages from draw.io
239
- window.addEventListener('message', (event) => {
240
- if (event.origin !== 'https://embed.diagrams.net') return;
241
-
365
+ window.addEventListener('message', (e) => {
366
+ if (e.origin !== 'https://embed.diagrams.net') return;
242
367
  try {
243
- const msg = JSON.parse(event.data);
244
- handleDrawioMessage(msg);
245
- } catch (e) {
246
- // Ignore non-JSON messages
247
- }
248
- });
249
-
250
- function handleDrawioMessage(msg) {
251
- if (msg.event === 'init') {
252
- isDrawioReady = true;
253
- statusEl.textContent = 'Ready';
254
- statusEl.className = 'status connected';
255
-
256
- // Load pending XML if any
257
- if (pendingXml) {
258
- loadDiagram(pendingXml);
259
- pendingXml = null;
368
+ const msg = JSON.parse(e.data);
369
+ if (msg.event === 'init') {
370
+ isReady = true;
371
+ statusEl.textContent = 'Ready';
372
+ statusEl.className = 'status connected';
373
+ if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
374
+ } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
375
+ // Request SVG export, then push state with SVG
376
+ pendingSvgExport = msg.xml;
377
+ iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
378
+ // Fallback if export doesn't respond
379
+ setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
380
+ } else if (msg.event === 'export' && msg.data) {
381
+ // Handle sync export (XML format) - server requested fresh state
382
+ if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
383
+ pendingSyncExport = false;
384
+ pushState(msg.data, '');
385
+ return;
386
+ }
387
+ // Handle SVG export
388
+ let svg = msg.data;
389
+ if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
390
+ if (pendingSvgExport) {
391
+ const xml = pendingSvgExport;
392
+ pendingSvgExport = null;
393
+ pushState(xml, svg);
394
+ } else if (pendingAiSvg) {
395
+ pendingAiSvg = false;
396
+ fetch('/api/history-svg', {
397
+ method: 'POST',
398
+ headers: { 'Content-Type': 'application/json' },
399
+ body: JSON.stringify({ sessionId, svg })
400
+ }).catch(() => {});
401
+ }
260
402
  }
261
- } else if (msg.event === 'save') {
262
- // User saved - push to state
263
- if (msg.xml && msg.xml !== lastLoadedXml) {
264
- pushState(msg.xml);
265
- }
266
- } else if (msg.event === 'export') {
267
- // Export completed
268
- if (msg.data) {
269
- pushState(msg.data);
270
- }
271
- } else if (msg.event === 'autosave') {
272
- // Autosave - push to state
273
- if (msg.xml && msg.xml !== lastLoadedXml) {
274
- pushState(msg.xml);
275
- }
276
- }
277
- }
403
+ } catch {}
404
+ });
278
405
 
279
- function loadDiagram(xml) {
280
- if (!isDrawioReady) {
281
- pendingXml = xml;
282
- return;
406
+ function loadDiagram(xml, capturePreview = false) {
407
+ if (!isReady) { pendingXml = xml; return; }
408
+ lastXml = xml;
409
+ iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
410
+ if (capturePreview) {
411
+ setTimeout(() => {
412
+ pendingAiSvg = true;
413
+ iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
414
+ }, 500);
283
415
  }
284
-
285
- lastLoadedXml = xml;
286
- iframe.contentWindow.postMessage(JSON.stringify({
287
- action: 'load',
288
- xml: xml,
289
- autosave: 1
290
- }), '*');
291
416
  }
292
417
 
293
- async function pushState(xml) {
418
+ async function pushState(xml, svg = '') {
294
419
  if (!sessionId) return;
295
-
296
420
  try {
297
- const response = await fetch('/api/state', {
421
+ const r = await fetch('/api/state', {
298
422
  method: 'POST',
299
423
  headers: { 'Content-Type': 'application/json' },
300
- body: JSON.stringify({ sessionId, xml })
424
+ body: JSON.stringify({ sessionId, xml, svg })
301
425
  });
426
+ if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
427
+ } catch (e) { console.error('Push failed:', e); }
428
+ }
302
429
 
303
- if (response.ok) {
304
- const result = await response.json();
305
- currentVersion = result.version;
306
- lastLoadedXml = xml;
430
+ let pendingSyncExport = false;
431
+
432
+ async function poll() {
433
+ if (!sessionId) return;
434
+ try {
435
+ const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
436
+ if (!r.ok) return;
437
+ const s = await r.json();
438
+ // Handle sync request - server needs fresh state
439
+ if (s.syncRequested && !pendingSyncExport) {
440
+ pendingSyncExport = true;
441
+ iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
307
442
  }
308
- } catch (e) {
309
- console.error('Failed to push state:', e);
310
- }
443
+ // Load new diagram from server
444
+ if (s.version > currentVersion && s.xml) {
445
+ currentVersion = s.version;
446
+ loadDiagram(s.xml, true);
447
+ }
448
+ } catch {}
311
449
  }
312
450
 
313
- async function pollState() {
314
- if (!sessionId) return;
451
+ if (sessionId) { poll(); setInterval(poll, 2000); }
452
+
453
+ // History UI
454
+ const historyBtn = document.getElementById('history-btn');
455
+ const historyModal = document.getElementById('history-modal');
456
+ const historyGrid = document.getElementById('history-grid');
457
+ const historyEmpty = document.getElementById('history-empty');
458
+ const restoreBtn = document.getElementById('restore-btn');
459
+ const cancelBtn = document.getElementById('cancel-btn');
460
+ let historyData = [], selectedIdx = null;
315
461
 
462
+ historyBtn.onclick = async () => {
463
+ if (!sessionId) return;
316
464
  try {
317
- const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
318
- if (!response.ok) return;
465
+ const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
466
+ if (r.ok) {
467
+ const d = await r.json();
468
+ historyData = d.entries || [];
469
+ renderHistory();
470
+ }
471
+ } catch {}
472
+ historyModal.classList.add('open');
473
+ };
319
474
 
320
- const state = await response.json();
475
+ cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
476
+ historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
321
477
 
322
- if (state.version && state.version > currentVersion && state.xml) {
323
- currentVersion = state.version;
324
- loadDiagram(state.xml);
325
- }
326
- } catch (e) {
327
- console.error('Failed to poll state:', e);
478
+ function renderHistory() {
479
+ if (historyData.length === 0) {
480
+ historyGrid.style.display = 'none';
481
+ historyEmpty.style.display = 'block';
482
+ return;
328
483
  }
484
+ historyGrid.style.display = 'grid';
485
+ historyEmpty.style.display = 'none';
486
+ historyGrid.innerHTML = historyData.map((e, i) => \`
487
+ <div class="history-item" data-idx="\${e.index}">
488
+ <div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
489
+ <div class="label">#\${e.index}</div>
490
+ </div>
491
+ \`).join('');
492
+ historyGrid.querySelectorAll('.history-item').forEach(item => {
493
+ item.onclick = () => {
494
+ const idx = parseInt(item.dataset.idx);
495
+ if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
496
+ else { selectedIdx = idx; restoreBtn.disabled = false; }
497
+ historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
498
+ };
499
+ });
329
500
  }
330
501
 
331
- // Start polling if we have a session
332
- if (sessionId) {
333
- pollState();
334
- setInterval(pollState, 2000);
335
- }
502
+ restoreBtn.onclick = async () => {
503
+ if (selectedIdx === null) return;
504
+ restoreBtn.disabled = true;
505
+ restoreBtn.textContent = 'Restoring...';
506
+ try {
507
+ const r = await fetch('/api/restore', {
508
+ method: 'POST',
509
+ headers: { 'Content-Type': 'application/json' },
510
+ body: JSON.stringify({ sessionId, index: selectedIdx })
511
+ });
512
+ if (r.ok) { cancelBtn.onclick(); await poll(); }
513
+ else { alert('Restore failed'); }
514
+ } catch { alert('Restore failed'); }
515
+ restoreBtn.textContent = 'Restore';
516
+ };
336
517
  </script>
337
518
  </body>
338
519
  </html>`;