mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
feat(mcp): add diagram version history
- Add history.ts module with circular buffer (max 50 entries) - Add history UI with floating button and modal - Add HTTP endpoints: /api/history, /api/restore - Add MCP tools: list_history, restore_version, get_version - Save history before and after AI changes - Track source (ai/human) for each entry
This commit is contained in:
169
packages/mcp-server/src/history.ts
Normal file
169
packages/mcp-server/src/history.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Diagram Version History for MCP Server
|
||||
*
|
||||
* Stores diagram versions in-memory per session.
|
||||
* Enables users and AI to restore previous diagram states.
|
||||
*/
|
||||
|
||||
import { log } from "./logger.js"
|
||||
|
||||
export interface HistoryEntry {
|
||||
version: number
|
||||
xml: string
|
||||
svg: string // SVG data for thumbnail preview
|
||||
source: "ai" | "human"
|
||||
tool?: string // Which tool made the change (display_diagram, edit_diagram, browser_sync)
|
||||
timestamp: Date
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface SessionHistory {
|
||||
entries: HistoryEntry[]
|
||||
nextVersion: number
|
||||
}
|
||||
|
||||
// In-memory history store keyed by session ID
|
||||
const historyStore = new Map<string, SessionHistory>()
|
||||
|
||||
// Configuration
|
||||
const MAX_HISTORY_ENTRIES = 50
|
||||
|
||||
/**
|
||||
* Add a new entry to session history
|
||||
* Returns the assigned version number
|
||||
*/
|
||||
export function addHistoryEntry(
|
||||
sessionId: string,
|
||||
entry: Omit<HistoryEntry, "version">,
|
||||
): number {
|
||||
let history = historyStore.get(sessionId)
|
||||
if (!history) {
|
||||
history = { entries: [], nextVersion: 1 }
|
||||
historyStore.set(sessionId, history)
|
||||
}
|
||||
|
||||
// Deduplicate: skip if XML is identical to last entry
|
||||
const lastEntry = history.entries[history.entries.length - 1]
|
||||
if (lastEntry && lastEntry.xml === entry.xml) {
|
||||
log.debug(`Skipping duplicate history entry for session ${sessionId}`)
|
||||
return lastEntry.version
|
||||
}
|
||||
|
||||
const version = history.nextVersion++
|
||||
const newEntry: HistoryEntry = {
|
||||
...entry,
|
||||
version,
|
||||
}
|
||||
|
||||
history.entries.push(newEntry)
|
||||
|
||||
// Prune oldest entries if over limit (circular buffer)
|
||||
if (history.entries.length > MAX_HISTORY_ENTRIES) {
|
||||
const removed = history.entries.shift()
|
||||
log.debug(`Pruned oldest history entry v${removed?.version}`)
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Added history v${version} for session ${sessionId} (source: ${entry.source}, entries: ${history.entries.length})`,
|
||||
)
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history entries for a session
|
||||
* Returns newest first, limited to specified count
|
||||
*/
|
||||
export function getHistory(
|
||||
sessionId: string,
|
||||
limit: number = 20,
|
||||
): HistoryEntry[] {
|
||||
const history = historyStore.get(sessionId)
|
||||
if (!history) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Return newest first
|
||||
return [...history.entries].reverse().slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific version from history
|
||||
*/
|
||||
export function getVersion(
|
||||
sessionId: string,
|
||||
version: number,
|
||||
): HistoryEntry | undefined {
|
||||
const history = historyStore.get(sessionId)
|
||||
if (!history) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return history.entries.find((e) => e.version === version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version number for a session
|
||||
*/
|
||||
export function getLatestVersion(sessionId: string): number {
|
||||
const history = historyStore.get(sessionId)
|
||||
if (!history || history.entries.length === 0) {
|
||||
return 0
|
||||
}
|
||||
return history.entries[history.entries.length - 1].version
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history for a session (used on session expiry)
|
||||
*/
|
||||
export function clearHistory(sessionId: string): void {
|
||||
if (historyStore.has(sessionId)) {
|
||||
historyStore.delete(sessionId)
|
||||
log.info(`Cleared history for session ${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the SVG of the latest entry (or specific version) that has empty SVG
|
||||
* Used when browser generates SVG after loading AI diagram
|
||||
*/
|
||||
export function updateLatestEntrySvg(
|
||||
sessionId: string,
|
||||
svg: string,
|
||||
targetVersion?: number,
|
||||
): boolean {
|
||||
const history = historyStore.get(sessionId)
|
||||
if (!history || history.entries.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find entry to update - either specific version or latest without SVG
|
||||
let entry: HistoryEntry | undefined
|
||||
if (targetVersion !== undefined) {
|
||||
entry = history.entries.find((e) => e.version === targetVersion)
|
||||
} else {
|
||||
// Find most recent entry without SVG
|
||||
for (let i = history.entries.length - 1; i >= 0; i--) {
|
||||
if (!history.entries[i].svg) {
|
||||
entry = history.entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry && !entry.svg) {
|
||||
entry.svg = svg
|
||||
log.debug(`Updated SVG for history v${entry.version}`)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of history entries for a session
|
||||
*/
|
||||
export function getHistoryCount(sessionId: string): number {
|
||||
const history = historyStore.get(sessionId)
|
||||
return history?.entries.length || 0
|
||||
}
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
|
||||
import http from "node:http"
|
||||
import {
|
||||
addHistoryEntry,
|
||||
clearHistory,
|
||||
getHistory,
|
||||
getVersion,
|
||||
updateLatestEntrySvg,
|
||||
} from "./history.js"
|
||||
import { log } from "./logger.js"
|
||||
|
||||
interface SessionState {
|
||||
@@ -105,6 +112,7 @@ function cleanupExpiredSessions(): void {
|
||||
for (const [sessionId, state] of stateStore) {
|
||||
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
||||
stateStore.delete(sessionId)
|
||||
clearHistory(sessionId) // Also clean up history
|
||||
log.info(`Cleaned up expired session: ${sessionId}`)
|
||||
}
|
||||
}
|
||||
@@ -148,12 +156,27 @@ function handleRequest(
|
||||
url.pathname === "/api/mcp/state"
|
||||
) {
|
||||
handleStateApi(req, res, url)
|
||||
} else if (
|
||||
url.pathname === "/api/history" ||
|
||||
url.pathname === "/api/mcp/history"
|
||||
) {
|
||||
handleHistoryApi(req, res, url)
|
||||
} else if (
|
||||
url.pathname === "/api/restore" ||
|
||||
url.pathname === "/api/mcp/restore"
|
||||
) {
|
||||
handleRestoreApi(req, res, url)
|
||||
} else if (
|
||||
url.pathname === "/api/health" ||
|
||||
url.pathname === "/api/mcp/health"
|
||||
) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ status: "ok", mcp: true }))
|
||||
} else if (
|
||||
url.pathname === "/api/update-svg" ||
|
||||
url.pathname === "/api/mcp/update-svg"
|
||||
) {
|
||||
handleUpdateSvgApi(req, res)
|
||||
} else {
|
||||
res.writeHead(404)
|
||||
res.end("Not Found")
|
||||
@@ -206,14 +229,28 @@ function handleStateApi(
|
||||
})
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const { sessionId, xml } = JSON.parse(body)
|
||||
const { sessionId, xml, svg } = JSON.parse(body)
|
||||
if (!sessionId) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||
return
|
||||
}
|
||||
|
||||
// Update state
|
||||
const version = setState(sessionId, xml)
|
||||
|
||||
// Save to history when browser sends SVG (human edits)
|
||||
if (svg) {
|
||||
addHistoryEntry(sessionId, {
|
||||
xml,
|
||||
svg,
|
||||
source: "human",
|
||||
tool: "browser_sync",
|
||||
timestamp: new Date(),
|
||||
description: "Manual edit",
|
||||
})
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true, version }))
|
||||
} catch {
|
||||
@@ -227,6 +264,153 @@ function handleStateApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle history API requests
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
const limit = parseInt(url.searchParams.get("limit") || "20")
|
||||
const history = getHistory(sessionId, limit)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
entries: history.map((entry) => ({
|
||||
version: entry.version,
|
||||
source: entry.source,
|
||||
tool: entry.tool,
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
description: entry.description,
|
||||
svg: entry.svg,
|
||||
// Don't include full XML in list - use get_version for that
|
||||
})),
|
||||
count: history.length,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update-svg API requests (browser sends SVG after loading AI diagram)
|
||||
*/
|
||||
function handleUpdateSvgApi(
|
||||
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, version } = JSON.parse(body)
|
||||
if (!sessionId || !svg) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "sessionId and svg required" }))
|
||||
return
|
||||
}
|
||||
|
||||
// Update the latest AI entry's SVG
|
||||
const updated = updateLatestEntrySvg(sessionId, svg, version)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true, updated }))
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle restore API requests
|
||||
*/
|
||||
function handleRestoreApi(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
url: URL,
|
||||
): 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, version } = JSON.parse(body)
|
||||
if (!sessionId || version === undefined) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({ error: "sessionId and version required" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const entry = getVersion(sessionId, version)
|
||||
if (!entry) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "Version not found" }))
|
||||
return
|
||||
}
|
||||
|
||||
// Restore by setting state (this will trigger browser poll to load it)
|
||||
const newVersion = setState(sessionId, entry.xml)
|
||||
|
||||
// Add history entry for the restore action
|
||||
addHistoryEntry(sessionId, {
|
||||
xml: entry.xml,
|
||||
svg: entry.svg,
|
||||
source: "human",
|
||||
tool: "restore",
|
||||
timestamp: new Date(),
|
||||
description: `Restored from v${version}`,
|
||||
})
|
||||
|
||||
log.info(
|
||||
`Restored session ${sessionId} to v${version}, new version: ${newVersion}`,
|
||||
)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
restoredFrom: version,
|
||||
newVersion,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the HTML page with draw.io embed
|
||||
*/
|
||||
@@ -256,6 +440,163 @@ function getHtmlPage(sessionId: string): string {
|
||||
#header .status.connected { color: #4ade80; }
|
||||
#header .status.disconnected { color: #f87171; }
|
||||
#drawio { flex: 1; border: none; }
|
||||
|
||||
/* History button */
|
||||
#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;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
z-index: 1000;
|
||||
}
|
||||
#history-btn:hover { background: #2563eb; transform: scale(1.1); }
|
||||
#history-btn:disabled { background: #6b7280; cursor: not-allowed; transform: none; }
|
||||
#history-btn svg { width: 24px; height: 24px; }
|
||||
#history-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
#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 */
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.modal-header h2 { font-size: 18px; margin: 0 0 4px 0; }
|
||||
.modal-header p { font-size: 13px; color: #6b7280; margin: 0; }
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.modal-footer .info { flex: 1; font-size: 13px; color: #6b7280; }
|
||||
|
||||
/* Grid of history items */
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.history-item {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.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: 16/9;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.history-item .thumb img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.history-item .thumb.no-preview {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
.history-item .meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.history-item .version { font-weight: 600; color: #374151; }
|
||||
.history-item .badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.history-item .badge.ai { background: #dbeafe; color: #1d4ed8; }
|
||||
.history-item .badge.human { background: #dcfce7; color: #166534; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
.btn-secondary:hover { background: #e5e7eb; }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -270,6 +611,36 @@ function getHtmlPage(sessionId: string): string {
|
||||
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- History floating button -->
|
||||
<button id="history-btn" title="View diagram history" ${sessionId ? "" : "disabled"}>
|
||||
<svg xmlns="http://www.w3.org/2000/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>
|
||||
<span id="history-badge" style="display: none;">0</span>
|
||||
</button>
|
||||
|
||||
<!-- History modal -->
|
||||
<div id="history-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Diagram History</h2>
|
||||
<p>Click on a version to restore it</p>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="history-grid" class="history-grid"></div>
|
||||
<div id="history-empty" class="empty-state" style="display: none;">
|
||||
No history available yet.<br>Make some changes to create history.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="info" id="restore-info"></div>
|
||||
<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>
|
||||
const sessionId = "${sessionId}";
|
||||
const iframe = document.getElementById('drawio');
|
||||
@@ -279,6 +650,7 @@ function getHtmlPage(sessionId: string): string {
|
||||
let isDrawioReady = false;
|
||||
let pendingXml = null;
|
||||
let lastLoadedXml = null;
|
||||
let pendingSvgExport = null; // For capturing SVG during save
|
||||
|
||||
// Listen for messages from draw.io
|
||||
window.addEventListener('message', (event) => {
|
||||
@@ -304,24 +676,53 @@ function getHtmlPage(sessionId: string): string {
|
||||
pendingXml = null;
|
||||
}
|
||||
} else if (msg.event === 'save') {
|
||||
// User saved - push to state
|
||||
// User saved - request SVG export then push state
|
||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
||||
pushState(msg.xml);
|
||||
requestSvgAndPushState(msg.xml);
|
||||
}
|
||||
} else if (msg.event === 'export') {
|
||||
// Export completed
|
||||
if (msg.data) {
|
||||
console.log('Export event received:', { format: msg.format, dataLength: msg.data?.length, pendingSvgForHistory, hasPendingSvgExport: !!pendingSvgExport });
|
||||
// Export completed - check if this is for SVG capture
|
||||
if (pendingSvgForHistory && msg.data) {
|
||||
// SVG export for history preview (after AI load)
|
||||
console.log('Updating history SVG, data preview:', msg.data?.substring(0, 100));
|
||||
updateHistorySvg(msg.data);
|
||||
pendingSvgForHistory = false;
|
||||
} else if (pendingSvgExport && msg.data) {
|
||||
const svgData = msg.data; // This is the SVG data
|
||||
pushStateWithSvg(pendingSvgExport.xml, svgData);
|
||||
pendingSvgExport = null;
|
||||
} else if (msg.data) {
|
||||
pushState(msg.data);
|
||||
}
|
||||
} else if (msg.event === 'autosave') {
|
||||
// Autosave - push to state
|
||||
// Autosave - request SVG export then push state
|
||||
if (msg.xml && msg.xml !== lastLoadedXml) {
|
||||
pushState(msg.xml);
|
||||
requestSvgAndPushState(msg.xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadDiagram(xml) {
|
||||
// Request SVG export before pushing state
|
||||
function requestSvgAndPushState(xml) {
|
||||
pendingSvgExport = { xml };
|
||||
iframe.contentWindow.postMessage(JSON.stringify({
|
||||
action: 'export',
|
||||
format: 'svg',
|
||||
spin: 'Exporting...'
|
||||
}), '*');
|
||||
|
||||
// Fallback: if export doesn't respond in 2s, push without SVG
|
||||
setTimeout(() => {
|
||||
if (pendingSvgExport && pendingSvgExport.xml === xml) {
|
||||
console.log('SVG export timeout, pushing without SVG');
|
||||
pushState(xml);
|
||||
pendingSvgExport = null;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function loadDiagram(xml, fromAi = false) {
|
||||
if (!isDrawioReady) {
|
||||
pendingXml = xml;
|
||||
return;
|
||||
@@ -333,16 +734,32 @@ function getHtmlPage(sessionId: string): string {
|
||||
xml: xml,
|
||||
autosave: 1
|
||||
}), '*');
|
||||
|
||||
// If loaded from AI, export SVG to update history preview
|
||||
if (fromAi) {
|
||||
console.log('Loaded from AI, scheduling SVG export in 500ms');
|
||||
setTimeout(() => {
|
||||
console.log('Requesting SVG export for history');
|
||||
pendingSvgForHistory = true;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({
|
||||
action: 'export',
|
||||
format: 'svg',
|
||||
spin: 'Generating preview...'
|
||||
}), '*');
|
||||
}, 500); // Small delay to let diagram render
|
||||
}
|
||||
}
|
||||
|
||||
async function pushState(xml) {
|
||||
let pendingSvgForHistory = false;
|
||||
|
||||
async function pushState(xml, svg = '') {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml })
|
||||
body: JSON.stringify({ sessionId, xml, svg })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -355,6 +772,39 @@ function getHtmlPage(sessionId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushStateWithSvg(xml, svgData) {
|
||||
// Convert SVG data to data URL if needed
|
||||
let svg = svgData;
|
||||
if (svgData && !svgData.startsWith('data:')) {
|
||||
svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
||||
}
|
||||
await pushState(xml, svg);
|
||||
}
|
||||
|
||||
// Update history entry SVG (for AI-generated diagrams)
|
||||
async function updateHistorySvg(svgData) {
|
||||
if (!sessionId) return;
|
||||
|
||||
let svg = svgData;
|
||||
if (svgData && !svgData.startsWith('data:')) {
|
||||
svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
||||
}
|
||||
|
||||
console.log('Sending SVG to /api/update-svg, length:', svg?.length);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/update-svg', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, svg })
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log('Update SVG response:', result);
|
||||
} catch (e) {
|
||||
console.error('Failed to update history SVG:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollState() {
|
||||
if (!sessionId) return;
|
||||
|
||||
@@ -366,7 +816,8 @@ function getHtmlPage(sessionId: string): string {
|
||||
|
||||
if (state.version && state.version > currentVersion && state.xml) {
|
||||
currentVersion = state.version;
|
||||
loadDiagram(state.xml);
|
||||
// Load from AI (server push) - generate SVG for history
|
||||
loadDiagram(state.xml, true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to poll state:', e);
|
||||
@@ -378,6 +829,156 @@ function getHtmlPage(sessionId: string): string {
|
||||
pollState();
|
||||
setInterval(pollState, 2000);
|
||||
}
|
||||
|
||||
// ============ History UI ============
|
||||
const historyBtn = document.getElementById('history-btn');
|
||||
const historyBadge = document.getElementById('history-badge');
|
||||
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');
|
||||
const restoreInfo = document.getElementById('restore-info');
|
||||
|
||||
let historyData = [];
|
||||
let selectedVersion = null;
|
||||
|
||||
// Open modal
|
||||
historyBtn.addEventListener('click', async () => {
|
||||
if (!sessionId) return;
|
||||
await fetchHistory();
|
||||
historyModal.classList.add('open');
|
||||
});
|
||||
|
||||
// Close modal
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
historyModal.addEventListener('click', (e) => {
|
||||
if (e.target === historyModal) closeModal();
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
historyModal.classList.remove('open');
|
||||
selectedVersion = null;
|
||||
restoreBtn.disabled = true;
|
||||
restoreInfo.textContent = '';
|
||||
}
|
||||
|
||||
// Fetch history from API
|
||||
async function fetchHistory() {
|
||||
try {
|
||||
const response = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
historyData = data.entries || [];
|
||||
renderHistory();
|
||||
updateBadge();
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update badge count
|
||||
function updateBadge() {
|
||||
if (historyData.length > 0) {
|
||||
historyBadge.textContent = historyData.length;
|
||||
historyBadge.style.display = 'block';
|
||||
} else {
|
||||
historyBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Render history grid
|
||||
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(entry => {
|
||||
const hasSvg = entry.svg && entry.svg.length > 0;
|
||||
return \`
|
||||
<div class="history-item" data-version="\${entry.version}">
|
||||
<div class="thumb \${hasSvg ? '' : 'no-preview'}">
|
||||
\${hasSvg
|
||||
? \`<img src="\${entry.svg}" alt="Version \${entry.version}">\`
|
||||
: 'No preview'
|
||||
}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="version">v\${entry.version}</span>
|
||||
<span class="badge \${entry.source}">\${entry.source === 'ai' ? 'AI' : 'You'}</span>
|
||||
</div>
|
||||
</div>
|
||||
\`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers
|
||||
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
||||
item.addEventListener('click', () => selectVersion(parseInt(item.dataset.version)));
|
||||
});
|
||||
}
|
||||
|
||||
// Select a version
|
||||
function selectVersion(version) {
|
||||
// Toggle selection
|
||||
if (selectedVersion === version) {
|
||||
selectedVersion = null;
|
||||
restoreBtn.disabled = true;
|
||||
restoreInfo.textContent = '';
|
||||
} else {
|
||||
selectedVersion = version;
|
||||
restoreBtn.disabled = false;
|
||||
const entry = historyData.find(e => e.version === version);
|
||||
restoreInfo.textContent = \`Restore to v\${version}? (\${entry?.source === 'ai' ? 'AI' : 'Your'} edit)\`;
|
||||
}
|
||||
|
||||
// Update selection UI
|
||||
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
||||
item.classList.toggle('selected', parseInt(item.dataset.version) === selectedVersion);
|
||||
});
|
||||
}
|
||||
|
||||
// Restore selected version
|
||||
restoreBtn.addEventListener('click', async () => {
|
||||
if (selectedVersion === null) return;
|
||||
|
||||
try {
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = 'Restoring...';
|
||||
|
||||
const response = await fetch('/api/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, version: selectedVersion })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeModal();
|
||||
// Poll will pick up the new state
|
||||
await pollState();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('Failed to restore: ' + (error.error || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore:', e);
|
||||
alert('Failed to restore version');
|
||||
} finally {
|
||||
restoreBtn.textContent = 'Restore';
|
||||
restoreBtn.disabled = selectedVersion === null;
|
||||
}
|
||||
});
|
||||
|
||||
// Periodically update badge (every 10s)
|
||||
if (sessionId) {
|
||||
setInterval(fetchHistory, 10000);
|
||||
fetchHistory(); // Initial fetch
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
@@ -34,6 +34,12 @@ import {
|
||||
applyDiagramOperations,
|
||||
type DiagramOperation,
|
||||
} from "./diagram-operations.js"
|
||||
import {
|
||||
addHistoryEntry,
|
||||
getHistory,
|
||||
getHistoryCount,
|
||||
getVersion as getHistoryVersion,
|
||||
} from "./history.js"
|
||||
import {
|
||||
getServerPort,
|
||||
getState,
|
||||
@@ -197,6 +203,21 @@ server.registerTool(
|
||||
|
||||
log.info(`Displaying diagram, ${xml.length} chars`)
|
||||
|
||||
// 1. Save current state to history BEFORE replacing (preserve user's work)
|
||||
if (currentSession.xml) {
|
||||
// Check last entry's source to use correct label
|
||||
const lastEntry = getHistory(currentSession.id, 1)[0]
|
||||
const actualSource = lastEntry?.source || "human"
|
||||
addHistoryEntry(currentSession.id, {
|
||||
xml: currentSession.xml,
|
||||
svg: "",
|
||||
source: actualSource,
|
||||
tool: "display_diagram",
|
||||
timestamp: new Date(),
|
||||
description: "Before AI replaced",
|
||||
})
|
||||
}
|
||||
|
||||
// Update session state
|
||||
currentSession.xml = xml
|
||||
currentSession.version++
|
||||
@@ -204,6 +225,16 @@ server.registerTool(
|
||||
// Push to embedded server state
|
||||
setState(currentSession.id, xml)
|
||||
|
||||
// 2. Save new state to history AFTER generation (capture AI result)
|
||||
addHistoryEntry(currentSession.id, {
|
||||
xml: xml,
|
||||
svg: "",
|
||||
source: "ai",
|
||||
tool: "display_diagram",
|
||||
timestamp: new Date(),
|
||||
description: "AI generated diagram",
|
||||
})
|
||||
|
||||
log.info(`Diagram displayed successfully`)
|
||||
|
||||
return {
|
||||
@@ -295,6 +326,19 @@ server.registerTool(
|
||||
|
||||
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
||||
|
||||
// 1. Save current state to history BEFORE editing (preserve user's work)
|
||||
// Check last entry's source to use correct label
|
||||
const lastEntry = getHistory(currentSession.id, 1)[0]
|
||||
const actualSource = lastEntry?.source || "human"
|
||||
addHistoryEntry(currentSession.id, {
|
||||
xml: currentSession.xml,
|
||||
svg: "",
|
||||
source: actualSource,
|
||||
tool: "edit_diagram",
|
||||
timestamp: new Date(),
|
||||
description: "Before AI edit",
|
||||
})
|
||||
|
||||
// Validate and auto-fix new_xml for each operation
|
||||
const validatedOps = operations.map((op) => {
|
||||
if (op.new_xml) {
|
||||
@@ -336,6 +380,16 @@ server.registerTool(
|
||||
// Push to embedded server
|
||||
setState(currentSession.id, result)
|
||||
|
||||
// 2. Save new state to history AFTER editing (capture AI result)
|
||||
addHistoryEntry(currentSession.id, {
|
||||
xml: result,
|
||||
svg: "",
|
||||
source: "ai",
|
||||
tool: "edit_diagram",
|
||||
timestamp: new Date(),
|
||||
description: `AI edit: ${operations.length} operation(s)`,
|
||||
})
|
||||
|
||||
log.info(`Diagram edited successfully`)
|
||||
|
||||
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
||||
@@ -502,6 +556,216 @@ server.registerTool(
|
||||
},
|
||||
)
|
||||
|
||||
// Tool: list_history
|
||||
server.registerTool(
|
||||
"list_history",
|
||||
{
|
||||
description:
|
||||
"List diagram version history for the current session. " +
|
||||
"Shows version numbers, who made each change (AI vs human), and timestamps. " +
|
||||
"Use this to find a version to restore.",
|
||||
inputSchema: {
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum number of entries to return (default: 20)"),
|
||||
},
|
||||
},
|
||||
async ({ limit = 20 }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const history = getHistory(currentSession.id, limit)
|
||||
|
||||
if (history.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No history available yet. Make some changes to create history.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const historyText = history
|
||||
.map((entry) => {
|
||||
const time = entry.timestamp.toLocaleTimeString()
|
||||
const source = entry.source === "ai" ? "AI" : "Human"
|
||||
const desc = entry.description
|
||||
? ` - ${entry.description}`
|
||||
: ""
|
||||
return `v${entry.version} [${source}] ${time}${desc}`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram History (${history.length} entries, newest first):\n\n${historyText}\n\nUse restore_version to restore a specific version.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
log.error("list_history failed:", message)
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Tool: restore_version
|
||||
server.registerTool(
|
||||
"restore_version",
|
||||
{
|
||||
description:
|
||||
"Restore diagram to a previous version from history. " +
|
||||
"Use list_history first to see available versions. " +
|
||||
"This creates a NEW history entry (non-destructive).",
|
||||
inputSchema: {
|
||||
version: z.number().describe("Version number to restore"),
|
||||
},
|
||||
},
|
||||
async ({ version }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const entry = getHistoryVersion(currentSession.id, version)
|
||||
if (!entry) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: Version ${version} not found in history. Use list_history to see available versions.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Restore by updating session and state
|
||||
currentSession.xml = entry.xml
|
||||
currentSession.version++
|
||||
setState(currentSession.id, entry.xml)
|
||||
|
||||
// Add history entry for the restore
|
||||
addHistoryEntry(currentSession.id, {
|
||||
xml: entry.xml,
|
||||
svg: entry.svg,
|
||||
source: "ai",
|
||||
tool: "restore_version",
|
||||
timestamp: new Date(),
|
||||
description: `Restored from v${version}`,
|
||||
})
|
||||
|
||||
log.info(`Restored diagram to v${version}`)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram restored to version ${version} successfully!\n\nThe browser will update automatically.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
log.error("restore_version failed:", message)
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Tool: get_version
|
||||
server.registerTool(
|
||||
"get_version",
|
||||
{
|
||||
description:
|
||||
"Get the XML content of a specific version from history. " +
|
||||
"Use this to inspect what a previous version looked like before restoring.",
|
||||
inputSchema: {
|
||||
version: z.number().describe("Version number to retrieve"),
|
||||
},
|
||||
},
|
||||
async ({ version }) => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const entry = getHistoryVersion(currentSession.id, version)
|
||||
if (!entry) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: Version ${version} not found in history.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const source = entry.source === "ai" ? "AI" : "Human"
|
||||
const time = entry.timestamp.toISOString()
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Version ${version} (${source} edit at ${time}):\n\n${entry.xml}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
log.error("get_version failed:", message)
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Start the MCP server
|
||||
async function main() {
|
||||
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
|
||||
|
||||
Reference in New Issue
Block a user