mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 19:49:52 +08:00
fix: what the whole-PR review and Copilot found
- A redirect followed for a custom base URL also drops the key headers of providers that do not use Authorization (x-api-key, x-goog-api-key, api-key) when it goes to another origin. - A second Enter or click while a message is being prepared (attachments read, diagram exported) no longer sends it twice. - The admin Test on the deployment's own endpoints (EdgeOne, the server's keyless Ollama, an address on the server's network) counts toward the quota like a chat; the chat and the Test share one rule for it. The Test of an Azure entry set up by AZURE_RESOURCE_NAME only goes where chat goes. - EdgeOne's function is called at the site root again, as on main: EdgeOne serves edge functions there, outside Next's base path. - MCP History: the state before a write is kept unless the browser saved no change of the user's since the last server write (draw.io's sync copy of it adds no entry), and the dedupe compares the exact text again, so a change of page size or other settings only is its own version. - MCP: an edit keeps untouched labels as draw.io shows them (a literal line break in an attribute is a space); a new document of empty pages the user named is auto-saved; load_diagram reads only regular files, so a pipe cannot hold up the other write tools; the preview does not load back its own push still on its way (an undo made meanwhile is saved). - Two overlapping saves of a new chat no longer reload the canvas from the older copy. - At most three screenshot checks per user turn, passed or failed, as documented. - Desktop: the main window navigates only within the app (draw.io stays in its frame); a presets file that is not JSON and cannot be moved aside is not overwritten. - A last self-closing cell with a raw "<" in a value is not taken for cut off output. - README: Material Design shapes load their icons from fonts.gstatic.com.
This commit is contained in:
@@ -195,7 +195,7 @@ You can deploy your own draw.io instance using the official Docker image:
|
||||
docker run -d -p 8080:8080 jgraph/drawio
|
||||
```
|
||||
|
||||
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works fully offline.
|
||||
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works offline. One exception: shapes from the Material Design library show icons from `fonts.gstatic.com`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -60,6 +60,31 @@ export function serializeXml(node: Node): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* XML parsers read a literal tab or line break inside an attribute value as
|
||||
* a space (a line break written as 
 stays one). linkedom keeps it, and
|
||||
* serializeXml would then write it as a real line break, so an edit would
|
||||
* change labels it never touched. Applied to the text before linkedom.
|
||||
*/
|
||||
function normalizeAttributeWhitespace(xml: string): string {
|
||||
return xml.replace(
|
||||
/<[A-Za-z][^"'<>]*(?:(?:"[^"]*"|'[^']*')[^"'<>]*)*>/g,
|
||||
(tag) =>
|
||||
tag.replace(/"[^"]*"|'[^']*'/g, (value) =>
|
||||
value.replace(/\r\n|[\t\n\r]/g, " "),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
class XmlDomParser extends DOMParser {
|
||||
parseFromString(text: string, type: string) {
|
||||
return super.parseFromString(
|
||||
type.includes("xml") ? normalizeAttributeWhitespace(text) : text,
|
||||
type as any,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: Node): string {
|
||||
return serializeXml(node)
|
||||
@@ -68,6 +93,6 @@ class XMLSerializerPolyfill {
|
||||
|
||||
/** Install the DOMParser and XMLSerializer globals the XML helpers use. */
|
||||
export function installDomPolyfill(): void {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
;(globalThis as any).DOMParser = XmlDomParser
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
* Stores {xml, svg} entries in a circular buffer
|
||||
*/
|
||||
|
||||
import { contentFingerprint } from "./edit-gate.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { normalizeToMxfile, parseMxfile } from "./pages.ts"
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
@@ -18,30 +16,6 @@ interface HistoryEntry {
|
||||
let nextEntryId = 0
|
||||
const historyStore = new Map<string, HistoryEntry[]>()
|
||||
|
||||
/** Each page's background colour */
|
||||
function backgrounds(xml: string): string {
|
||||
const doc = parseMxfile(normalizeToMxfile(xml) ?? xml)
|
||||
if (!doc) return ""
|
||||
return Array.from(doc.querySelectorAll("mxGraphModel"))
|
||||
.map((m) => m.getAttribute("background") || "none")
|
||||
.join(",")
|
||||
}
|
||||
|
||||
// The same pages, cells and backgrounds. draw.io's own copy of a diagram
|
||||
// (a sync reply) adds view and page attributes such as dx, grid and the
|
||||
// page size, which the model's XML leaves out, so those are not compared.
|
||||
// A document without pages has an empty fingerprint and is compared as
|
||||
// text only.
|
||||
function sameDiagram(a: string, b: string): boolean {
|
||||
if (a === b) return true
|
||||
const fingerprint = contentFingerprint(a)
|
||||
return (
|
||||
fingerprint !== "" &&
|
||||
fingerprint === contentFingerprint(b) &&
|
||||
backgrounds(a) === backgrounds(b)
|
||||
)
|
||||
}
|
||||
|
||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
let history = historyStore.get(sessionId)
|
||||
if (!history) {
|
||||
@@ -49,10 +23,11 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
historyStore.set(sessionId, history)
|
||||
}
|
||||
|
||||
// Dedupe: skip if same as last entry, also when only re-serialized
|
||||
// (a change of background only is a new version)
|
||||
// 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 && sameDiagram(last.xml, xml)) {
|
||||
if (last && last.xml === xml) {
|
||||
if (svg && !last.svg) last.svg = svg
|
||||
return history.length - 1
|
||||
}
|
||||
|
||||
@@ -138,6 +138,9 @@ interface SessionState {
|
||||
serverVersion?: number
|
||||
// The XML of that write: what a thumbnail taken after loading it shows
|
||||
serverXml?: string
|
||||
// The browser saved a change of the user's since that write (a sync
|
||||
// reply is no change)
|
||||
userEdited?: boolean
|
||||
lastUpdated: Date
|
||||
lastPolled?: number // Last browser poll; an open tab keeps the session alive
|
||||
svg?: string // Cached SVG from last browser save
|
||||
@@ -206,6 +209,7 @@ export function setState(
|
||||
stateId: existing?.stateId ?? randomUUID(),
|
||||
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
||||
serverXml: fromBrowser ? existing?.serverXml : xml,
|
||||
userEdited: fromBrowser ? existing?.userEdited : false,
|
||||
lastUpdated: new Date(),
|
||||
lastPolled: existing?.lastPolled,
|
||||
// The image of this XML, never an older one's: a write without an
|
||||
@@ -226,6 +230,21 @@ export function setState(
|
||||
return newVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the session's diagram in History before a write replaces it.
|
||||
* Nothing to keep when the browser saved no change of the user's since the
|
||||
* last server write and History ends with that write: the state is that
|
||||
* write, or draw.io's own copy of it from a sync (other text, same diagram).
|
||||
*/
|
||||
export function keepInHistory(sessionId: string, xml: string, svg = ""): void {
|
||||
const state = stateStore.get(sessionId)
|
||||
const last = getHistory(sessionId).at(-1)
|
||||
if (state && !state.userEdited && last && last.xml === state.serverXml) {
|
||||
return
|
||||
}
|
||||
addHistory(sessionId, xml, svg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the browser bridge to export the current diagram as png/svg.
|
||||
*
|
||||
@@ -604,6 +623,8 @@ function handleStateApi(
|
||||
return
|
||||
}
|
||||
const version = setState(sessionId, data.xml, data.svg, true)
|
||||
const saved = stateStore.get(sessionId)
|
||||
if (saved && data.source !== "sync") saved.userEdited = true
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true, version }))
|
||||
} catch {
|
||||
@@ -685,7 +706,7 @@ function handleRestoreApi(
|
||||
contentFingerprint(current.xml) !==
|
||||
contentFingerprint(BLANK_MXFILE)
|
||||
) {
|
||||
addHistory(sessionId, current.xml, current.svg)
|
||||
keepInHistory(sessionId, current.xml, current.svg)
|
||||
}
|
||||
const newVersion = setState(sessionId, entry.xml)
|
||||
addHistory(sessionId, entry.xml, entry.svg)
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type ExportOptions,
|
||||
getServerPort,
|
||||
getState,
|
||||
keepInHistory,
|
||||
onSessionRecreate,
|
||||
onStateChange,
|
||||
requestExport,
|
||||
@@ -389,7 +390,7 @@ Rules: cells are siblings (never nested), ids are unique per page and start from
|
||||
|
||||
// Save user's state before AI overwrites (with cached SVG)
|
||||
if (currentSession.xml) {
|
||||
addHistory(
|
||||
keepInHistory(
|
||||
currentSession.id,
|
||||
currentSession.xml,
|
||||
browserState?.svg || "",
|
||||
@@ -481,6 +482,11 @@ registerWriteTool(
|
||||
|
||||
let content: string
|
||||
try {
|
||||
// A pipe or device could be read forever, and the other
|
||||
// write tools wait for this one
|
||||
if (!(await fs.stat(absolutePath)).isFile()) {
|
||||
throw new Error("not a regular file")
|
||||
}
|
||||
content = await fs.readFile(absolutePath, "utf-8")
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@@ -515,7 +521,7 @@ registerWriteTool(
|
||||
currentSession.xml = browserState.xml
|
||||
}
|
||||
if (currentSession.xml) {
|
||||
addHistory(
|
||||
keepInHistory(
|
||||
currentSession.id,
|
||||
currentSession.xml,
|
||||
browserState?.svg || "",
|
||||
@@ -728,7 +734,7 @@ registerWriteTool(
|
||||
// Save the pre-edit state for undo (with cached SVG from browser).
|
||||
// Done only once the edit applied: a rejected edit returns above
|
||||
// without leaving a phantom history entry.
|
||||
addHistory(
|
||||
keepInHistory(
|
||||
currentSession.id,
|
||||
currentSession.xml,
|
||||
browserState?.svg || "",
|
||||
@@ -1456,7 +1462,11 @@ async function loadMxfileForMutation(): Promise<
|
||||
browserState?.xml ?? "",
|
||||
).ok
|
||||
// Save history before overwriting so the user can undo.
|
||||
addHistory(sessionRef.id, sessionRef.xml, browserState?.svg || "")
|
||||
keepInHistory(
|
||||
sessionRef.id,
|
||||
sessionRef.xml,
|
||||
browserState?.svg || "",
|
||||
)
|
||||
sessionRef.xml = newXml
|
||||
sessionRef.version++
|
||||
setState(sessionRef.id, newXml)
|
||||
|
||||
@@ -18,8 +18,15 @@ import {
|
||||
} from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { contentFingerprint } from "./edit-gate.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { hasCells } from "./pages.ts"
|
||||
import { BLANK_MXFILE, hasCells } from "./pages.ts"
|
||||
|
||||
// The blank page the browser shows before any drawing (page names count:
|
||||
// empty pages the user named or added are kept)
|
||||
const isBlank = (xml: string) =>
|
||||
!hasCells(xml) &&
|
||||
contentFingerprint(xml) === contentFingerprint(BLANK_MXFILE)
|
||||
|
||||
const DELAY_MS = 1000
|
||||
const MAX_FILES = 50
|
||||
@@ -126,7 +133,7 @@ export class Autosaver {
|
||||
try {
|
||||
const isNew = !existsSync(path)
|
||||
// A blank page the browser shows before any drawing: nothing to keep
|
||||
if (isNew && !hasCells(entry.xml)) return
|
||||
if (isNew && isBlank(entry.xml)) return
|
||||
mkdirSync(this.dir, { recursive: true })
|
||||
// Write to a temporary file first so a crash never leaves half a file
|
||||
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
|
||||
|
||||
@@ -7,6 +7,7 @@ let stateId = null;
|
||||
// the last one the server has
|
||||
let latestXml = null;
|
||||
let pushFailing = false; // the last push could not reach the server
|
||||
const pushesInFlight = []; // XML of pushes not answered yet
|
||||
// After recovery replaced the canvas, until draw.io reports the load: an
|
||||
// autosave still on its way belongs to the canvas being replaced
|
||||
let awaitingLoad = false;
|
||||
@@ -181,6 +182,7 @@ function showNotice(text) {
|
||||
// server state the push is based on.
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit', sid = stateId) {
|
||||
if (!sessionId) return;
|
||||
pushesInFlight.push(xml);
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
@@ -226,6 +228,8 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
||||
pushFailing = true;
|
||||
showNotice("Can't reach the MCP server. Your changes are only in this tab for now; use Download to keep a copy.");
|
||||
}
|
||||
} finally {
|
||||
pushesInFlight.splice(pushesInFlight.indexOf(xml), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +295,10 @@ async function poll() {
|
||||
// (forceReload) replaces it, so a new version doesn't fight the
|
||||
// projection; currentVersion stays unadvanced until then, so the
|
||||
// bump is applied with the real document.
|
||||
if ((forceReload || (s.version > currentVersion && !projectionExportActive)) && s.xml) {
|
||||
// The tab's own push still on its way is not loaded back: the
|
||||
// canvas may have moved on since (an undo), and its answer follows
|
||||
const ownPush = pushesInFlight.includes(s.xml);
|
||||
if ((forceReload || (s.version > currentVersion && !projectionExportActive && !ownPush)) && s.xml) {
|
||||
forceReload = false;
|
||||
projectionExportActive = false;
|
||||
currentVersion = s.version;
|
||||
|
||||
@@ -141,3 +141,23 @@ describe("targetPageXml", () => {
|
||||
expect(xml).not.toContain('id="a"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("labels an edit does not touch", () => {
|
||||
it("keep their line breaks and spaces as draw.io reads them", () => {
|
||||
// A literal line break in an attribute reads as a space; 
 is a
|
||||
// real line break
|
||||
const labels =
|
||||
`<mxCell id="s" value="Hello
|
||||
world" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>` +
|
||||
`<mxCell id="m" value="Line 1
Line 2" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`
|
||||
const out = editDiagram(
|
||||
`<mxfile>${page("p1", labels)}</mxfile>`,
|
||||
[{ operation: "add", cell_id: "c", new_xml: cell("c") }],
|
||||
{},
|
||||
)
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
expect(out.xml).toContain(`value="Hello world"`)
|
||||
expect(out.xml).toContain(`value="Line 1
Line 2"`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { installDomPolyfill } from "../src/dom.ts"
|
||||
import { addHistory, getHistory } from "../src/history.ts"
|
||||
import {
|
||||
getState,
|
||||
keepInHistory,
|
||||
onSessionRecreate,
|
||||
requestExport,
|
||||
requestSync,
|
||||
@@ -582,17 +583,55 @@ describe("history restore", () => {
|
||||
expect(getHistory(id)).toHaveLength(1)
|
||||
// The missing image is filled in
|
||||
expect(getHistory(id)[0].svg).toBe("SVG")
|
||||
// draw.io's copy of it, as a sync reply brings it back
|
||||
addHistory(
|
||||
id,
|
||||
page("same").replace(
|
||||
"<mxGraphModel>",
|
||||
'<mxGraphModel dx="1244" dy="534" grid="1" pageWidth="850">',
|
||||
),
|
||||
})
|
||||
|
||||
// draw.io's own copy of a diagram, as a sync reply or an edit brings it
|
||||
const drawioCopy = (xml: string, attrs = 'pageWidth="850"') =>
|
||||
xml.replace(
|
||||
"<mxGraphModel>",
|
||||
`<mxGraphModel dx="1244" dy="534" grid="1" ${attrs}>`,
|
||||
)
|
||||
|
||||
it("keeps no copy of a server write that a sync brought back", async () => {
|
||||
const id = "mcp-history-keep-sync"
|
||||
const version = setState(id, page("ai"))
|
||||
addHistory(id, page("ai"))
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: drawioCopy(page("ai")),
|
||||
baseVersion: version,
|
||||
source: "sync",
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
// The next AI write keeps the state it replaces
|
||||
keepInHistory(id, getState(id)?.xml ?? "")
|
||||
expect(getHistory(id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("keeps a change of page settings only before a write", async () => {
|
||||
const id = "mcp-history-keep-settings"
|
||||
const version = setState(id, page("ai"))
|
||||
addHistory(id, page("ai"))
|
||||
// The user turns the page to A3 in the preview
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: drawioCopy(page("ai"), 'pageWidth="1169" pageHeight="1654"'),
|
||||
baseVersion: version,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
keepInHistory(id, getState(id)?.xml ?? "")
|
||||
expect(getHistory(id)).toHaveLength(2)
|
||||
expect(getHistory(id).at(-1)?.xml).toContain('pageWidth="1169"')
|
||||
})
|
||||
|
||||
it("keeps a diagram restored from its file before a write", () => {
|
||||
const id = "mcp-history-keep-restored"
|
||||
// As restoreSavedSession puts it back after a restart
|
||||
setState(id, page("from file"), undefined, false, false)
|
||||
keepInHistory(id, page("from file"))
|
||||
expect(getHistory(id).map((e) => e.xml)).toEqual([page("from file")])
|
||||
})
|
||||
|
||||
it("keeps a version that changed only the background", () => {
|
||||
const id = "mcp-history-background"
|
||||
addHistory(id, page("same"))
|
||||
|
||||
@@ -14,11 +14,16 @@ import {
|
||||
} from "node:fs"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.ts"
|
||||
import { Autosaver, defaultDataDir } from "../src/persistence.ts"
|
||||
|
||||
beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const BLANK = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const BLANK = `<mxfile><diagram id="page-1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
const tempDir = () => mkdtempSync(join(tmpdir(), "mcp-autosave-"))
|
||||
|
||||
@@ -35,6 +40,19 @@ describe("Autosaver", () => {
|
||||
expect(readdirSync(dir)).toEqual(["mcp-a.drawio"])
|
||||
})
|
||||
|
||||
it("saves a new document of empty pages the user named", async () => {
|
||||
const dir = tempDir()
|
||||
const saver = new Autosaver(dir, 10)
|
||||
const emptyPage = (name: string) =>
|
||||
`<diagram id="${name}" name="${name}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`
|
||||
const pages = `<mxfile>${emptyPage("Planning")}${emptyPage("Notes")}</mxfile>`
|
||||
saver.schedule("mcp-pages", pages)
|
||||
saver.flush()
|
||||
expect(
|
||||
readFileSync(saver.pathFor("mcp-pages") as string, "utf-8"),
|
||||
).toBe(pages)
|
||||
})
|
||||
|
||||
it("skips a blank page that was never saved, but saves clearing a diagram", async () => {
|
||||
const dir = tempDir()
|
||||
const saver = new Autosaver(dir, 10)
|
||||
|
||||
Reference in New Issue
Block a user