fix: what the third round broke, and the first batch's review

MCP preview after the server lost a session (it expired, or the MCP
process restarted):
- Every server state has an id, made when the state is created. The tab
  notices a new id even when the version numbers happen to match, and
  every push names the state it was based on, so one based on a lost state
  is refused, also when it comes before the tab's first poll (the server
  recovers the saved file first).
- The tab keeps the newest canvas XML, saved or not. When the server knows
  nothing (no file) or exactly what the tab last saved, the canvas wins and
  is saved, so edits made while the server was down are kept. Otherwise
  the server's diagram (an AI write the tab missed, a cleared document
  that was saved) is shown and the tab's copy goes to History.
- Late answers to an old state's push or poll are dropped; a failed push
  says the server is unreachable; Download as .drawio saves the canvas.

Settings and server:
- Saved providers this version does not know stay in storage with their
  keys, and sending no longer trips over them.
- The desktop "Ollama (Local)" preset with a key goes to local Ollama
  again; a server model's Ollama URL variable is read; the admin panel
  writes Ollama Cloud's URL for a key without one.
- Provider error texts show again in the desktop app and for EdgeOne.
- .env: a quoted value followed by a comment ending in a quote is read as
  dotenv reads it; unquoted values are unchanged.
- Desktop app: the next launch opens the port where a chat was last
  saved; a launch elsewhere that saves nothing does not move it, and a
  page with no chats lets the next launch try the other port once.
- The Test button no longer stays busy after another tab changed the key.
- A completed append_diagram is no longer undone by an earlier failed
  edit's preview; a file read once in vain is saved again once it is read
  or gone.

From the first batch's review:
- The admin panel's Test of an entry without a URL now tests the server's
  <P>_BASE_URL, where chat sends the entry's key; chat is unchanged (the
  first fix rerouted working setups).
- The model list ends downloads that are too large, accepts answers
  without a body, and keeps the "redirects are not allowed" explanation.
- A test covers the preview's History rendering.
This commit is contained in:
dayuan.jiang
2026-10-05 17:33:36 +09:00
parent 4731394f32
commit c75f74a5a0
36 changed files with 1179 additions and 188 deletions
+62 -1
View File
@@ -3,6 +3,7 @@
* Serves draw.io embed with state sync and history UI
*/
import { randomUUID } from "node:crypto"
import { readFileSync } from "node:fs"
import http from "node:http"
import { dirname, join } from "node:path"
@@ -105,11 +106,22 @@ function ensureSessionStateInitialized(sessionId: string): void {
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
// forever when no load(xml) is ever sent
setState(sessionId, BLANK_MXFILE, undefined, false, false)
// Nothing is known about this session: a tab that still shows it keeps
// its diagram
const state = stateStore.get(sessionId)
if (state) state.blank = true
}
interface SessionState {
xml: string
version: number
// Made when the state is created (first use, or again after it expired
// or the MCP process restarted) and kept by every write. A tab tells by
// it that the server lost what it knew, and every push names the state
// it was based on, so one based on a lost state is refused.
stateId: string
// Created blank because nothing was saved; cleared by the first write
blank?: boolean
// Version of the last write the browser did not make itself (AI edit,
// restore). A browser push based on an older version is rejected.
serverVersion?: number
@@ -178,6 +190,7 @@ export function setState(
stateStore.set(sessionId, {
xml,
version: newVersion,
stateId: existing?.stateId ?? randomUUID(),
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
lastUpdated: new Date(),
lastPolled: existing?.lastPolled,
@@ -444,6 +457,8 @@ function handleStateApi(
JSON.stringify({
xml: state?.xml || null,
version: state?.version || 0,
stateId: state?.stateId ?? null,
blank: !!state?.blank,
syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null,
exportXml: state?.exportXml || null,
@@ -486,12 +501,58 @@ function handleStateApi(
return
}
// A push can come before the tab's first poll after a
// restart: recover the saved file first, so it is compared
// with that and never overwrites it unseen
ensureSessionStateInitialized(sessionId)
const current = stateStore.get(sessionId)
// A tab of this version names the state its push is based
// on. Another state (the server lost the one it knew, or
// the tab has not polled yet): refused, and the tab's next
// poll decides whose diagram wins.
if (current && "stateId" in data) {
if (data.stateId !== current.stateId) {
res.writeHead(409, {
"Content-Type": "application/json",
})
res.end(
JSON.stringify({
error: "Session was recreated",
stateChanged: true,
version: current.version,
}),
)
return
}
// What a recovering tab showed: kept in history only
if (data.source === "recover") {
const saved =
typeof data.xml === "string" &&
!!data.xml &&
data.xml !== current.xml
if (saved) {
addHistory(sessionId, data.xml, data.svg || "")
}
res.writeHead(409, {
"Content-Type": "application/json",
})
res.end(
JSON.stringify({
error: "Diagram changed on the server",
version: current.version,
savedToHistory: saved,
}),
)
return
}
}
// The browser edited a version older than the latest AI write
// (it has not loaded that write yet). Keep the AI write; the
// browser loads it on its next poll. A sync reply is also
// stale after a newer write of the browser's own (a user
// edit saved while the export ran).
const current = stateStore.get(sessionId)
if (
typeof data.baseVersion === "number" &&
(data.baseVersion < (current?.serverVersion ?? 0) ||
+17 -4
View File
@@ -56,15 +56,22 @@ export class Autosaver {
}
// Saved files that could not be read back: never written over, since
// the session then shows something else than what they hold
// the session then shows something else than what they hold. Cleared
// once the file is read, or is gone.
private unreadable = new Set<string>()
/** The session's saved diagram, or null. */
load(sessionId: string): string | null {
const path = this.pathFor(sessionId)
if (!path || !existsSync(path)) return null
if (!path) return null
if (!existsSync(path)) {
this.unreadable.delete(path)
return null
}
try {
return readFileSync(path, "utf-8")
const xml = readFileSync(path, "utf-8")
this.unreadable.delete(path)
return xml
} catch (error) {
log.warn(`Could not read the saved diagram ${path}: ${error}`)
this.unreadable.add(path)
@@ -93,7 +100,13 @@ export class Autosaver {
const entry = this.pending.get(sessionId)
this.pending.delete(sessionId)
const path = this.pathFor(sessionId)
if (!entry || !this.dir || !path || this.unreadable.has(path)) return
if (!entry || !this.dir || !path) return
if (this.unreadable.has(path)) {
log.warn(
`Not saving ${path}: it could not be read, so it may hold work this session does not show`,
)
return
}
try {
const isNew = !existsSync(path)
// A blank page the browser shows before any drawing: nothing to keep
+83 -33
View File
@@ -1,7 +1,16 @@
const iframe = document.getElementById('drawio');
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
// The server state this tab is in step with (see stateId in http-server.ts);
// null until the first poll
let stateId = null;
// The newest diagram on the canvas, saved to the server or not: lastXml is
// the last one the server has
let latestXml = null;
let pushFailing = false; // the last push could not reach the server
let pollSeq = 0, lastHandledPoll = 0; // polls overlap; older answers are dropped
let pendingSvgExport = null;
let pendingSvgBase = 0; // version the pending autosave was based on
let pendingSvgStateId = null; // and the state it belonged to
let pendingAiSvg = false;
let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export
let mcpExportSeq = 0; // number of the latest MCP export
@@ -17,19 +26,24 @@ window.addEventListener('message', (e) => {
if (msg.event === 'init') {
isReady = true;
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml) {
// Ignore autosave while a single-page projection is on screen
// for a page-targeted export — otherwise we'd push the
// transient projection back as the canonical session state.
if (projectionExportActive) return;
// Also an edit undone back to what the server has
latestXml = msg.xml;
if (msg.xml === lastXml) return;
// Request SVG export, then push state with SVG. Remember the
// version this edit is based on, so the server can reject it
// if the AI wrote a newer version that is not loaded yet.
// version and state this edit is based on, so the server can
// reject it if the AI wrote a newer version that is not loaded
// yet, or if it lost that state.
pendingSvgExport = msg.xml;
pendingSvgBase = currentVersion;
pendingSvgStateId = stateId;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
// Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000);
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase, 'edit', pendingSvgStateId); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.format === 'xml') {
// Sync export requested by the server (get_diagram).
// draw.io returns the XML in msg.xml, with no msg.data. A late
@@ -39,7 +53,7 @@ window.addEventListener('message', (e) => {
// Push with the version the export was taken at: a
// newer AI write may have loaded meanwhile, and this
// older XML must not overwrite it.
pushState(msg.xml, '', pendingSyncBase, 'sync');
pushState(msg.xml, '', pendingSyncBase, 'sync', pendingSyncStateId);
}
} else if (msg.event === 'export' && msg.data) {
// Handle MCP server export request (png/svg). fireExport tags
@@ -98,7 +112,7 @@ window.addEventListener('message', (e) => {
if (pendingSvgExport) {
const xml = pendingSvgExport;
pendingSvgExport = null;
pushState(xml, svg, pendingSvgBase);
pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId);
} else if (pendingAiSvg) {
pendingAiSvg = false;
fetch('/api/history-svg', {
@@ -114,6 +128,7 @@ window.addEventListener('message', (e) => {
function loadDiagram(xml, capturePreview = false) {
if (!isReady) { pendingXml = xml; return; }
lastXml = xml;
latestXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
if (capturePreview) {
setTimeout(() => {
@@ -144,24 +159,27 @@ function showNotice(text) {
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
}
// Same rule as hasCells in pages.ts: a cell besides the root cells, or a
// compressed page
function hasCells(xml) {
return /<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(xml || '');
}
// source is 'sync' for replies to a server sync request, 'recover' for the
// tab's copy after the server recovered the session, else 'edit'
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
// tab's copy after the server recovered the session, else 'edit'. sid is the
// server state the push is based on.
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit', sid = stateId) {
if (!sessionId) return;
try {
const r = await fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source })
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
});
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
// 409: the AI wrote a newer version; load it now
pushFailing = false;
if (r.ok) {
const d = await r.json();
// An answer about a state this tab has left since
if (sid !== stateId) return;
currentVersion = d.version;
lastXml = xml;
}
// 409: the AI wrote a newer version, or the server lost the state
// this push was based on; the next poll sorts it out
else if (r.status === 409) {
const d = await r.json().catch(() => ({}));
if (d.savedToHistory) {
@@ -171,33 +189,63 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
}
poll();
}
} catch (e) { console.error('Push failed:', e); }
} catch (e) {
console.error('Push failed:', e);
if (!pushFailing) {
pushFailing = true;
showNotice("Can't reach the MCP server. Your changes are only in this tab for now; use Download to keep a copy.");
}
}
}
// The server made a new state for this session: it expired, or the MCP
// process restarted. Decide whose diagram wins.
function recoverState(s) {
stateId = s.stateId;
// The old state's pending work is gone with it
const projectionShown = projectionExportActive;
projectionExportActive = false;
forceReload = false;
pendingMcpExport = null;
pendingSyncExport = false;
const mine = latestXml;
currentVersion = s.version;
if (s.blank || s.xml === lastXml) {
// The server knows nothing, or exactly what this tab last saved:
// the canvas can only be newer, so it wins (edits made while the
// server was down are saved now)
if (projectionShown && mine) {
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: mine, autosave: 1 }), '*');
}
if (mine && mine !== s.xml) pushState(mine, '', s.version);
} else {
// The server has a diagram this tab never showed (an AI write it
// missed, a saved file): show that, and keep this tab's copy in
// History unless it is the same
loadDiagram(s.xml, true);
if (mine && mine !== s.xml) pushState(mine, '', s.version, 'recover');
}
}
let pendingSyncExport = false;
let pendingSyncBase = 0; // version the pending sync export was taken at
let pendingSyncStateId = null; // and the state it belonged to
let syncExportSeq = 0; // number of the latest sync export
async function poll() {
if (!sessionId) return;
const knownVersion = currentVersion;
const seq = ++pollSeq;
try {
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!r.ok) return;
const s = await r.json();
// The server lost this session (it expired, or the MCP process
// restarted) and rebuilt it. Blank: push back what the browser
// shows. From the auto-save file, which can hold an AI write this
// tab never loaded: show that, and keep this tab's copy in History
// (a push based on version 0 is refused and saved there).
if (s.version < knownVersion && lastXml) {
if (!hasCells(s.xml)) {
pushState(lastXml);
} else {
currentVersion = 0;
if (s.xml !== lastXml) pushState(lastXml, '', 0, 'recover');
}
}
// An older answer than one already handled (the interval, the 409
// handler and the projection restore each poll): it could name a
// state that is gone
if (seq < lastHandledPoll) return;
lastHandledPoll = seq;
if (stateId === null) stateId = s.stateId;
else if (s.stateId && s.stateId !== stateId) recoverState(s);
// Load new diagram from server (before export, so we export latest).
// While a page-targeted projection is on screen, only the restore
// (forceReload) replaces it, so a new version doesn't fight the
@@ -217,6 +265,7 @@ async function poll() {
if (s.syncRequested && !pendingSyncExport && isReady && !projectionExportActive) {
pendingSyncExport = true;
pendingSyncBase = currentVersion;
pendingSyncStateId = stateId;
// draw.io echoes the request in msg.message, so the reply can
// be matched to this request
const seq = ++syncExportSeq;
@@ -324,7 +373,8 @@ saveConfirmBtn.onclick = () => {
// so no wrapper injection is needed. The legacy fallback below
// remains only for documents that somehow slipped past
// normalisation (e.g. an older session loaded from external state).
let xmlData = lastXml || '';
// The canvas as it is, also edits not saved to the server yet
let xmlData = latestXml || lastXml || '';
if (xmlData && !xmlData.includes('<mxfile')) {
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
}
@@ -337,6 +337,87 @@ describe("export requests", () => {
})
})
describe("a session state recreated after it was lost", () => {
const SAVED = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="saved" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const getJson = async (id: string) =>
JSON.parse((await request(`/api/state?sessionId=${id}`)).body)
it("names each state, and says when it was made blank", async () => {
const first = await getJson("mcp-sid-blank")
expect(first.stateId).toMatch(/^[0-9a-f-]{36}$/)
expect(first.blank).toBe(true)
setState("mcp-sid-blank", "<mxfile>AI write</mxfile>")
const after = await getJson("mcp-sid-blank")
// Same state, no longer blank
expect(after.stateId).toBe(first.stateId)
expect(after.blank).toBe(false)
})
it("refuses a push made for another state, also before any poll", async () => {
// The MCP process restarted; the tab's push comes before its poll
onSessionRecreate((id) => (id === "mcp-sid-restart" ? SAVED : null))
try {
for (const stateId of ["from-before", null]) {
const res = await postJson("/api/state", {
sessionId: "mcp-sid-restart",
xml: "<mxfile>tab's old copy</mxfile>",
baseVersion: 7,
stateId,
})
expect(res.status).toBe(409)
expect(JSON.parse(res.body).stateChanged).toBe(true)
// The saved file was recovered first and is kept
expect(getState("mcp-sid-restart")?.xml).toBe(SAVED)
}
} finally {
onSessionRecreate(() => null)
}
})
it("accepts a push for the current state", async () => {
const { stateId, version } = await getJson("mcp-sid-ok")
const res = await postJson("/api/state", {
sessionId: "mcp-sid-ok",
xml: "<mxfile>user edit</mxfile>",
baseVersion: version,
stateId,
})
expect(res.status).toBe(200)
expect(getState("mcp-sid-ok")?.xml).toBe("<mxfile>user edit</mxfile>")
})
it("keeps a recovering tab's copy in history, never on the canvas", async () => {
setState("mcp-sid-recover", SAVED)
const { stateId, version } = await getJson("mcp-sid-recover")
const before = getHistory("mcp-sid-recover").length
const res = await postJson("/api/state", {
sessionId: "mcp-sid-recover",
xml: "<mxfile>what the tab showed</mxfile>",
baseVersion: version,
stateId,
source: "recover",
})
expect(res.status).toBe(409)
expect(JSON.parse(res.body).savedToHistory).toBe(true)
expect(getState("mcp-sid-recover")?.xml).toBe(SAVED)
expect(getHistory("mcp-sid-recover")).toHaveLength(before + 1)
expect(getHistory("mcp-sid-recover").at(-1)?.xml).toBe(
"<mxfile>what the tab showed</mxfile>",
)
})
it("keeps the old rules for a tab from an older version", async () => {
// Its pushes have no stateId field
const version = setState("mcp-sid-legacy", "<mxfile>AI</mxfile>")
const res = await postJson("/api/state", {
sessionId: "mcp-sid-legacy",
xml: "<mxfile>edit</mxfile>",
baseVersion: version,
})
expect(res.status).toBe(200)
})
})
describe("preview page", () => {
it("shows the saved diagram of a session whose state expired", async () => {
const saved = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="kept" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
@@ -8,6 +8,7 @@ import {
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync,
} from "node:fs"
@@ -102,6 +103,33 @@ describe("Autosaver", () => {
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
})
it("saves again once the file was read, or is gone", () => {
const saver = new Autosaver(tempDir(), 10)
saver.schedule("mcp-fixed", DIAGRAM)
saver.flush()
const path = saver.pathFor("mcp-fixed") as string
chmodSync(path, 0o000)
expect(saver.load("mcp-fixed")).toBeNull()
// Permissions fixed; the session is recreated and reads the file
chmodSync(path, 0o644)
expect(saver.load("mcp-fixed")).toBe(DIAGRAM)
const edited = DIAGRAM.replace('id="a"', 'id="b"')
saver.schedule("mcp-fixed", edited)
saver.flush()
expect(readFileSync(path, "utf-8")).toBe(edited)
// A file that could not be read and was then deleted protects
// nothing any more
chmodSync(path, 0o000)
expect(saver.load("mcp-fixed")).toBeNull()
chmodSync(path, 0o644)
rmSync(path)
expect(saver.load("mcp-fixed")).toBeNull()
saver.schedule("mcp-fixed", DIAGRAM)
saver.flush()
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
})
it("does nothing when saving is off", () => {
const saver = new Autosaver(null)
expect(saver.pathFor("mcp-x")).toBeNull()