mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
fix: enable progressive diagram rendering during streaming (#380)
- Add extractCompleteMxCells() to extract only complete mxCell elements from partial XML - Remove useEffect cleanup that was killing debounce timeouts on every re-render - Wrap XML in <root> tags for proper DOMParser validation Previously, diagrams only rendered after ALL XML finished streaming because: 1. useEffect cleanup cleared the 150ms debounce timeout on every message change 2. DOMParser rejected partial XML like '<mxCell id="2" value="...' (incomplete) Now each complete mxCell renders progressively as it finishes streaming.
This commit is contained in:
@@ -31,6 +31,7 @@ import { getApiEndpoint } from "@/lib/base-path"
|
|||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
|
extractCompleteMxCells,
|
||||||
isMxCellXmlComplete,
|
isMxCellXmlComplete,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
@@ -315,12 +316,28 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string, showToast = false) => {
|
||||||
const currentXml = xml || ""
|
let currentXml = xml || ""
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
// During streaming (showToast=false), extract only complete mxCell elements
|
||||||
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||||
|
if (!showToast) {
|
||||||
|
const completeCells = extractCompleteMxCells(currentXml)
|
||||||
|
if (!completeCells) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentXml = completeCells
|
||||||
|
}
|
||||||
|
|
||||||
const convertedXml = convertToLegalXml(currentXml)
|
const convertedXml = convertToLegalXml(currentXml)
|
||||||
if (convertedXml !== previousXML.current) {
|
if (convertedXml !== previousXML.current) {
|
||||||
// Parse and validate XML BEFORE calling replaceNodes
|
// Parse and validate XML BEFORE calling replaceNodes
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser()
|
||||||
const testDoc = parser.parseFromString(convertedXml, "text/xml")
|
// Wrap in root element for parsing multiple mxCell elements
|
||||||
|
const testDoc = parser.parseFromString(
|
||||||
|
`<root>${convertedXml}</root>`,
|
||||||
|
"text/xml",
|
||||||
|
)
|
||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
@@ -347,7 +364,22 @@ export function ChatMessageDisplay({
|
|||||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
// Validate and auto-fix the XML
|
const xmlProcessTime = performance.now() - startTime
|
||||||
|
|
||||||
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||||
|
// The quick DOM parse check above catches malformed XML
|
||||||
|
// Full validation runs on final output (showToast=true)
|
||||||
|
if (!showToast) {
|
||||||
|
previousXML.current = convertedXml
|
||||||
|
const loadStartTime = performance.now()
|
||||||
|
onDisplayChart(replacedXML, true)
|
||||||
|
console.log(
|
||||||
|
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final output: run full validation and auto-fix
|
||||||
const validation = validateAndFixXml(replacedXML)
|
const validation = validateAndFixXml(replacedXML)
|
||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
@@ -360,18 +392,19 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Skip validation in loadDiagram since we already validated above
|
// Skip validation in loadDiagram since we already validated above
|
||||||
|
const loadStartTime = performance.now()
|
||||||
onDisplayChart(xmlToLoad, true)
|
onDisplayChart(xmlToLoad, true)
|
||||||
|
console.log(
|
||||||
|
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
"[ChatMessageDisplay] XML validation failed:",
|
"[ChatMessageDisplay] XML validation failed:",
|
||||||
validation.error,
|
validation.error,
|
||||||
)
|
)
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
toast.error(
|
||||||
if (showToast) {
|
"Diagram validation failed. Please try regenerating.",
|
||||||
toast.error(
|
)
|
||||||
"Diagram validation failed. Please try regenerating.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -603,17 +636,10 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup: clear any pending debounce timeout on unmount
|
// NOTE: Don't cleanup debounce timeouts here!
|
||||||
return () => {
|
// The cleanup runs on every re-render (when messages changes),
|
||||||
if (debounceTimeoutRef.current) {
|
// which would cancel the timeout before it fires.
|
||||||
clearTimeout(debounceTimeoutRef.current)
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||||
debounceTimeoutRef.current = null
|
|
||||||
}
|
|
||||||
if (editDebounceTimeoutRef.current) {
|
|
||||||
clearTimeout(editDebounceTimeoutRef.current)
|
|
||||||
editDebounceTimeoutRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [messages, handleDisplayChart, chartXML])
|
}, [messages, handleDisplayChart, chartXML])
|
||||||
|
|
||||||
const renderToolPart = (part: ToolPartLike) => {
|
const renderToolPart = (part: ToolPartLike) => {
|
||||||
|
|||||||
41
lib/utils.ts
41
lib/utils.ts
@@ -61,6 +61,47 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
|||||||
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only complete mxCell elements from partial/streaming XML.
|
||||||
|
* This allows progressive rendering during streaming by ignoring incomplete trailing elements.
|
||||||
|
* @param xml - The partial XML string (may contain incomplete trailing mxCell)
|
||||||
|
* @returns XML string containing only complete mxCell elements
|
||||||
|
*/
|
||||||
|
export function extractCompleteMxCells(xml: string | undefined | null): string {
|
||||||
|
if (!xml) return ""
|
||||||
|
|
||||||
|
const completeCells: Array<{ index: number; text: string }> = []
|
||||||
|
|
||||||
|
// Match self-closing mxCell tags: <mxCell ... />
|
||||||
|
// Also match mxCell with nested mxGeometry: <mxCell ...>...<mxGeometry .../></mxCell>
|
||||||
|
const selfClosingPattern = /<mxCell\s+[^>]*\/>/g
|
||||||
|
const nestedPattern = /<mxCell\s+[^>]*>[\s\S]*?<\/mxCell>/g
|
||||||
|
|
||||||
|
// Find all self-closing mxCell elements
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = selfClosingPattern.exec(xml)) !== null) {
|
||||||
|
completeCells.push({ index: match.index, text: match[0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all mxCell elements with nested content (like mxGeometry)
|
||||||
|
while ((match = nestedPattern.exec(xml)) !== null) {
|
||||||
|
completeCells.push({ index: match.index, text: match[0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by position to maintain order
|
||||||
|
completeCells.sort((a, b) => a.index - b.index)
|
||||||
|
|
||||||
|
// Remove duplicates (a self-closing match might overlap with nested match)
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const uniqueCells = completeCells.filter((cell) => {
|
||||||
|
if (seen.has(cell.index)) return false
|
||||||
|
seen.add(cell.index)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return uniqueCells.map((c) => c.text).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// XML Parsing Helpers
|
// XML Parsing Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user