Files
next-ai-draw-io/contexts/diagram-context.tsx
T
Dayuan Jiang d1ee1594f1 fix(chat): the AI reads and draws the page the user is viewing (#974)
* fix(chat): the AI reads and draws the page the user is viewing

The model always got the first page and display_diagram replaced the whole
file with one page, so drawing on page 3 of a multi-page file deleted the
other pages (#241), and edits went to page 1 while the user looked at
another page.

Now a turn records the page the user is viewing when the message is sent.
That page's model goes to the model (also as previousXml, on regenerate and
in edit_diagram error messages), selected shapes are sent on any page,
display_diagram and append_diagram replace only that page (a drawing that
brings several pages still replaces the file), edit_diagram targets it,
and the streaming preview draws on it. Compact cells and default styles
take the layer and the edges of that page, in the preview and in
editDiagram. The editor bridge replaces the current page in place when the
other pages are unchanged, so Ctrl+Z undoes an AI change on a multi-page
file in one step; when a full load is needed, the user's page is shown
again afterwards. Version summaries and the compare dialog count the page
the change was made on.

* test: read the failed edit's output without unsafe optional chaining
2026-10-10 23:30:51 +09:00

544 lines
20 KiB
TypeScript

"use client"
import type React from "react"
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import type {
DrawioExportEvent,
DrawioFrameHandle,
} from "@/components/canvas/drawio-frame"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { withPageDefaults } from "@/lib/drawio/drawio-config"
import {
canReplaceDiagram,
commitDiagram,
previewDiagram,
resetPreview,
revertPreview,
selectPage,
} from "@/lib/drawio/editor-bridge"
import {
BLANK_MXFILE,
normalizeToMxfile,
} from "@/packages/mcp-server/src/pages.ts"
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
import { useCanvasStore } from "@/stores/canvas-store"
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
/**
* How a diagram reaches the canvas:
* - load: replace the whole document and reset undo (sessions, new chat)
* - preview: streaming AI output, not recorded in undo history
* - commit: final AI result, one undo step; reported to commit listeners
* - revert: drop the streaming preview and go back to the given diagram
*
* preview/commit/revert go through the draw.io editor when it is reachable
* (same origin); otherwise they fall back to a full load.
*/
export type LoadMode = "load" | "preview" | "commit" | "revert"
/** The document without where its view was scrolled to (dx, dy) */
function withoutView(xml: string): string {
return xml.replace(/<mxGraphModel\b[^>]*>/g, (tag) =>
tag.replace(/\s(?:dx|dy)="[^"]*"/g, ""),
)
}
export interface DiagramCommit {
beforeXml: string
afterXml: string
toolCallId?: string
/** The page the change was made on; null or absent: the first page */
pageId?: string | null
}
interface DiagramContextType {
chartXML: string
// chartXML right away, before the re-render (loadDiagram sets both)
chartXMLRef: React.MutableRefObject<string>
latestSvg: string
loadDiagram: (
chart: string,
skipValidation?: boolean,
mode?: LoadMode,
meta?: { toolCallId?: string; pageId?: string | null },
) => string | null
// Returns the export's tag (empty when draw.io is not there yet)
handleExport: () => string
// Pending exports by tag; a plain export's resolver gets the XML of the
// page on screen
exportResolversRef: React.MutableRefObject<
Record<string, (data: string, xml?: string) => void>
>
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
handleDiagramExport: (data: DrawioExportEvent) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void
saveDiagramToFile: (
filename: string,
format: ExportFormat,
sessionId?: string,
successMessage?: string,
) => void
getThumbnailSvg: () => Promise<string | null>
getVersionSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
/** Register the handler told about every committed AI change */
setCommitHandler: (
handler: ((commit: DiagramCommit) => void) | null,
) => void
/** A new user turn starts: forget the streaming base */
startTurn: () => void
}
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// Every export carries a tag in the request's `message` field. draw.io
// echoes the request back in the export event, so each result reaches its
// own caller. Tags end in a request number, so a late result never answers
// a newer request.
type ExportTag = "thumbnail" | "validation" | "version"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("")
const [isDrawioReady, setIsDrawioReady] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const drawioRef = useRef<DrawioFrameHandle | null>(null)
// Pending exports, keyed by their export tag
const exportResolversRef = useRef<
Record<string, (data: string, xml?: string) => void>
>({})
const exportSeqRef = useRef(0)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
// Diagram before the current AI change started streaming
const turnBaseRef = useRef<string | null>(null)
// Full loads sent that draw.io has not reported done yet
const pendingLoadsRef = useRef(0)
const commitHandlerRef = useRef<((commit: DiagramCommit) => void) | null>(
null,
)
// The page to show again once a full load is done: draw.io shows the
// first page of a loaded document
const pageAfterLoadRef = useRef<string | null>(null)
// Sends a full load. draw.io runs it when its message arrives, and then
// reports "load": until then the editor shows the diagram from before,
// so later changes go the same way and keep their order
const fullLoad = (xml: string) => {
if (!drawioRef.current) return
const pageId = useCanvasStore.getState().currentPageId
pageAfterLoadRef.current =
pageId && xml.includes(`id="${pageId}"`) ? pageId : null
pendingLoadsRef.current++
drawioRef.current.load({ xml })
}
const onDrawioLoad = () => {
pendingLoadsRef.current = Math.max(0, pendingLoadsRef.current - 1)
// Back to the page the user was on
const pageId = pageAfterLoadRef.current
pageAfterLoadRef.current = null
if (pageId) selectPage(pageId)
// Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
setIsDrawioReady(true)
// draw.io's first load: loads sent before it went nowhere
pendingLoadsRef.current = 0
// Restore diagram after remount (e.g., theme/UI change), or a file
// opened while draw.io loaded (its page settings count too)
if (chartXMLRef.current) fullLoad(chartXMLRef.current)
}
const resetDrawioReady = () => {
hasCalledOnLoadRef.current = false
pendingLoadsRef.current = 0
setIsDrawioReady(false)
}
// Update chartXML and its ref together, so callbacks that read the ref
// (export handler, autosave) see the new value right away
const updateChartXML = (xml: string) => {
chartXMLRef.current = xml
setChartXML(xml)
}
const handleExport = () => {
if (!drawioRef.current) return ""
// Export of the current state (for the chat and edit_diagram)
const tag = `fetch-${++exportSeqRef.current}`
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
// Export with a tag in `message` (draw.io echoes it back in the export
// event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally.
// (Reads refs only, so it keeps one identity)
const requestTaggedExport = useCallback(
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
new Promise<string | null>((resolve) => {
const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: string | null) => {
clearTimeout(timer)
delete exportResolversRef.current[id]
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
exportResolversRef.current[id] = finish
drawioRef.current?.exportDiagram({ format, message: id })
}),
[],
)
// Get current diagram as SVG for thumbnail (used by session storage).
// One identity: the chat's auto-save depends on it, and each thumbnail
// renders this provider again (latestSvg), which would otherwise start
// the next save
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves
setLatestSvg(svgData)
return svgData
}
return null
}, [requestTaggedExport])
// Plain SVG (no embedded diagram data) for version cards
const getVersionSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
const svgData = await requestTaggedExport("version", "svg", 4000)
return svgData?.startsWith("data:image/svg") ? svgData : null
}
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
const pngData = await requestTaggedExport("validation", "png", 5000)
// PNG data should be a base64 data URL
return pngData?.startsWith("data:image/png") ? pngData : null
}
const loadDiagram = (
chart: string,
skipValidation?: boolean,
mode: LoadMode = "load",
meta?: { toolCallId?: string; pageId?: string | null },
): string | null => {
// The editor bridge is shared: a page that is gone (another language
// mounted a new one) must not change the new page's canvas
if (!mountedRef.current) return null
let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal
// use). Not strict: the XML may hold the user's own diagram, and the
// tool handlers check model XML strictly before it gets here.
if (!skipValidation) {
const validation = validateAndFixXml(chart, { strict: false })
if (!validation.valid) {
console.warn(
"[loadDiagram] Validation error:",
validation.error,
)
return validation.error
}
// Use fixed XML if auto-fix was applied
if (validation.fixed) {
console.log(
"[loadDiagram] Auto-fixed XML issues:",
validation.fixes,
)
xmlToLoad = validation.fixed
}
}
xmlToLoad = withPageDefaults(xmlToLoad)
const previousXml = chartXMLRef.current
if (mode === "preview" && turnBaseRef.current === null) {
turnBaseRef.current = previousXml
}
const beforeXml = turnBaseRef.current ?? previousXml
if (mode !== "preview") turnBaseRef.current = null
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
updateChartXML(xmlToLoad)
let applied = false
if (
mode !== "load" &&
pendingLoadsRef.current === 0 &&
canReplaceDiagram(xmlToLoad)
) {
try {
if (mode === "preview") previewDiagram(xmlToLoad)
else if (mode === "commit") commitDiagram(xmlToLoad)
else revertPreview(xmlToLoad)
applied = true
} catch (error) {
console.warn("[loadDiagram] Editor update failed:", error)
}
}
if (!applied) {
// A full load replaces any preview, and its base is stale now
resetPreview()
fullLoad(xmlToLoad)
}
if (mode === "commit") {
commitHandlerRef.current?.({
beforeXml,
afterXml: xmlToLoad,
toolCallId: meta?.toolCallId,
pageId: meta?.pageId,
})
}
return null
}
const startTurn = () => {
turnBaseRef.current = null
resetPreview()
}
const handleDiagramExport = (data: DrawioExportEvent) => {
// Thumbnail, version, validation PNG and file save exports go only
// to their own caller
const tag = data.message?.message
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
exportResolversRef.current[tag as string]?.(data.data, data.xml)
return
}
// Don't write chartXML here: exports don't change the diagram, and
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
// The chat gets the page the user is viewing (the first one with
// an external draw.io, which cannot tell us)
const extractedXML = extractDiagramXML(
data.data,
useCanvasStore.getState().currentPageId,
)
setLatestSvg(data.data)
// The chat's own export (onFetchChart), not another one in flight
const resolve =
tag !== undefined ? exportResolversRef.current[tag] : undefined
if (resolve) {
delete exportResolversRef.current[tag as string]
resolve(extractedXML)
}
}
// The frame registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a diagram but
// DrawIO hasn't loaded yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
// Only the view moved, or draw.io saved what it just loaded: nothing
// changed, and a save would put a chat that was only opened first
// in the list
if (withoutView(data.xml) === withoutView(chartXMLRef.current)) return
updateChartXML(data.xml)
}
const clearDiagram = () => {
const emptyDiagram = BLANK_MXFILE
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(emptyDiagram, true)
setLatestSvg("")
}
const saveDiagramToFile = (
filename: string,
format: ExportFormat,
sessionId?: string,
successMessage?: string,
) => {
if (!drawioRef.current) {
console.warn("Draw.io editor not ready")
return
}
// Map format to draw.io export format
const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Each save has its own tag, so two at once never swap results
const tag = `save-${++exportSeqRef.current}`
exportResolversRef.current[tag] = (
exportData: string,
fullDiagramXML?: string,
) => {
delete exportResolversRef.current[tag]
let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") {
// Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
fileContent =
normalizeToMxfile(xml, {
pageId: "page-1",
pageName: "Page-1",
}) ?? xml
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
// PNG data comes as base64 data URL
fileContent = exportData
mimeType = "image/png"
extension = ".png"
} else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".drawio.svg"
} else {
// SVG format (view-only)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".svg"
}
// Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId)
// Handle download
let url: string
if (
typeof fileContent === "string" &&
fileContent.startsWith("data:")
) {
// Already a data URL (PNG)
url = fileContent
} else {
const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob)
}
const a = document.createElement("a")
a.href = url
a.download = `${filename}${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, { duration: 2500 })
}
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100)
}
}
// Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({
format: drawioFormat,
message: tag,
})
}
// Log save event to Langfuse (just flags the trace, doesn't send content)
const logSaveToLangfuse = async (
filename: string,
format: string,
sessionId?: string,
) => {
try {
await fetch(getApiEndpoint("/api/log-save"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }),
})
} catch (error) {
console.warn("Failed to log save to Langfuse:", error)
}
}
const setCommitHandler = (
handler: ((commit: DiagramCommit) => void) | null,
) => {
commitHandlerRef.current = handler
}
return (
<DiagramContext.Provider
value={{
chartXML,
chartXMLRef,
latestSvg,
loadDiagram,
handleExport,
exportResolversRef,
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
clearDiagram,
saveDiagramToFile,
getThumbnailSvg,
getVersionSvg,
captureValidationPng,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
setCommitHandler,
startTurn,
}}
>
{children}
</DiagramContext.Provider>
)
}
export function useDiagram() {
const context = useContext(DiagramContext)
if (context === undefined) {
throw new Error("useDiagram must be used within a DiagramProvider")
}
return context
}