mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
* feat(mcp-server): list and resume saved diagram sessions start_session accepts an optional session_id to continue a saved diagram under the same preview URL and auto-save file, reporting the restored pages. New read-only tool list_saved_diagrams prints every auto-saved file newest first with its pages. Autosaver gains list() and dataDir(). INSTRUCTIONS and both READMEs mention the new tool. * feat(mcp-server): add restore_version tool for undo and redo from History Add the restore_version write tool so the model can put an earlier version from History back on the canvas (steps_back, default 1). The current canvas is kept in History unless it is the blank page, so a redo is possible. http-server.ts exports restoreHistoryEntry, shared by the HTTP restore endpoint and the tool; history.ts exports HistoryEntry and otherVersions (distinct versions other than the canvas, newest first). The drawing guide tells the model to call restore_version when the user asks to undo, and both READMEs document the tool. * feat(mcp-server): save the diagram History next to the auto-saved file The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files. * fix(mcp-server): review fixes for saved sessions, undo tool, history on disk * fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk - start_session resumes every saved file, also a cleared one or one of empty named pages, and decompresses pages draw.io compressed - History kept by a lost user edit or a recovering tab is saved too - A diagram cleared before its first save keeps its History on disk - The History file is written again after the 50-file cap removed it - restore_version compares versions like the edit gate (a bare mxGraphModel from the browser has no page name), checks the restore result, and says "none" when no version is left - The preview closes an open History list when the server state was recreated, since the entries got new ids - start_session's description mentions session_id * fix(mcp-server): compare History versions pairwise and tie restores to the server state Second Codex review of the History fixes: - One bare mxGraphModel in History no longer hides page renames in every comparison: names are left out only when one of the two is bare - A History list asked for before the server recreated the session is dropped, and a restore names the state its list belongs to; the server refuses one from a lost state (History ids change after a restart) * fix(mcp-server): restore from a History list with the state it was taken in Third Codex review: a restore sent the tab's current state, so an old list shown after a failed refresh passed the server's check. The list now keeps the state it came from, and the server refuses a missing (null) state too; only a tab of an older version, which sends none, is let through.
130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
/**
|
|
* Simple diagram history - matches Next.js app pattern
|
|
* Stores {xml, svg} entries in a circular buffer
|
|
*/
|
|
|
|
import { contentFingerprint } from "./edit-gate.ts"
|
|
import { log } from "./logger.ts"
|
|
import { isMxGraphModel } from "./pages.ts"
|
|
|
|
const MAX_HISTORY = 20
|
|
|
|
export interface HistoryEntry {
|
|
id: number // Stable across shifts of the circular buffer
|
|
xml: string
|
|
svg: string
|
|
}
|
|
|
|
let nextEntryId = 0
|
|
const historyStore = new Map<string, HistoryEntry[]>()
|
|
|
|
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
|
let history = historyStore.get(sessionId)
|
|
if (!history) {
|
|
history = []
|
|
historyStore.set(sessionId, history)
|
|
}
|
|
|
|
// Dedupe: skip if same as last entry (any other change, also of page
|
|
// settings only, is a new version; keepInHistory leaves out draw.io's
|
|
// copy of a server write)
|
|
const last = history[history.length - 1]
|
|
if (last && last.xml === xml) {
|
|
if (svg && !last.svg) last.svg = svg
|
|
return history.length - 1
|
|
}
|
|
|
|
history.push({ id: nextEntryId++, xml, svg })
|
|
|
|
// Circular buffer
|
|
if (history.length > MAX_HISTORY) {
|
|
history.shift()
|
|
}
|
|
|
|
log.debug(`History: session=${sessionId}, entries=${history.length}`)
|
|
return history.length - 1
|
|
}
|
|
|
|
export function getHistory(sessionId: string): HistoryEntry[] {
|
|
return historyStore.get(sessionId) || []
|
|
}
|
|
|
|
/** Look up an entry by its id; the array index shifts as old entries drop. */
|
|
export function getHistoryEntry(
|
|
sessionId: string,
|
|
id: number,
|
|
): HistoryEntry | undefined {
|
|
return historyStore.get(sessionId)?.find((entry) => entry.id === id)
|
|
}
|
|
|
|
export function clearHistory(sessionId: string): void {
|
|
historyStore.delete(sessionId)
|
|
}
|
|
|
|
/**
|
|
* The versions the diagram can go back (or forward) to: every entry whose
|
|
* content differs from currentXml, newest first, one entry per distinct
|
|
* content (its newest copy). Re-serialised copies of one diagram count as
|
|
* the same version.
|
|
*/
|
|
export function otherVersions(
|
|
sessionId: string,
|
|
currentXml: string,
|
|
): HistoryEntry[] {
|
|
const kept = [versionOf(currentXml)]
|
|
const result: HistoryEntry[] = []
|
|
const history = getHistory(sessionId)
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
const version = versionOf(history[i].xml)
|
|
if (kept.some((seen) => sameVersion(seen, version))) continue
|
|
kept.push(version)
|
|
result.push(history[i])
|
|
}
|
|
return result
|
|
}
|
|
|
|
/** True when History holds a copy of this diagram. */
|
|
export function isInHistory(sessionId: string, xml: string): boolean {
|
|
const version = versionOf(xml)
|
|
return getHistory(sessionId).some((entry) =>
|
|
sameVersion(version, versionOf(entry.xml)),
|
|
)
|
|
}
|
|
|
|
/** A diagram's fingerprints, with and without its page names */
|
|
function versionOf(xml: string) {
|
|
return {
|
|
bare: isMxGraphModel(xml),
|
|
named: contentFingerprint(xml),
|
|
cells: contentFingerprint(xml, false),
|
|
}
|
|
}
|
|
|
|
// Same rule as checkEditGate: a bare <mxGraphModel> (the browser's sync
|
|
// can send one) has no page name, so names count only when both have them
|
|
function sameVersion(
|
|
a: ReturnType<typeof versionOf>,
|
|
b: ReturnType<typeof versionOf>,
|
|
): boolean {
|
|
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
|
|
}
|
|
|
|
/**
|
|
* Give the last entry the image the browser took of shownXml, the diagram
|
|
* it just loaded, when that entry is this diagram
|
|
*/
|
|
export function updateLastHistorySvg(
|
|
sessionId: string,
|
|
svg: string,
|
|
shownXml: string,
|
|
): boolean {
|
|
const history = historyStore.get(sessionId)
|
|
if (!history || history.length === 0) return false
|
|
const last = history[history.length - 1]
|
|
if (!last.svg && last.xml === shownXml) {
|
|
last.svg = svg
|
|
return true
|
|
}
|
|
return false
|
|
}
|