mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 03:59:58 +08:00
The model defines reused styles once as <mxStyle name="..." value="..."/> and refers to them by name, and writes shapes as one self-closing mxCell with x, y, w, h and edges with source and target. style-classes.ts and compact-cells.ts expand both back into standard draw.io XML after validation and add draw.io's html=1 / whiteSpace=wrap defaults; the diagram shown to the model is folded into the same notation. Prompts, tool descriptions and the MCP drawing guide teach the notation with shared examples. Measured on the five start-screen examples against main: gpt-6-luna 35% fewer output tokens, Claude Opus 5.5 41% fewer. Two five-model review rounds fixed quote-aware cell matching, attribute escaping, edge/vertex inference, root-id and layer handling, and several auto-fix gaps.
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import type { UIMessage } from "ai"
|
|
import type { RefObject } from "react"
|
|
import { useCallback, useEffect, useRef } from "react"
|
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
|
import { useDiagram } from "@/contexts/diagram-context"
|
|
import {
|
|
convertToLegalXml,
|
|
extractCompleteMxCells,
|
|
replaceNodes,
|
|
} from "@/lib/utils"
|
|
import {
|
|
defaultLayerOf,
|
|
expandCompactCells,
|
|
} from "@/packages/mcp-server/src/compact-cells.ts"
|
|
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
|
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
|
import {
|
|
addDefaultStyles,
|
|
applyStyleClasses,
|
|
edgeIdsOf,
|
|
readStyleClasses,
|
|
} from "@/packages/mcp-server/src/style-classes.ts"
|
|
|
|
// Helper to extract complete operations from streaming input
|
|
function getCompleteOperations(
|
|
operations: DiagramOperation[] | undefined,
|
|
): DiagramOperation[] {
|
|
if (!operations || !Array.isArray(operations)) return []
|
|
return operations.filter(
|
|
(op) =>
|
|
op &&
|
|
typeof op.operation === "string" &&
|
|
["update", "add", "delete"].includes(op.operation) &&
|
|
typeof op.cell_id === "string" &&
|
|
op.cell_id.length > 0 &&
|
|
(op.operation === "delete" || typeof op.new_xml === "string"),
|
|
)
|
|
}
|
|
|
|
interface UseDiagramStreamingParams {
|
|
messages: UIMessage[]
|
|
processedToolCallsRef: RefObject<Set<string>>
|
|
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
|
loadedMessageIdsRef: RefObject<Set<string>>
|
|
}
|
|
|
|
/**
|
|
* Draws AI tool output onto the canvas while it streams, as a preview that
|
|
* is not recorded in undo history.
|
|
*
|
|
* display_diagram: the complete mxCells written so far. edit_diagram: the
|
|
* complete operations applied to the diagram from before the call. The tool
|
|
* handler validates and commits the final result. Runs in the chat engine
|
|
* so streaming continues while the chat panel is collapsed.
|
|
*/
|
|
export function useDiagramStreaming({
|
|
messages,
|
|
processedToolCallsRef,
|
|
editDiagramOriginalXmlRef,
|
|
loadedMessageIdsRef,
|
|
}: UseDiagramStreamingParams) {
|
|
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
|
|
const previousXML = useRef<string>("")
|
|
// Last processed XML per toolCallId, to skip redundant work while streaming
|
|
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
|
|
|
// Reset when messages become empty (new chat or session switch), so
|
|
// cached examples work again after starting a new session
|
|
useEffect(() => {
|
|
if (messages.length === 0) {
|
|
previousXML.current = ""
|
|
lastProcessedXmlRef.current.clear()
|
|
processedToolCallsRef.current.clear()
|
|
editDiagramOriginalXmlRef.current.clear()
|
|
}
|
|
}, [messages.length, processedToolCallsRef, editDiagramOriginalXmlRef])
|
|
|
|
// Streaming preview of display_diagram: draw the complete cells written
|
|
// so far. The tool handler validates and loads the final diagram.
|
|
const handleDisplayChart = useCallback(
|
|
(xml: string) => {
|
|
// Named styles come before the cells, so the ones written so
|
|
// far are known by the time their cells stream in
|
|
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
|
|
const completeCells = extractCompleteMxCells(cellsXml)
|
|
if (!completeCells) return
|
|
const convertedXml = addDefaultStyles(
|
|
applyStyleClasses(
|
|
expandCompactCells(convertToLegalXml(completeCells)),
|
|
classes,
|
|
),
|
|
)
|
|
if (convertedXml === previousXML.current) return
|
|
|
|
// Skip this update while the cells written so far don't parse
|
|
const testDoc = new DOMParser().parseFromString(
|
|
`<root>${convertedXml}</root>`,
|
|
"text/xml",
|
|
)
|
|
if (testDoc.querySelector("parsererror")) return
|
|
|
|
try {
|
|
// Replace the first page's cells so other pages stay intact.
|
|
// An empty canvas gets a default mxfile to put the cells in.
|
|
const baseXML = chartXML || BLANK_MXFILE
|
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
|
previousXML.current = convertedXml
|
|
loadDiagram(replacedXML, true, "preview")
|
|
} catch (error) {
|
|
console.error("Error processing XML:", error)
|
|
}
|
|
},
|
|
[chartXML, loadDiagram],
|
|
)
|
|
|
|
useEffect(() => {
|
|
// Only the last message can still be streaming
|
|
const message = messages[messages.length - 1]
|
|
if (!message?.parts) return
|
|
|
|
// Messages restored from a saved session were applied before it was
|
|
// saved; the saved diagram is authoritative, so don't replay them
|
|
if (loadedMessageIdsRef.current.has(message.id)) return
|
|
|
|
// The diagram without streamed previews, as loaded last: the tool
|
|
// handler's result of an earlier edit is there before the chartXML
|
|
// state catches up. Undoing a failed edit's preview below changes it
|
|
// too, and an edit streaming right after must start from the undone
|
|
// diagram.
|
|
let baseXml = chartXMLRef.current
|
|
|
|
for (const part of message.parts) {
|
|
if (
|
|
part.type !== "tool-display_diagram" &&
|
|
part.type !== "tool-edit_diagram"
|
|
) {
|
|
continue
|
|
}
|
|
const { toolCallId, state, input } = part as ToolPartLike
|
|
|
|
// Failed or stopped: if the original XML is still stored, the
|
|
// tool handler never ran (invalid JSON, or the user pressed
|
|
// stop), so undo the streamed preview here. Invalid JSON leaves
|
|
// no input, so check this first.
|
|
if (state === "output-error") {
|
|
const originalXml =
|
|
editDiagramOriginalXmlRef.current.get(toolCallId)
|
|
if (originalXml) {
|
|
editDiagramOriginalXmlRef.current.delete(toolCallId)
|
|
loadDiagram(originalXml, true, "revert")
|
|
baseXml = originalXml
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Input complete, or the tool handler, a stop or an error took
|
|
// the call already: the tool handler loads the checked diagram.
|
|
// The messages update at most every 150 ms (useChat throttle in
|
|
// the chat engine), so they can still show the call streaming
|
|
// after that.
|
|
if (
|
|
state !== "input-streaming" ||
|
|
processedToolCallsRef.current.has(toolCallId)
|
|
) {
|
|
processedToolCallsRef.current.add(toolCallId)
|
|
lastProcessedXmlRef.current.delete(toolCallId)
|
|
lastProcessedXmlRef.current.delete(`${toolCallId}-result`)
|
|
lastProcessedXmlRef.current.delete(`${toolCallId}-ops`)
|
|
continue
|
|
}
|
|
|
|
if (part.type === "tool-display_diagram") {
|
|
const xml = input?.xml as string | undefined
|
|
// Skip if XML hasn't changed since last processing
|
|
if (!xml || lastProcessedXmlRef.current.get(toolCallId) === xml)
|
|
continue
|
|
// Keep the diagram from before the preview, to undo it on a
|
|
// stop or an error
|
|
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
|
|
editDiagramOriginalXmlRef.current.set(
|
|
toolCallId,
|
|
baseXml || BLANK_MXFILE,
|
|
)
|
|
}
|
|
handleDisplayChart(xml)
|
|
lastProcessedXmlRef.current.set(toolCallId, xml)
|
|
continue
|
|
}
|
|
|
|
// edit_diagram: apply operations incrementally for a preview.
|
|
// editDiagramOriginalXmlRef is shared with the tool handler.
|
|
if (!input?.operations) continue
|
|
const completeOps = getCompleteOperations(
|
|
input.operations as DiagramOperation[],
|
|
)
|
|
if (completeOps.length === 0) continue
|
|
|
|
// Capture the original XML when streaming starts
|
|
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
|
|
if (!baseXml) {
|
|
console.warn(
|
|
"[edit_diagram streaming] No chart XML available",
|
|
)
|
|
continue
|
|
}
|
|
editDiagramOriginalXmlRef.current.set(toolCallId, baseXml)
|
|
}
|
|
const originalXml =
|
|
editDiagramOriginalXmlRef.current.get(toolCallId)
|
|
if (!originalXml) continue
|
|
|
|
// The last operation's XML may still be streaming: it applies
|
|
// once it parses, so compare results, not the number of
|
|
// operations. Skip if nothing changed since the last preview.
|
|
const resultKey = `${toolCallId}-result`
|
|
const opsKey = `${toolCallId}-ops`
|
|
const ops = JSON.stringify(completeOps)
|
|
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
|
|
lastProcessedXmlRef.current.set(opsKey, ops)
|
|
try {
|
|
const edges = edgeIdsOf(originalXml)
|
|
const layer = defaultLayerOf(originalXml)
|
|
const { result } = applyDiagramOperations(
|
|
originalXml,
|
|
completeOps.map((op) =>
|
|
op.new_xml
|
|
? {
|
|
...op,
|
|
new_xml: addDefaultStyles(
|
|
expandCompactCells(op.new_xml, layer),
|
|
edges,
|
|
),
|
|
}
|
|
: op,
|
|
),
|
|
)
|
|
if (lastProcessedXmlRef.current.get(resultKey) === result) {
|
|
continue
|
|
}
|
|
// Load the full document so other pages stay intact
|
|
loadDiagram(result, true, "preview")
|
|
lastProcessedXmlRef.current.set(resultKey, result)
|
|
} catch (e) {
|
|
console.warn(
|
|
"[edit_diagram streaming] Operation failed:",
|
|
e instanceof Error ? e.message : e,
|
|
)
|
|
}
|
|
}
|
|
}, [
|
|
messages,
|
|
handleDisplayChart,
|
|
chartXMLRef,
|
|
loadDiagram,
|
|
processedToolCallsRef,
|
|
editDiagramOriginalXmlRef,
|
|
loadedMessageIdsRef,
|
|
])
|
|
}
|