fix: edit_diagram streaming and JSON repair improvements (#271)

- Add shared editDiagramOriginalXmlRef between streaming preview and tool handler
  to avoid conflicts when applying operations (fixes "cell already exists" errors)
- Add JSON repair preprocessing to fix LLM-generated malformed JSON like `:=`
- Filter out tool calls with invalid/undefined inputs from interrupted streaming
- Remove perf console logs
This commit is contained in:
Dayuan Jiang
2025-12-15 21:28:31 +09:00
committed by GitHub
parent c527ce1520
commit cd76fa615e
5 changed files with 364 additions and 91 deletions

View File

@@ -70,29 +70,41 @@ function isMinimalDiagram(xml: string): boolean {
// Helper function to replace historical tool call XML with placeholders // Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth) // This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
function replaceHistoricalToolInputs(messages: any[]): any[] { function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => { return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) { if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg return msg
} }
const replacedContent = msg.content.map((part: any) => { const replacedContent = msg.content
if (part.type === "tool-call") { .map((part: any) => {
const toolName = part.toolName if (part.type === "tool-call") {
if ( const toolName = part.toolName
toolName === "display_diagram" || // Fix invalid/undefined inputs from interrupted streaming
toolName === "edit_diagram" if (
) { !part.input ||
return { typeof part.input !== "object" ||
...part, Object.keys(part.input).length === 0
input: { ) {
placeholder: // Skip tool calls with invalid inputs entirely
"[XML content replaced - see current diagram XML in system context]", return null
}, }
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
} }
} }
} return part
return part })
}) .filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent } return { ...msg, content: replacedContent }
}) })
} }
@@ -231,6 +243,36 @@ ${userInputText}
// Convert UIMessages to ModelMessages and add system message // Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages) const modelMessages = convertToModelMessages(messages)
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
// Replace historical tool call XML with placeholders to reduce tokens // Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML // Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
const enableHistoryReplace = const enableHistoryReplace =
@@ -246,6 +288,63 @@ ${userInputText}
msg.content && Array.isArray(msg.content) && msg.content.length > 0, msg.content && Array.isArray(msg.content) && msg.content.length > 0,
) )
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
enhancedMessages = enhancedMessages
.map((msg: any) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const filteredContent = msg.content.filter((part: any) => {
if (part.type === "tool-call") {
// Check if input is a valid object (not null, undefined, or empty)
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
console.warn(
`[route.ts] Filtering out tool-call with invalid input:`,
{ toolName: part.toolName, input: part.input },
)
return false
}
}
return true
})
return { ...msg, content: filteredContent }
})
.filter((msg: any) => msg.content && msg.content.length > 0)
// DEBUG: Log modelMessages structure (what's being sent to AI)
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (part.type === "tool-call" || part.type === "tool-result") {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
// Update the last message with user input only (XML moved to separate cached system message) // Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) { if (enhancedMessages.length >= 1) {
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1] const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
@@ -327,14 +426,30 @@ ${userInputText}
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON // Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => { experimental_repairToolCall: async ({ toolCall, error }) => {
// DEBUG: Log what we're trying to repair
console.log(`[repairToolCall] Tool: ${toolCall.toolName}`)
console.log(
`[repairToolCall] Error: ${error.name} - ${error.message}`,
)
console.log(`[repairToolCall] Input type: ${typeof toolCall.input}`)
console.log(`[repairToolCall] Input value:`, toolCall.input)
// Only attempt repair for invalid tool input (broken JSON from truncation) // Only attempt repair for invalid tool input (broken JSON from truncation)
if ( if (
error instanceof InvalidToolInputError || error instanceof InvalidToolInputError ||
error.name === "AI_InvalidToolInputError" error.name === "AI_InvalidToolInputError"
) { ) {
try { try {
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
let inputToRepair = toolCall.input
if (typeof inputToRepair === "string") {
// Fix `:=` instead of `: ` (LLM sometimes generates this)
inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "`
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
}
// Use jsonrepair to fix truncated JSON // Use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(toolCall.input) const repairedInput = jsonrepair(inputToRepair)
console.log( console.log(
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`, `[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
) )
@@ -344,6 +459,26 @@ ${userInputText}
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`, `[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
repairError, repairError,
) )
// Return a placeholder input to avoid API errors in multi-step
// The tool will fail gracefully on client side
if (toolCall.toolName === "edit_diagram") {
return {
...toolCall,
input: {
operations: [],
_error: "JSON repair failed - no operations to apply",
},
}
}
if (toolCall.toolName === "display_diagram") {
return {
...toolCall,
input: {
xml: "",
_error: "JSON repair failed - empty diagram",
},
}
}
return null return null
} }
} }

View File

@@ -28,6 +28,7 @@ import {
} from "@/components/ai-elements/reasoning" } from "@/components/ai-elements/reasoning"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { import {
applyDiagramOperations,
convertToLegalXml, convertToLegalXml,
isMxCellXmlComplete, isMxCellXmlComplete,
replaceNodes, replaceNodes,
@@ -42,6 +43,23 @@ interface DiagramOperation {
new_xml?: string new_xml?: string
} }
// 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.type === "string" &&
["update", "add", "delete"].includes(op.type) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do
(op.type === "delete" || typeof op.new_xml === "string"),
)
}
// Tool part interface for type safety // Tool part interface for type safety
interface ToolPartLike { interface ToolPartLike {
type: string type: string
@@ -167,6 +185,7 @@ interface ChatMessageDisplayProps {
setInput: (input: string) => void setInput: (input: string) => void
setFiles: (files: File[]) => void setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>> processedToolCallsRef: MutableRefObject<Set<string>>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
sessionId?: string sessionId?: string
onRegenerate?: (messageIndex: number) => void onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void onEditMessage?: (messageIndex: number, newText: string) => void
@@ -178,6 +197,7 @@ export function ChatMessageDisplay({
setInput, setInput,
setFiles, setFiles,
processedToolCallsRef, processedToolCallsRef,
editDiagramOriginalXmlRef,
sessionId, sessionId,
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
@@ -195,6 +215,14 @@ export function ChatMessageDisplay({
null, null,
) )
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
// Refs for edit_diagram streaming
const pendingEditRef = useRef<{
operations: DiagramOperation[]
toolCallId: string
} | null>(null)
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>( const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{}, {},
) )
@@ -286,15 +314,12 @@ export function ChatMessageDisplay({
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string, showToast = false) => { (xml: string, showToast = false) => {
console.time("perf:handleDisplayChart")
const currentXml = xml || "" const currentXml = xml || ""
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
console.time("perf:DOMParser")
const parser = new DOMParser() const parser = new DOMParser()
const testDoc = parser.parseFromString(convertedXml, "text/xml") const testDoc = parser.parseFromString(convertedXml, "text/xml")
console.timeEnd("perf:DOMParser")
const parseError = testDoc.querySelector("parsererror") const parseError = testDoc.querySelector("parsererror")
if (parseError) { if (parseError) {
@@ -310,7 +335,6 @@ export function ChatMessageDisplay({
"AI generated invalid diagram XML. Please try regenerating.", "AI generated invalid diagram XML. Please try regenerating.",
) )
} }
console.timeEnd("perf:handleDisplayChart")
return // Skip this update return // Skip this update
} }
@@ -320,14 +344,10 @@ export function ChatMessageDisplay({
const baseXML = const baseXML =
chartXML || chartXML ||
`<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>`
console.time("perf:replaceNodes")
const replacedXML = replaceNodes(baseXML, convertedXml) const replacedXML = replaceNodes(baseXML, convertedXml)
console.timeEnd("perf:replaceNodes")
// Validate and auto-fix the XML // Validate and auto-fix the XML
console.time("perf:validateAndFixXml")
const validation = validateAndFixXml(replacedXML) const validation = validateAndFixXml(replacedXML)
console.timeEnd("perf:validateAndFixXml")
if (validation.valid) { if (validation.valid) {
previousXML.current = convertedXml previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original // Use fixed XML if available, otherwise use original
@@ -364,9 +384,6 @@ export function ChatMessageDisplay({
) )
} }
} }
console.timeEnd("perf:handleDisplayChart")
} else {
console.timeEnd("perf:handleDisplayChart")
} }
}, },
[chartXML, onDisplayChart], [chartXML, onDisplayChart],
@@ -385,11 +402,6 @@ export function ChatMessageDisplay({
}, [editingMessageId]) }, [editingMessageId])
useEffect(() => { useEffect(() => {
console.time("perf:message-display-useEffect")
let processedCount = 0
let skippedCount = 0
let debouncedCount = 0
// Only process the last message for streaming performance // Only process the last message for streaming performance
// Previous messages are already processed and won't change // Previous messages are already processed and won't change
const messagesToProcess = const messagesToProcess =
@@ -419,7 +431,6 @@ export function ChatMessageDisplay({
const lastXml = const lastXml =
lastProcessedXmlRef.current.get(toolCallId) lastProcessedXmlRef.current.get(toolCallId)
if (lastXml === xml) { if (lastXml === xml) {
skippedCount++
return // Skip redundant processing return // Skip redundant processing
} }
@@ -439,9 +450,6 @@ export function ChatMessageDisplay({
debounceTimeoutRef.current = null debounceTimeoutRef.current = null
pendingXmlRef.current = null pendingXmlRef.current = null
if (pendingXml) { if (pendingXml) {
console.log(
"perf:debounced-handleDisplayChart executing",
)
handleDisplayChart( handleDisplayChart(
pendingXml, pendingXml,
false, false,
@@ -455,7 +463,6 @@ export function ChatMessageDisplay({
STREAMING_DEBOUNCE_MS, STREAMING_DEBOUNCE_MS,
) )
} }
debouncedCount++
} else if ( } else if (
state === "output-available" && state === "output-available" &&
!processedToolCalls.current.has(toolCallId) !processedToolCalls.current.has(toolCallId)
@@ -471,17 +478,129 @@ export function ChatMessageDisplay({
processedToolCalls.current.add(toolCallId) processedToolCalls.current.add(toolCallId)
// Clean up the ref entry - tool is complete, no longer needed // Clean up the ref entry - tool is complete, no longer needed
lastProcessedXmlRef.current.delete(toolCallId) lastProcessedXmlRef.current.delete(toolCallId)
processedCount++ }
}
// Handle edit_diagram streaming - apply operations incrementally for preview
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
if (
part.type === "tool-edit_diagram" &&
input?.operations
) {
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) return
// Capture original XML when streaming starts (store in shared ref)
if (
!editDiagramOriginalXmlRef.current.has(
toolCallId,
)
) {
if (!chartXML) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
return
}
editDiagramOriginalXmlRef.current.set(
toolCallId,
chartXML,
)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (!originalXml) return
// Skip if no change from last processed state
const lastCount = lastProcessedXmlRef.current.get(
toolCallId + "-opCount",
)
if (lastCount === String(completeOps.length)) return
if (
state === "input-streaming" ||
state === "input-available"
) {
// Queue the operations for debounced processing
pendingEditRef.current = {
operations: completeOps,
toolCallId,
}
if (!editDebounceTimeoutRef.current) {
editDebounceTimeoutRef.current = setTimeout(
() => {
const pending =
pendingEditRef.current
editDebounceTimeoutRef.current =
null
pendingEditRef.current = null
if (pending) {
const origXml =
editDiagramOriginalXmlRef.current.get(
pending.toolCallId,
)
if (!origXml) return
try {
const {
result: editedXml,
} = applyDiagramOperations(
origXml,
pending.operations,
)
handleDisplayChart(
editedXml,
false,
)
lastProcessedXmlRef.current.set(
pending.toolCallId +
"-opCount",
String(
pending.operations
.length,
),
)
} catch (e) {
console.warn(
`[edit_diagram streaming] Operation failed:`,
e instanceof Error
? e.message
: e,
)
}
}
},
STREAMING_DEBOUNCE_MS,
)
}
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final state - cleanup streaming refs (tool handler does final application)
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
)
processedToolCalls.current.add(toolCallId)
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
} }
} }
} }
}) })
} }
}) })
console.log(
`perf:message-display-useEffect processed=${processedCount} skipped=${skippedCount} debounced=${debouncedCount}`,
)
console.timeEnd("perf:message-display-useEffect")
// Cleanup: clear any pending debounce timeout on unmount // Cleanup: clear any pending debounce timeout on unmount
return () => { return () => {
@@ -489,8 +608,12 @@ export function ChatMessageDisplay({
clearTimeout(debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null debounceTimeoutRef.current = null
} }
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
} }
}, [messages, handleDisplayChart]) }, [messages, handleDisplayChart, chartXML])
const renderToolPart = (part: ToolPartLike) => { const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId const callId = part.toolCallId

View File

@@ -202,6 +202,10 @@ export default function ChatPanel({
// Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs // Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs
const processedToolCallsRef = useRef<Set<string>>(new Set()) const processedToolCallsRef = useRef<Set<string>>(new Set())
// Store original XML for edit_diagram streaming - shared between streaming preview and tool handler
// Key: toolCallId, Value: original XML before any operations applied
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
// Debounce timeout for localStorage writes (prevents blocking during streaming) // Debounce timeout for localStorage writes (prevents blocking during streaming)
const localStorageDebounceRef = useRef<ReturnType< const localStorageDebounceRef = useRef<ReturnType<
typeof setTimeout typeof setTimeout
@@ -333,13 +337,22 @@ ${finalXml}
let currentXml = "" let currentXml = ""
try { try {
// Use chartXML from ref directly - more reliable than export // Use the original XML captured during streaming (shared with chat-message-display)
const cachedXML = chartXMLRef.current // This ensures we apply operations to the same base XML that streaming used
if (cachedXML) { const originalXml = editDiagramOriginalXmlRef.current.get(
currentXml = cachedXML toolCall.toolCallId,
)
if (originalXml) {
currentXml = originalXml
} else { } else {
// Fallback to export only if no cached XML // Fallback: use chartXML from ref if streaming didn't capture original
currentXml = await onFetchChart(false) const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML
} else {
// Last resort: export from iframe
currentXml = await onFetchChart(false)
}
} }
const { applyDiagramOperations } = await import( const { applyDiagramOperations } = await import(
@@ -370,6 +383,10 @@ ${currentXml}
Please check the cell IDs and retry.`, Please check the cell IDs and retry.`,
}) })
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
return return
} }
@@ -393,6 +410,10 @@ ${currentXml}
Please fix the operations to avoid structural issues.`, Please fix the operations to avoid structural issues.`,
}) })
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
return return
} }
onExport() onExport()
@@ -401,6 +422,10 @@ Please fix the operations to avoid structural issues.`,
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${operations.length} operation(s) to the diagram.`, output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
}) })
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
} catch (error) { } catch (error) {
console.error("[edit_diagram] Failed:", error) console.error("[edit_diagram] Failed:", error)
@@ -420,6 +445,10 @@ ${currentXml || "No XML available"}
Please check cell IDs and retry, or use display_diagram to regenerate.`, Please check cell IDs and retry, or use display_diagram to regenerate.`,
}) })
// Clean up the shared original XML ref even on error
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
} }
} else if (toolCall.toolName === "append_diagram") { } else if (toolCall.toolName === "append_diagram") {
const { xml } = toolCall.input as { xml: string } const { xml } = toolCall.input as { xml: string }
@@ -508,6 +537,32 @@ Continue from EXACTLY where you stopped.`,
// Silence access code error in console since it's handled by UI // Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) { if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error) console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
}
})
} }
// Translate technical errors into user-friendly messages // Translate technical errors into user-friendly messages
@@ -723,12 +778,10 @@ Continue from EXACTLY where you stopped.`,
// Debounce: save after 1 second of no changes // Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(() => { localStorageDebounceRef.current = setTimeout(() => {
try { try {
console.time("perf:localStorage-messages")
localStorage.setItem( localStorage.setItem(
STORAGE_MESSAGES_KEY, STORAGE_MESSAGES_KEY,
JSON.stringify(messages), JSON.stringify(messages),
) )
console.timeEnd("perf:localStorage-messages")
} catch (error) { } catch (error) {
console.error("Failed to save messages to localStorage:", error) console.error("Failed to save messages to localStorage:", error)
} }
@@ -754,9 +807,7 @@ Continue from EXACTLY where you stopped.`,
// Debounce: save after 1 second of no changes // Debounce: save after 1 second of no changes
xmlStorageDebounceRef.current = setTimeout(() => { xmlStorageDebounceRef.current = setTimeout(() => {
console.time("perf:localStorage-xml")
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML) localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
console.timeEnd("perf:localStorage-xml")
}, LOCAL_STORAGE_DEBOUNCE_MS) }, LOCAL_STORAGE_DEBOUNCE_MS)
return () => { return () => {
@@ -769,13 +820,11 @@ Continue from EXACTLY where you stopped.`,
// Save XML snapshots to localStorage whenever they change // Save XML snapshots to localStorage whenever they change
const saveXmlSnapshots = useCallback(() => { const saveXmlSnapshots = useCallback(() => {
try { try {
console.time("perf:localStorage-snapshots")
const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries()) const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
localStorage.setItem( localStorage.setItem(
STORAGE_XML_SNAPSHOTS_KEY, STORAGE_XML_SNAPSHOTS_KEY,
JSON.stringify(snapshotsArray), JSON.stringify(snapshotsArray),
) )
console.timeEnd("perf:localStorage-snapshots")
} catch (error) { } catch (error) {
console.error( console.error(
"Failed to save XML snapshots to localStorage:", "Failed to save XML snapshots to localStorage:",
@@ -1326,6 +1375,7 @@ Continue from EXACTLY where you stopped.`,
setInput={setInput} setInput={setInput}
setFiles={handleFileChange} setFiles={handleFileChange}
processedToolCallsRef={processedToolCallsRef} processedToolCallsRef={processedToolCallsRef}
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
sessionId={sessionId} sessionId={sessionId}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
status={status} status={status}

View File

@@ -86,20 +86,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
chart: string, chart: string,
skipValidation?: boolean, skipValidation?: boolean,
): string | null => { ): string | null => {
console.time("perf:loadDiagram")
let xmlToLoad = chart let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal use) // Validate XML structure before loading (unless skipped for internal use)
if (!skipValidation) { if (!skipValidation) {
console.time("perf:loadDiagram-validation")
const validation = validateAndFixXml(chart) const validation = validateAndFixXml(chart)
console.timeEnd("perf:loadDiagram-validation")
if (!validation.valid) { if (!validation.valid) {
console.warn( console.warn(
"[loadDiagram] Validation error:", "[loadDiagram] Validation error:",
validation.error, validation.error,
) )
console.timeEnd("perf:loadDiagram")
return validation.error return validation.error
} }
// Use fixed XML if auto-fix was applied // Use fixed XML if auto-fix was applied
@@ -116,14 +112,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setChartXML(xmlToLoad) setChartXML(xmlToLoad)
if (drawioRef.current) { if (drawioRef.current) {
console.time("perf:drawio-iframe-load")
drawioRef.current.load({ drawioRef.current.load({
xml: xmlToLoad, xml: xmlToLoad,
}) })
console.timeEnd("perf:drawio-iframe-load")
} }
console.timeEnd("perf:loadDiagram")
return null return null
} }

View File

@@ -743,8 +743,6 @@ function checkNestedMxCells(xml: string): string | null {
* @returns null if valid, error message string if invalid * @returns null if valid, error message string if invalid
*/ */
export function validateMxCellStructure(xml: string): string | null { export function validateMxCellStructure(xml: string): string | null {
console.time("perf:validateMxCellStructure")
console.log(`perf:validateMxCellStructure XML size: ${xml.length} bytes`)
// Size check for performance // Size check for performance
if (xml.length > MAX_XML_SIZE) { if (xml.length > MAX_XML_SIZE) {
console.warn( console.warn(
@@ -754,18 +752,10 @@ export function validateMxCellStructure(xml: string): string | null {
// 0. First use DOM parser to catch syntax errors (most accurate) // 0. First use DOM parser to catch syntax errors (most accurate)
try { try {
console.time("perf:validate-DOMParser")
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(xml, "text/xml") const doc = parser.parseFromString(xml, "text/xml")
console.timeEnd("perf:validate-DOMParser")
const parseError = doc.querySelector("parsererror") const parseError = doc.querySelector("parsererror")
if (parseError) { if (parseError) {
const actualError = parseError.textContent || "Unknown parse error"
console.log(
"[validateMxCellStructure] DOMParser error:",
actualError,
)
console.timeEnd("perf:validateMxCellStructure")
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for ". Regenerate the diagram with properly escaped values.` return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for ". Regenerate the diagram with properly escaped values.`
} }
@@ -774,7 +764,6 @@ export function validateMxCellStructure(xml: string): string | null {
for (const cell of allCells) { for (const cell of allCells) {
if (cell.parentElement?.tagName === "mxCell") { if (cell.parentElement?.tagName === "mxCell") {
const id = cell.getAttribute("id") || "unknown" const id = cell.getAttribute("id") || "unknown"
console.timeEnd("perf:validateMxCellStructure")
return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.` return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.`
} }
} }
@@ -788,16 +777,12 @@ export function validateMxCellStructure(xml: string): string | null {
// 1. Check for CDATA wrapper (invalid at document root) // 1. Check for CDATA wrapper (invalid at document root)
if (/^\s*<!\[CDATA\[/.test(xml)) { if (/^\s*<!\[CDATA\[/.test(xml)) {
console.timeEnd("perf:validateMxCellStructure")
return "Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end" return "Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end"
} }
// 2. Check for duplicate structural attributes // 2. Check for duplicate structural attributes
console.time("perf:checkDuplicateAttributes")
const dupAttrError = checkDuplicateAttributes(xml) const dupAttrError = checkDuplicateAttributes(xml)
console.timeEnd("perf:checkDuplicateAttributes")
if (dupAttrError) { if (dupAttrError) {
console.timeEnd("perf:validateMxCellStructure")
return dupAttrError return dupAttrError
} }
@@ -807,33 +792,25 @@ export function validateMxCellStructure(xml: string): string | null {
while ((attrValMatch = attrValuePattern.exec(xml)) !== null) { while ((attrValMatch = attrValuePattern.exec(xml)) !== null) {
const value = attrValMatch[1] const value = attrValMatch[1]
if (/</.test(value) && !/&lt;/.test(value)) { if (/</.test(value) && !/&lt;/.test(value)) {
console.timeEnd("perf:validateMxCellStructure")
return "Invalid XML: Unescaped < character in attribute values. Replace < with &lt;" return "Invalid XML: Unescaped < character in attribute values. Replace < with &lt;"
} }
} }
// 4. Check for duplicate IDs // 4. Check for duplicate IDs
console.time("perf:checkDuplicateIds")
const dupIdError = checkDuplicateIds(xml) const dupIdError = checkDuplicateIds(xml)
console.timeEnd("perf:checkDuplicateIds")
if (dupIdError) { if (dupIdError) {
console.timeEnd("perf:validateMxCellStructure")
return dupIdError return dupIdError
} }
// 5. Check for tag mismatches // 5. Check for tag mismatches
console.time("perf:checkTagMismatches")
const tagMismatchError = checkTagMismatches(xml) const tagMismatchError = checkTagMismatches(xml)
console.timeEnd("perf:checkTagMismatches")
if (tagMismatchError) { if (tagMismatchError) {
console.timeEnd("perf:validateMxCellStructure")
return tagMismatchError return tagMismatchError
} }
// 6. Check invalid character references // 6. Check invalid character references
const charRefError = checkCharacterReferences(xml) const charRefError = checkCharacterReferences(xml)
if (charRefError) { if (charRefError) {
console.timeEnd("perf:validateMxCellStructure")
return charRefError return charRefError
} }
@@ -842,7 +819,6 @@ export function validateMxCellStructure(xml: string): string | null {
let commentMatch let commentMatch
while ((commentMatch = commentPattern.exec(xml)) !== null) { while ((commentMatch = commentPattern.exec(xml)) !== null) {
if (/--/.test(commentMatch[1])) { if (/--/.test(commentMatch[1])) {
console.timeEnd("perf:validateMxCellStructure")
return "Invalid XML: Comment contains -- (double hyphen) which is not allowed" return "Invalid XML: Comment contains -- (double hyphen) which is not allowed"
} }
} }
@@ -850,24 +826,20 @@ export function validateMxCellStructure(xml: string): string | null {
// 8. Check for unescaped entity references and invalid entity names // 8. Check for unescaped entity references and invalid entity names
const entityError = checkEntityReferences(xml) const entityError = checkEntityReferences(xml)
if (entityError) { if (entityError) {
console.timeEnd("perf:validateMxCellStructure")
return entityError return entityError
} }
// 9. Check for empty id attributes on mxCell // 9. Check for empty id attributes on mxCell
if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) { if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) {
console.timeEnd("perf:validateMxCellStructure")
return "Invalid XML: Found mxCell element(s) with empty id attribute" return "Invalid XML: Found mxCell element(s) with empty id attribute"
} }
// 10. Check for nested mxCell tags // 10. Check for nested mxCell tags
const nestedCellError = checkNestedMxCells(xml) const nestedCellError = checkNestedMxCells(xml)
if (nestedCellError) { if (nestedCellError) {
console.timeEnd("perf:validateMxCellStructure")
return nestedCellError return nestedCellError
} }
console.timeEnd("perf:validateMxCellStructure")
return null return null
} }