mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
feat(mcp-server): history entries name their maker, and /api/history the entry on the canvas
Each History entry records who made it (a user edit, a recovering tab's copy, a restored copy; none for the AI's writes), when, and its page count. GET /api/history now also returns each entry's XML, the first entry with the same content (a restore adds a copy of an older one), the state the ids belong to, and the entry whose content the canvas shows, by the rule restore_version uses. The shell's version cards read these; the classic page keeps using index, id and svg.
This commit is contained in:
@@ -5,20 +5,44 @@
|
||||
|
||||
import { contentFingerprint } from "./edit-gate.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { isMxGraphModel } from "./pages.ts"
|
||||
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.ts"
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
/**
|
||||
* Who made an entry's diagram: an edit in the browser ("user"), a tab's
|
||||
* copy kept when the server recovered the session ("recover"), a restored
|
||||
* copy of an older entry ("restore"). The AI's writes and entries loaded
|
||||
* from the saved file carry none.
|
||||
*/
|
||||
export type HistorySource = "user" | "recover" | "restore"
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: number // Stable across shifts of the circular buffer
|
||||
xml: string
|
||||
svg: string
|
||||
source?: HistorySource
|
||||
/** When it was added, in ms since the epoch */
|
||||
time: number
|
||||
/** Pages in the document (0 when it has none) */
|
||||
pages: number
|
||||
}
|
||||
|
||||
let nextEntryId = 0
|
||||
const historyStore = new Map<string, HistoryEntry[]>()
|
||||
|
||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
function countPages(xml: string): number {
|
||||
const normalized = normalizeToMxfile(xml)
|
||||
const doc = normalized ? parseMxfile(normalized) : null
|
||||
return doc ? doc.querySelectorAll("diagram").length : 0
|
||||
}
|
||||
|
||||
export function addHistory(
|
||||
sessionId: string,
|
||||
xml: string,
|
||||
svg = "",
|
||||
source?: HistorySource,
|
||||
): number {
|
||||
let history = historyStore.get(sessionId)
|
||||
if (!history) {
|
||||
history = []
|
||||
@@ -34,7 +58,14 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
return history.length - 1
|
||||
}
|
||||
|
||||
history.push({ id: nextEntryId++, xml, svg })
|
||||
history.push({
|
||||
id: nextEntryId++,
|
||||
xml,
|
||||
svg,
|
||||
...(source && { source }),
|
||||
time: Date.now(),
|
||||
pages: countPages(xml),
|
||||
})
|
||||
|
||||
// Circular buffer
|
||||
if (history.length > MAX_HISTORY) {
|
||||
@@ -75,7 +106,7 @@ export function otherVersions(
|
||||
const result: HistoryEntry[] = []
|
||||
const history = getHistory(sessionId)
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const version = versionOf(history[i].xml)
|
||||
const version = entryVersion(history[i])
|
||||
if (kept.some((seen) => sameVersion(seen, version))) continue
|
||||
kept.push(version)
|
||||
result.push(history[i])
|
||||
@@ -87,12 +118,49 @@ export function otherVersions(
|
||||
export function isInHistory(sessionId: string, xml: string): boolean {
|
||||
const version = versionOf(xml)
|
||||
return getHistory(sessionId).some((entry) =>
|
||||
sameVersion(version, versionOf(entry.xml)),
|
||||
sameVersion(version, entryVersion(entry)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry the canvas shows: the newest one with the content of xml (the
|
||||
* rule of otherVersions), or null when the canvas is in none of them
|
||||
*/
|
||||
export function currentEntryId(sessionId: string, xml: string): number | null {
|
||||
const version = versionOf(xml)
|
||||
const history = getHistory(sessionId)
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (sameVersion(version, entryVersion(history[i]))) {
|
||||
return history[i].id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* For every entry, the id of the oldest entry with the same content (its
|
||||
* own when no earlier one has it). A restore adds a copy of an older entry;
|
||||
* the shell shows one version per content, where its first copy is.
|
||||
*/
|
||||
export function firstCopyIds(sessionId: string): Map<number, number> {
|
||||
const history = getHistory(sessionId)
|
||||
const versions = history.map(entryVersion)
|
||||
const result = new Map<number, number>()
|
||||
history.forEach((entry, i) => {
|
||||
const first = versions.findIndex((v) => sameVersion(v, versions[i]))
|
||||
result.set(entry.id, history[first].id)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
interface Version {
|
||||
bare: boolean
|
||||
named: string
|
||||
cells: string
|
||||
}
|
||||
|
||||
/** A diagram's fingerprints, with and without its page names */
|
||||
function versionOf(xml: string) {
|
||||
function versionOf(xml: string): Version {
|
||||
return {
|
||||
bare: isMxGraphModel(xml),
|
||||
named: contentFingerprint(xml),
|
||||
@@ -100,12 +168,20 @@ function versionOf(xml: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// An entry's XML never changes: its fingerprints are computed once
|
||||
const entryVersions = new WeakMap<HistoryEntry, Version>()
|
||||
function entryVersion(entry: HistoryEntry): Version {
|
||||
let version = entryVersions.get(entry)
|
||||
if (!version) {
|
||||
version = versionOf(entry.xml)
|
||||
entryVersions.set(entry, version)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// 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 {
|
||||
function sameVersion(a: Version, b: Version): boolean {
|
||||
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ import { contentFingerprint } from "./edit-gate.ts"
|
||||
import {
|
||||
addHistory,
|
||||
clearHistory,
|
||||
currentEntryId,
|
||||
firstCopyIds,
|
||||
getHistory,
|
||||
getHistoryEntry,
|
||||
updateLastHistorySvg,
|
||||
@@ -335,7 +337,7 @@ export function keepInHistory(sessionId: string, xml: string, svg = ""): void {
|
||||
if (state && !state.userEdited && last && last.xml === state.serverXml) {
|
||||
return
|
||||
}
|
||||
addHistory(sessionId, xml, svg)
|
||||
addHistory(sessionId, xml, svg, state?.userEdited ? "user" : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -741,7 +743,12 @@ function handleStateApi(
|
||||
!!data.xml &&
|
||||
data.xml !== current.xml
|
||||
if (saved) {
|
||||
addHistory(sessionId, data.xml, data.svg || "")
|
||||
addHistory(
|
||||
sessionId,
|
||||
data.xml,
|
||||
data.svg || "",
|
||||
"recover",
|
||||
)
|
||||
// Saved with the History, as after any change
|
||||
stateListener?.(sessionId, current.xml)
|
||||
}
|
||||
@@ -778,7 +785,7 @@ function handleStateApi(
|
||||
} else if (typeof data.xml === "string" && data.xml) {
|
||||
// A user edit lost the race with an AI write. Keep
|
||||
// it in history so the user can restore it.
|
||||
addHistory(sessionId, data.xml, data.svg || "")
|
||||
addHistory(sessionId, data.xml, data.svg || "", "user")
|
||||
savedToHistory = true
|
||||
if (current) stateListener?.(sessionId, current.xml)
|
||||
}
|
||||
@@ -832,7 +839,12 @@ function handleHistoryApi(
|
||||
return
|
||||
}
|
||||
|
||||
// The classic page reads index, id and svg; the shell's version cards
|
||||
// the rest: the document, who made it, which earlier entry it is a copy
|
||||
// of, the state the ids belong to, and the entry the canvas shows
|
||||
const history = getHistory(sessionId)
|
||||
const firstIds = firstCopyIds(sessionId)
|
||||
const state = stateStore.get(sessionId)
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
@@ -840,8 +852,15 @@ function handleHistoryApi(
|
||||
index: i,
|
||||
id: entry.id,
|
||||
svg: entry.svg,
|
||||
xml: entry.xml,
|
||||
source: entry.source ?? null,
|
||||
time: entry.time,
|
||||
pages: entry.pages,
|
||||
firstId: firstIds.get(entry.id),
|
||||
})),
|
||||
count: history.length,
|
||||
stateId: state?.stateId ?? null,
|
||||
currentId: state ? currentEntryId(sessionId, state.xml) : null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -870,7 +889,7 @@ export function restoreHistoryEntry(
|
||||
keepInHistory(sessionId, current.xml, current.svg)
|
||||
}
|
||||
const newVersion = setState(sessionId, entry.xml)
|
||||
addHistory(sessionId, entry.xml, entry.svg)
|
||||
addHistory(sessionId, entry.xml, entry.svg, "restore")
|
||||
|
||||
log.info(`Restored session ${sessionId} to history entry ${entryId}`)
|
||||
return newVersion
|
||||
|
||||
@@ -7,6 +7,8 @@ import { installDomPolyfill } from "../src/dom.ts"
|
||||
import {
|
||||
addHistory,
|
||||
clearHistory,
|
||||
currentEntryId,
|
||||
firstCopyIds,
|
||||
getHistory,
|
||||
isInHistory,
|
||||
otherVersions,
|
||||
@@ -116,3 +118,80 @@ describe("otherVersions", () => {
|
||||
expect(otherVersions(id, A)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("entries", () => {
|
||||
it("record who made them, when, and their page count", () => {
|
||||
const id = "history-fields"
|
||||
clearHistory(id)
|
||||
const before = Date.now()
|
||||
addHistory(id, A)
|
||||
addHistory(id, B, "<svg/>", "user")
|
||||
addHistory(
|
||||
id,
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/></root></mxGraphModel></diagram><diagram id="q" name="Q"><mxGraphModel><root><mxCell id="0"/></root></mxGraphModel></diagram></mxfile>`,
|
||||
"",
|
||||
"restore",
|
||||
)
|
||||
const [first, second, third] = getHistory(id)
|
||||
expect(first.source).toBeUndefined()
|
||||
expect(first.time).toBeGreaterThanOrEqual(before)
|
||||
expect(first.pages).toBe(1)
|
||||
expect(second.source).toBe("user")
|
||||
expect(second.svg).toBe("<svg/>")
|
||||
expect(third.source).toBe("restore")
|
||||
expect(third.pages).toBe(2)
|
||||
// A bare model is one page; text that is no diagram has none
|
||||
addHistory(id, "<mxGraphModel><root/></mxGraphModel>")
|
||||
addHistory(id, "not a diagram")
|
||||
expect(
|
||||
getHistory(id)
|
||||
.slice(-2)
|
||||
.map((e) => e.pages),
|
||||
).toEqual([1, 0])
|
||||
})
|
||||
})
|
||||
|
||||
// The shell's version cards: which entry the canvas shows, and which
|
||||
// entries are copies of an earlier one (a restore adds one)
|
||||
describe("currentEntryId and firstCopyIds", () => {
|
||||
it("name the newest entry with the canvas's content, by the rule of otherVersions", () => {
|
||||
const id = "history-current"
|
||||
fill(id, [A, B, C, A])
|
||||
const [a, b, c, aCopy] = getHistory(id)
|
||||
expect(currentEntryId(id, A)).toBe(aCopy.id)
|
||||
expect(currentEntryId(id, B_COPY)).toBe(b.id)
|
||||
expect(currentEntryId(id, page("manual"))).toBeNull()
|
||||
// A restore's copy belongs with its first entry; the others are
|
||||
// their own
|
||||
expect([...firstCopyIds(id).entries()]).toEqual([
|
||||
[a.id, a.id],
|
||||
[b.id, b.id],
|
||||
[c.id, c.id],
|
||||
[aCopy.id, a.id],
|
||||
])
|
||||
})
|
||||
|
||||
it("agree with otherVersions on a page setting that only the canvas shows", () => {
|
||||
// The same cells with another background: one version to the MCP
|
||||
// core (the model's edits stay valid when the user changes a page
|
||||
// setting), so the canvas still shows that entry and restore_version
|
||||
// offers no other copy of it
|
||||
const id = "history-background"
|
||||
const withBackground = A.replace(
|
||||
"<mxGraphModel>",
|
||||
'<mxGraphModel background="#ffcc00" shadow="1">',
|
||||
)
|
||||
fill(id, [A, B])
|
||||
const [a] = getHistory(id)
|
||||
expect(currentEntryId(id, withBackground)).toBe(a.id)
|
||||
expect(otherVersions(id, withBackground).map((e) => e.xml)).toEqual([B])
|
||||
expect(isInHistory(id, withBackground)).toBe(true)
|
||||
})
|
||||
|
||||
it("return nothing for an empty history", () => {
|
||||
const id = "history-current-empty"
|
||||
clearHistory(id)
|
||||
expect(currentEntryId(id, A)).toBeNull()
|
||||
expect(firstCopyIds(id).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1097,6 +1097,123 @@ describe("history restore", () => {
|
||||
// The missing image is filled in
|
||||
expect(getHistory(id)[0].svg).toBe("SVG")
|
||||
})
|
||||
})
|
||||
|
||||
// What the shell's version cards read (the classic page uses index, id
|
||||
// and svg of the same list)
|
||||
describe("GET /api/history for the version cards", () => {
|
||||
const page = (cellId: string) =>
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
it("lists each entry's document, maker and first copy, with the state and the entry on the canvas", async () => {
|
||||
const id = "mcp-history-cards"
|
||||
addHistory(id, page("a"))
|
||||
addHistory(id, page("b"), "SVG-B", "user")
|
||||
setState(id, page("b"))
|
||||
const res = await request(`/api/history?sessionId=${id}`)
|
||||
expect(res.status).toBe(200)
|
||||
const d = JSON.parse(res.body)
|
||||
const [a, b] = getHistory(id)
|
||||
expect(d.count).toBe(2)
|
||||
expect(d.stateId).toBe(getState(id)?.stateId)
|
||||
expect(d.currentId).toBe(b.id)
|
||||
expect(d.entries[0]).toEqual({
|
||||
index: 0,
|
||||
id: a.id,
|
||||
svg: "",
|
||||
xml: page("a"),
|
||||
source: null,
|
||||
time: a.time,
|
||||
pages: 1,
|
||||
firstId: a.id,
|
||||
})
|
||||
expect(d.entries[1]).toMatchObject({
|
||||
id: b.id,
|
||||
svg: "SVG-B",
|
||||
source: "user",
|
||||
firstId: b.id,
|
||||
})
|
||||
})
|
||||
|
||||
it("names the entry a restored copy stands for, and marks the copy as a restore", async () => {
|
||||
const id = "mcp-history-cards-restore"
|
||||
addHistory(id, page("a"))
|
||||
addHistory(id, page("b"))
|
||||
setState(id, page("b"))
|
||||
const [a, b] = getHistory(id)
|
||||
const restore = await postJson("/api/restore", {
|
||||
sessionId: id,
|
||||
id: a.id,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
expect(restore.status).toBe(200)
|
||||
const d = JSON.parse(
|
||||
(await request(`/api/history?sessionId=${id}`)).body,
|
||||
)
|
||||
// History: a, b, a' (the copy); the canvas shows a's content
|
||||
expect(d.entries.map((e: { firstId: number }) => e.firstId)).toEqual([
|
||||
a.id,
|
||||
b.id,
|
||||
a.id,
|
||||
])
|
||||
expect(d.entries[2].source).toBe("restore")
|
||||
expect(d.currentId).toBe(d.entries[2].id)
|
||||
})
|
||||
|
||||
it("marks the user's edit that a write replaces, and one that lost the race", async () => {
|
||||
const id = "mcp-history-cards-user"
|
||||
const version = setState(id, page("ai"))
|
||||
addHistory(id, page("ai"))
|
||||
// The user edits in the browser; the AI writes over it
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: page("user"),
|
||||
baseVersion: version,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
keepInHistory(id, page("user"))
|
||||
const ai2 = setState(id, page("ai2"))
|
||||
addHistory(id, page("ai2"))
|
||||
// A tab that had not loaded the write yet pushes another edit
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: page("late"),
|
||||
baseVersion: ai2 - 1,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
const d = JSON.parse(
|
||||
(await request(`/api/history?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(
|
||||
d.entries.map((e: { xml: string; source: string | null }) => [
|
||||
e.xml === page("user")
|
||||
? "user"
|
||||
: e.xml === page("late")
|
||||
? "late"
|
||||
: "ai",
|
||||
e.source,
|
||||
]),
|
||||
).toEqual([
|
||||
["ai", null],
|
||||
["user", "user"],
|
||||
["ai", null],
|
||||
["late", "user"],
|
||||
])
|
||||
// The canvas shows the write, not an edit
|
||||
expect(d.currentId).toBe(getHistory(id)[2].id)
|
||||
})
|
||||
|
||||
it("reports no state and no current entry for a session it does not know", async () => {
|
||||
const d = JSON.parse(
|
||||
(await request("/api/history?sessionId=mcp-never-seen")).body,
|
||||
)
|
||||
expect(d).toEqual({
|
||||
entries: [],
|
||||
count: 0,
|
||||
stateId: null,
|
||||
currentId: null,
|
||||
})
|
||||
})
|
||||
|
||||
// draw.io's own copy of a diagram, as a sync reply or an edit brings it
|
||||
const drawioCopy = (xml: string, attrs = 'pageWidth="850"') =>
|
||||
|
||||
Reference in New Issue
Block a user