mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 11:39:50 +08:00
Chats: - New Chat right after an answer saves that chat once. Saves run one at a time and read the chat on screen when their turn comes; a save scheduled for a chat that is no longer on screen is dropped. A chat whose id was still on its way to the URL no longer comes back after New Chat (the next answer went into it). - Crossing the 768 px breakpoint keeps the chat panel: a streaming answer, unsaved messages and attachments stay. The panel gets the sizes of each side, and a panel collapsed on desktop opens on mobile. - The chat's export waits for its own reply: an edit's history export still on its way no longer answers it with the older diagram, and two file saves at once no longer swap results. - A second edit in one answer is previewed on the first edit's result. - Stop also ends a running screenshot check; a chat that cannot be saved (storage full) can be left with "Continue without saving". - Small diagrams with shapes count as diagrams; the tool card no longer crashes on malformed operations. Quota and providers: - Requests that reach the server's own endpoints count toward the quota: EdgeOne (always its own endpoint now), a private base URL whatever key header is sent, keyless Ollama without a URL. With the quota on, a redirect is followed only to a public address. The output cap applies to these requests too. - Stop records the tokens of the steps that finished; the screenshot check counts its tokens without counting a request. - EdgeOne configured only by AI_PROVIDER works, also in the admin Test, which forwards the access code. Azure set up only in the admin panel works in chat. The Test sends a Bedrock session token. - The admin panel's Test of an entry without a URL uses the server's URL as the server does (no private address check for it); the admin panel no longer writes an Ollama URL. MCP server: - Write tools and start_session run one at a time, so two at once never drop each other's change; a cancelled call waiting its turn is skipped. get_diagram and export_diagram keep the session they started with. - Export to .drawio first gets the user's latest edits from the browser. - History thumbnails: one that arrives after the next AI write is dropped; a sync reply keeps the image; a version that changed only page settings is its own entry. - A diagram over the 10 MB limit is saved without its image, or the user is told to download it (the server now answers 413 instead of cutting the connection). - Labels holding text like id='1' or parent='1' are no longer read as attributes (a layer or a parent was deleted). A broken bare <mxGraphModel> file is refused. - After a sync reply the tab no longer sends its autosave copy again. Desktop and files: - A newer switch of the same preset is not rolled back by an older one that failed. .env values with escaped quotes are read whole. - MCP saved files: a file that could not be read stays protected while a folder without permission hides it, and is saved again once deleted. - The desktop app reports "no chats" only when the count was read and no model settings are stored.
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { createContext, useContext, useRef, useState } from "react"
|
|
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
|
import { toast } from "sonner"
|
|
import type { ExportFormat } from "@/components/save-dialog"
|
|
import { getApiEndpoint } from "@/lib/base-path"
|
|
import {
|
|
BLANK_MXFILE,
|
|
normalizeToMxfile,
|
|
} from "@/packages/mcp-server/src/pages.ts"
|
|
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
|
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
|
|
|
interface DiagramContextType {
|
|
chartXML: string
|
|
// chartXML right away, before the re-render (loadDiagram sets both)
|
|
chartXMLRef: React.MutableRefObject<string>
|
|
latestSvg: string
|
|
diagramHistory: { svg: string; xml: string }[]
|
|
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
|
// Both return the export's tag (empty when draw.io is not there yet)
|
|
handleExport: () => string
|
|
handleExportWithoutHistory: () => string
|
|
// Pending exports by tag; a history or plain export's resolver gets the
|
|
// first page's XML
|
|
exportResolversRef: React.MutableRefObject<
|
|
Record<string, (data: string, xml?: string) => void>
|
|
>
|
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
|
handleDiagramExport: (data: EventExport) => void
|
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
|
clearDiagram: () => void
|
|
saveDiagramToFile: (
|
|
filename: string,
|
|
format: ExportFormat,
|
|
sessionId?: string,
|
|
successMessage?: string,
|
|
) => void
|
|
getThumbnailSvg: () => Promise<string | null>
|
|
captureValidationPng: () => Promise<string | null>
|
|
isDrawioReady: boolean
|
|
onDrawioLoad: () => void
|
|
resetDrawioReady: () => void
|
|
showSaveDialog: boolean
|
|
setShowSaveDialog: (show: boolean) => 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"
|
|
|
|
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|
const [chartXML, setChartXML] = useState<string>("")
|
|
const [latestSvg, setLatestSvg] = useState<string>("")
|
|
const [diagramHistory, setDiagramHistory] = useState<
|
|
{ svg: string; xml: string }[]
|
|
>([])
|
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
|
const hasCalledOnLoadRef = useRef(false)
|
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
|
// Pending exports, keyed by their export tag
|
|
const exportResolversRef = useRef<
|
|
Record<string, (data: string, xml?: string) => void>
|
|
>({})
|
|
// Pending history exports: the document each one was asked for
|
|
const historyXmlRef = useRef(new Map<string, string>())
|
|
const exportSeqRef = useRef(0)
|
|
// Track latest chartXML for restoration after remount
|
|
const chartXMLRef = useRef<string>("")
|
|
|
|
const onDrawioLoad = () => {
|
|
// Only set ready state once to prevent infinite loops
|
|
if (hasCalledOnLoadRef.current) return
|
|
hasCalledOnLoadRef.current = true
|
|
setIsDrawioReady(true)
|
|
// Restore diagram after remount (e.g., theme/UI change)
|
|
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
|
drawioRef.current.load({ xml: chartXMLRef.current })
|
|
}
|
|
}
|
|
|
|
const resetDrawioReady = () => {
|
|
hasCalledOnLoadRef.current = false
|
|
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 ""
|
|
// Save this export to history, with the document shown now:
|
|
// chartXML can change before the result comes back
|
|
const tag = `history-${++exportSeqRef.current}`
|
|
historyXmlRef.current.set(tag, chartXMLRef.current)
|
|
drawioRef.current.exportDiagram({
|
|
format: "xmlsvg",
|
|
message: tag,
|
|
})
|
|
return tag
|
|
}
|
|
|
|
const handleExportWithoutHistory = () => {
|
|
if (!drawioRef.current) return ""
|
|
// Export without saving to history (for edit_diagram fetching current state)
|
|
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.
|
|
const requestTaggedExport = (
|
|
tag: ExportTag,
|
|
format: "xmlsvg" | "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)
|
|
const getThumbnailSvg = 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
|
|
}
|
|
|
|
// 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,
|
|
): string | 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
|
|
}
|
|
}
|
|
|
|
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
|
updateChartXML(xmlToLoad)
|
|
|
|
if (drawioRef.current) {
|
|
drawioRef.current.load({
|
|
xml: xmlToLoad,
|
|
})
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const handleDiagramExport = (data: EventExport) => {
|
|
// Thumbnail, validation PNG and file save exports go only to their
|
|
// own caller
|
|
const tag = data.message?.message
|
|
if (/^(thumbnail|validation|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).
|
|
const extractedXML = extractDiagramXML(data.data)
|
|
setLatestSvg(data.data)
|
|
|
|
// Only add to history if this was a user-initiated export
|
|
// Limit to 20 entries to prevent memory leaks during long sessions
|
|
const MAX_HISTORY_SIZE = 20
|
|
const askedXml =
|
|
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
|
|
if (askedXml !== undefined) {
|
|
historyXmlRef.current.delete(tag as string)
|
|
// Store the full multi-page document (extractedXML is only the
|
|
// first page), so restoring a version keeps every page
|
|
const historyXml = askedXml || extractedXML
|
|
setDiagramHistory((prev) => {
|
|
const newHistory = [
|
|
...prev,
|
|
{
|
|
svg: data.data,
|
|
xml: historyXml,
|
|
},
|
|
]
|
|
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
|
return newHistory.slice(-MAX_HISTORY_SIZE)
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// react-drawio 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 real diagram but
|
|
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
|
if (!hasCalledOnLoadRef.current && isRealDiagram(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("")
|
|
setDiagramHistory([])
|
|
}
|
|
|
|
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, {
|
|
position: "bottom-left",
|
|
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)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<DiagramContext.Provider
|
|
value={{
|
|
chartXML,
|
|
chartXMLRef,
|
|
latestSvg,
|
|
diagramHistory,
|
|
setDiagramHistory,
|
|
loadDiagram,
|
|
handleExport,
|
|
handleExportWithoutHistory,
|
|
exportResolversRef,
|
|
drawioRef,
|
|
handleDiagramExport,
|
|
handleDiagramAutoSave,
|
|
clearDiagram,
|
|
saveDiagramToFile,
|
|
getThumbnailSvg,
|
|
captureValidationPng,
|
|
isDrawioReady,
|
|
onDrawioLoad,
|
|
resetDrawioReady,
|
|
showSaveDialog,
|
|
setShowSaveDialog,
|
|
}}
|
|
>
|
|
{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
|
|
}
|