Compare commits

..

4 Commits

Author SHA1 Message Date
dayuan.jiang
0fbd3fe842 docs: fix append_diagram instructions for consistency
- Change 'Do NOT include' to 'Do NOT start with' (clearer intent)
- Add <mxCell id="0"> to prohibited start patterns
- Change 'closing tags </root></mxGraphModel>' to just '</root>' (wrapWithMxFile handles the rest)
2025-12-14 12:33:47 +09:00
dayuan.jiang
9068f608bf refactor: remove debug logs and simplify truncation state
- Remove all debug console.log statements
- Remove isContinuationModeRef, derive from partialXmlRef.current.length > 0
2025-12-14 11:15:26 +09:00
dayuan.jiang
43139a5ef0 fix: show friendly message and yellow badge for truncated output
- Add yellow 'Truncated' badge in UI instead of red 'Error' when XML is incomplete
- Show friendly error message for toolUse.input is invalid errors
- Built on top of append_diagram continuation feature
2025-12-14 10:47:18 +09:00
dayuan.jiang
62e07f5f9c feat: add append_diagram tool for truncation continuation
When LLM output hits maxOutputTokens mid-generation, instead of
failing with an error loop, the system now:

1. Detects truncation (missing </root> in XML)
2. Stores partial XML and tells LLM to use new append_diagram tool
3. LLM continues generating from where it stopped
4. Fragments are accumulated until XML is complete
5. Server limits to 5 steps via stepCountIs(5)

Key changes:
- Add append_diagram tool definition in route.ts
- Add append_diagram handler in chat-panel.tsx
- Track continuation mode separately from error mode
- Continuation mode has unlimited retries (not counted against limit)
- Error mode still limited to MAX_AUTO_RETRY_COUNT (1)
- Update system prompts to document append_diagram tool
2025-12-14 09:38:47 +09:00
11 changed files with 225 additions and 691 deletions

View File

@@ -207,7 +207,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1. **Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azue, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
## How It Works ## How It Works

View File

@@ -202,9 +202,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
modelId: req.headers.get("x-ai-model"), modelId: req.headers.get("x-ai-model"),
} }
// Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true"
// Get AI model with optional client overrides // Get AI model with optional client overrides
const { model, providerOptions, headers, modelId } = const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides) getAIModel(clientOverrides)
@@ -216,7 +213,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
) )
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle) const systemMessage = getSystemPrompt(modelId)
// Extract file parts (images) from the last message // Extract file parts (images) from the last message
const fileParts = const fileParts =
@@ -370,32 +367,36 @@ ${userInputText}
tools: { tools: {
// Client-side tool that will be executed on the client // Client-side tool that will be executed on the client
display_diagram: { display_diagram: {
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically. description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
VALIDATION RULES (XML will be rejected if violated): VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>) 1. All mxCell elements must be DIRECT children of <root> - never nested
2. Do NOT include root cells (id="0" or id="1") - they are added automatically 2. Every mxCell needs a unique id
3. All mxCell elements must be siblings - never nested 3. Every mxCell (except id="0") needs a valid parent attribute
4. Every mxCell needs a unique id (start from "2") 4. Edge source/target must reference existing cell IDs
5. Every mxCell needs a valid parent attribute (use "1" for top-level) 5. Escape special chars in values: &lt; &gt; &amp; &quot;
6. Escape special chars in values: &lt; &gt; &amp; &quot; 6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
Example (generate ONLY this - no wrapper tags): Example with swimlanes and edges (note: all mxCells are siblings):
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1"> <root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/> <mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1"> <mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/> <mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1"> <mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/> <mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2"> <mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/> <mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2"> <mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
</root>
Notes: Notes:
- For AWS diagrams, use **AWS 2025 icons**. - For AWS diagrams, use **AWS 2025 icons**.
@@ -443,9 +444,9 @@ IMPORTANT: Keep edits concise:
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation). WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
CRITICAL INSTRUCTIONS: CRITICAL INSTRUCTIONS:
1. Do NOT include any wrapper tags - just continue the mxCell elements 1. Do NOT start with <mxGraphModel>, <root>, or <mxCell id="0"> - they already exist in the partial
2. Continue from EXACTLY where your previous output stopped 2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements 3. End with the closing </root> tag to complete the diagram
4. If still truncated, call append_diagram again with the next fragment 4. If still truncated, call append_diagram again with the next fragment
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`, Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,

View File

@@ -81,15 +81,16 @@ Contains the actual diagram data.
## Root Cell Container: `<root>` ## Root Cell Container: `<root>`
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically. Contains all the cells in the diagram.
**Internal structure (auto-generated):** **Example:**
```xml ```xml
<root> <root>
<mxCell id="0"/> <!-- Auto-added --> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <!-- Auto-added --> <mxCell id="1" parent="0"/>
<!-- Your mxCell elements go here (start from id="2") -->
<!-- Other cells go here -->
</root> </root>
``` ```
@@ -202,15 +203,15 @@ Draw.io files contain two special cells that are always present:
1. **Root Cell** (id = "0"): The parent of all cells 1. **Root Cell** (id = "0"): The parent of all cells
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells 2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
## Tips for Creating Draw.io XML ## Tips for Manually Creating Draw.io XML
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically 1. Start with the basic structure (`mxfile`, `diagram`, `mxGraphModel`, `root`)
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells) 2. Always include the two special cells (id = "0" and id = "1")
3. Assign unique and sequential IDs to all cells 3. Assign unique and sequential IDs to all cells
4. Define parent relationships correctly (use parent="1" for top-level shapes) 4. Define parent relationships correctly
5. Use `mxGeometry` elements to position shapes 5. Use `mxGeometry` elements to position shapes
6. For connectors, specify `source` and `target` attributes 6. For connectors, specify `source` and `target` attributes
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.** 7. **CRITICAL: All mxCell elements must be DIRECT children of `<root>`. NEVER nest mxCell inside another mxCell.**
## Common Patterns ## Common Patterns

View File

@@ -17,13 +17,7 @@ import { HistoryDialog } from "@/components/history-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal" import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog" import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { FilePreviewList } from "./file-preview-list" import { FilePreviewList } from "./file-preview-list"
@@ -135,8 +129,6 @@ interface ChatInputProps {
onToggleHistory?: (show: boolean) => void onToggleHistory?: (show: boolean) => void
sessionId?: string sessionId?: string
error?: Error | null error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
} }
export function ChatInput({ export function ChatInput({
@@ -152,8 +144,6 @@ export function ChatInput({
onToggleHistory = () => {}, onToggleHistory = () => {},
sessionId, sessionId,
error = null, error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram() const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -353,32 +343,6 @@ export function ChatInput({
showHistory={showHistory} showHistory={showHistory}
onToggleHistory={onToggleHistory} onToggleHistory={onToggleHistory}
/> />
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
className="scale-75"
/>
<label
htmlFor="minimal-style"
className={`text-xs cursor-pointer select-none ${
minimalStyle
? "text-primary font-medium"
: "text-muted-foreground"
}`}
>
{minimalStyle ? "Minimal" : "Styled"}
</label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Use minimal for faster generation (no colors)
</TooltipContent>
</Tooltip>
</div> </div>
{/* Right actions */} {/* Right actions */}

View File

@@ -29,12 +29,7 @@ import {
ReasoningTrigger, ReasoningTrigger,
} 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 { convertToLegalXml, replaceNodes, validateAndFixXml } from "@/lib/utils"
convertToLegalXml,
isMxCellXmlComplete,
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel" import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block" import { CodeBlock } from "./code-block"
@@ -193,14 +188,6 @@ export function ChatMessageDisplay({
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("") const previousXML = useRef<string>("")
const processedToolCalls = processedToolCallsRef const processedToolCalls = processedToolCallsRef
// Track the last processed XML per toolCallId to skip redundant processing during streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>( const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{}, {},
) )
@@ -292,31 +279,24 @@ 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) {
// Use console.warn instead of console.error to avoid triggering
// Next.js dev mode error overlay for expected streaming states
// (partial XML during streaming is normal and will be fixed by subsequent updates)
if (showToast) {
// Only log as error and show toast if this is the final XML
console.error( console.error(
"[ChatMessageDisplay] Malformed XML detected in final output", "[ChatMessageDisplay] Malformed XML detected - skipping update",
) )
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error( toast.error(
"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
} }
@@ -326,14 +306,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
@@ -370,9 +346,6 @@ export function ChatMessageDisplay({
) )
} }
} }
console.timeEnd("perf:handleDisplayChart")
} else {
console.timeEnd("perf:handleDisplayChart")
} }
}, },
[chartXML, onDisplayChart], [chartXML, onDisplayChart],
@@ -391,17 +364,7 @@ export function ChatMessageDisplay({
}, [editingMessageId]) }, [editingMessageId])
useEffect(() => { useEffect(() => {
console.time("perf:message-display-useEffect") messages.forEach((message) => {
let processedCount = 0
let skippedCount = 0
let debouncedCount = 0
// Only process the last message for streaming performance
// Previous messages are already processed and won't change
const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : []
messagesToProcess.forEach((message) => {
if (message.parts) { if (message.parts) {
message.parts.forEach((part) => { message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
@@ -420,82 +383,25 @@ export function ChatMessageDisplay({
input?.xml input?.xml
) { ) {
const xml = input.xml as string const xml = input.xml as string
// Skip if XML hasn't changed since last processing
const lastXml =
lastProcessedXmlRef.current.get(toolCallId)
if (lastXml === xml) {
skippedCount++
return // Skip redundant processing
}
if ( if (
state === "input-streaming" || state === "input-streaming" ||
state === "input-available" state === "input-available"
) { ) {
// Debounce streaming updates - queue the XML and process after delay // During streaming, don't show toast (XML may be incomplete)
pendingXmlRef.current = xml handleDisplayChart(xml, false)
if (!debounceTimeoutRef.current) {
// No pending timeout - set one up
debounceTimeoutRef.current = setTimeout(
() => {
const pendingXml =
pendingXmlRef.current
debounceTimeoutRef.current = null
pendingXmlRef.current = null
if (pendingXml) {
console.log(
"perf:debounced-handleDisplayChart executing",
)
handleDisplayChart(
pendingXml,
false,
)
lastProcessedXmlRef.current.set(
toolCallId,
pendingXml,
)
}
},
STREAMING_DEBOUNCE_MS,
)
}
debouncedCount++
} else if ( } else if (
state === "output-available" && state === "output-available" &&
!processedToolCalls.current.has(toolCallId) !processedToolCalls.current.has(toolCallId)
) { ) {
// Final output - process immediately (clear any pending debounce)
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
pendingXmlRef.current = null
}
// Show toast only if final XML is malformed // Show toast only if final XML is malformed
handleDisplayChart(xml, true) handleDisplayChart(xml, true)
processedToolCalls.current.add(toolCallId) processedToolCalls.current.add(toolCallId)
// Clean up the ref entry - tool is complete, no longer needed
lastProcessedXmlRef.current.delete(toolCallId)
processedCount++
} }
} }
} }
}) })
} }
}) })
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
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
}
}
}, [messages, handleDisplayChart]) }, [messages, handleDisplayChart])
const renderToolPart = (part: ToolPartLike) => { const renderToolPart = (part: ToolPartLike) => {
@@ -551,7 +457,8 @@ export function ChatMessageDisplay({
const isTruncated = const isTruncated =
(toolName === "display_diagram" || (toolName === "display_diagram" ||
toolName === "append_diagram") && toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml) (!input?.xml ||
!input.xml.includes("</root>"))
return isTruncated ? ( return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full"> <span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated Truncated
@@ -600,7 +507,7 @@ export function ChatMessageDisplay({
const isTruncated = const isTruncated =
(toolName === "display_diagram" || (toolName === "display_diagram" ||
toolName === "append_diagram") && toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml) (!input?.xml || !input.xml.includes("</root>"))
return ( return (
<div <div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`} className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}

View File

@@ -26,7 +26,7 @@ import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { formatXML, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
// localStorage keys for persistence // localStorage keys for persistence
@@ -40,7 +40,6 @@ interface MessagePart {
type: string type: string
state?: string state?: string
toolName?: string toolName?: string
input?: { xml?: string; [key: string]: unknown }
[key: string]: unknown [key: string]: unknown
} }
@@ -146,7 +145,6 @@ export default function ChatPanel({
const [dailyTokenLimit, setDailyTokenLimit] = useState(0) const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0) const [tpmLimit, setTpmLimit] = useState(0)
const [showNewChatDialog, setShowNewChatDialog] = useState(false) const [showNewChatDialog, setShowNewChatDialog] = useState(false)
const [minimalStyle, setMinimalStyle] = useState(false)
// Check config on mount // Check config on mount
useEffect(() => { useEffect(() => {
@@ -202,15 +200,6 @@ 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())
// Debounce timeout for localStorage writes (prevents blocking during streaming)
const localStorageDebounceRef = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const xmlStorageDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
const { const {
messages, messages,
sendMessage, sendMessage,
@@ -233,16 +222,9 @@ export default function ChatPanel({
if (toolCall.toolName === "display_diagram") { if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string } const { xml } = toolCall.input as { xml: string }
// DEBUG: Log raw input to diagnose false truncation detection // Check if XML is truncated (missing </root> indicates incomplete output)
console.log( const isTruncated =
"[display_diagram] XML ending (last 100 chars):", !xml.includes("</root>") && !xml.trim().endsWith("/>")
xml.slice(-100),
)
console.log("[display_diagram] XML length:", xml.length)
// Check if XML is truncated (incomplete mxCell indicates truncated output)
const isTruncated = !isMxCellXmlComplete(xml)
console.log("[display_diagram] isTruncated:", isTruncated)
if (isTruncated) { if (isTruncated) {
// Store the partial XML for continuation via append_diagram // Store the partial XML for continuation via append_diagram
@@ -262,9 +244,9 @@ ${partialEnding}
\`\`\` \`\`\`
NEXT STEP: Call append_diagram with the continuation XML. NEXT STEP: Call append_diagram with the continuation XML.
- Do NOT include wrapper tags or root cells (id="0", id="1") - Do NOT start with <mxGraphModel>, <root>, or <mxCell id="0"> (they already exist)
- Start from EXACTLY where you stopped - Start from EXACTLY where you stopped
- Complete all remaining mxCell elements`, - End with the closing </root> tag to complete the diagram`,
}) })
return return
} }
@@ -394,21 +376,17 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
const { xml } = toolCall.input as { xml: string } const { xml } = toolCall.input as { xml: string }
// Detect if LLM incorrectly started fresh instead of continuing // Detect if LLM incorrectly started fresh instead of continuing
// LLM should only output bare mxCells now, so wrapper tags indicate error
const trimmed = xml.trim()
const isFreshStart = const isFreshStart =
trimmed.startsWith("<mxGraphModel") || xml.trim().startsWith("<mxGraphModel") ||
trimmed.startsWith("<root") || xml.trim().startsWith("<root") ||
trimmed.startsWith("<mxfile") || xml.trim().startsWith('<mxCell id="0"')
trimmed.startsWith('<mxCell id="0"') ||
trimmed.startsWith('<mxCell id="1"')
if (isFreshStart) { if (isFreshStart) {
addToolOutput({ addToolOutput({
tool: "append_diagram", tool: "append_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
state: "output-error", state: "output-error",
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1"). errorText: `ERROR: You started fresh with wrapper tags. Do NOT include <mxGraphModel>, <root>, or <mxCell id="0">.
Continue from EXACTLY where the partial ended: Continue from EXACTLY where the partial ended:
\`\`\` \`\`\`
@@ -423,8 +401,8 @@ Start your continuation with the NEXT character after where it stopped.`,
// Append to accumulated XML // Append to accumulated XML
partialXmlRef.current += xml partialXmlRef.current += xml
// Check if XML is now complete (last mxCell is complete) // Check if XML is now complete
const isComplete = isMxCellXmlComplete(partialXmlRef.current) const isComplete = partialXmlRef.current.includes("</root>")
if (isComplete) { if (isComplete) {
// Wrap and display the complete diagram // Wrap and display the complete diagram
@@ -461,7 +439,7 @@ Please use display_diagram with corrected XML.`,
tool: "append_diagram", tool: "append_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
state: "output-error", state: "output-error",
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue. errorText: `XML still incomplete (missing </root>). Call append_diagram again to continue.
Current ending: Current ending:
\`\`\` \`\`\`
@@ -523,10 +501,6 @@ Continue from EXACTLY where you stopped.`,
| Record<string, unknown> | Record<string, unknown>
| undefined | undefined
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
console.log("[onFinish] metadata:", metadata)
if (metadata) { if (metadata) {
// Use Number.isFinite to guard against NaN (typeof NaN === 'number' is true) // Use Number.isFinite to guard against NaN (typeof NaN === 'number' is true)
const inputTokens = Number.isFinite(metadata.inputTokens) const inputTokens = Number.isFinite(metadata.inputTokens)
@@ -632,10 +606,6 @@ Continue from EXACTLY where you stopped.`,
} }
} catch (error) { } catch (error) {
console.error("Failed to restore from localStorage:", error) console.error("Failed to restore from localStorage:", error)
// On complete failure, clear storage to allow recovery
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
toast.error("Session data was corrupted. Starting fresh.")
} }
}, [setMessages]) }, [setMessages])
@@ -680,71 +650,32 @@ Continue from EXACTLY where you stopped.`,
}, 500) }, 500)
}, [isDrawioReady, onDisplayChart]) }, [isDrawioReady, onDisplayChart])
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming) // Save messages to localStorage whenever they change
useEffect(() => { useEffect(() => {
if (!hasRestoredRef.current) return if (!hasRestoredRef.current) return
// Clear any pending save
if (localStorageDebounceRef.current) {
clearTimeout(localStorageDebounceRef.current)
}
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(() => {
try { try {
console.time("perf:localStorage-messages") localStorage.setItem(STORAGE_MESSAGES_KEY, JSON.stringify(messages))
localStorage.setItem(
STORAGE_MESSAGES_KEY,
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)
} }
}, LOCAL_STORAGE_DEBOUNCE_MS)
// Cleanup on unmount
return () => {
if (localStorageDebounceRef.current) {
clearTimeout(localStorageDebounceRef.current)
}
}
}, [messages]) }, [messages])
// Save diagram XML to localStorage whenever it changes (debounced) // Save diagram XML to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (!canSaveDiagram) return if (!canSaveDiagram) return
if (!chartXML || chartXML.length <= 300) return if (chartXML && chartXML.length > 300) {
// Clear any pending save
if (xmlStorageDebounceRef.current) {
clearTimeout(xmlStorageDebounceRef.current)
}
// Debounce: save after 1 second of no changes
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)
return () => {
if (xmlStorageDebounceRef.current) {
clearTimeout(xmlStorageDebounceRef.current)
}
} }
}, [chartXML, canSaveDiagram]) }, [chartXML, canSaveDiagram])
// 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:",
@@ -1001,9 +932,6 @@ Continue from EXACTLY where you stopped.`,
}), }),
...(config.aiModel && { "x-ai-model": config.aiModel }), ...(config.aiModel && { "x-ai-model": config.aiModel }),
}), }),
...(minimalStyle && {
"x-minimal-style": "true",
}),
}, },
}, },
) )
@@ -1189,7 +1117,6 @@ Continue from EXACTLY where you stopped.`,
style: { style: {
maxWidth: "480px", maxWidth: "480px",
}, },
duration: 2000,
}} }}
/> />
{/* Header */} {/* Header */}
@@ -1319,8 +1246,6 @@ Continue from EXACTLY where you stopped.`,
onToggleHistory={setShowHistory} onToggleHistory={setShowHistory}
sessionId={sessionId} sessionId={sessionId}
error={error} error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
/> />
</footer> </footer>

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
} }
@@ -145,20 +138,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setLatestSvg(data.data) setLatestSvg(data.data)
// Only add to history if this was a user-initiated export // 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
if (expectHistoryExportRef.current) { if (expectHistoryExportRef.current) {
setDiagramHistory((prev) => { setDiagramHistory((prev) => [
const newHistory = [
...prev, ...prev,
{ {
svg: data.data, svg: data.data,
xml: extractedXML, xml: extractedXML,
}, },
] ])
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
return newHistory.slice(-MAX_HISTORY_SIZE)
})
expectHistoryExportRef.current = false expectHistoryExportRef.current = false
} }

View File

@@ -9,7 +9,12 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
promptText: promptText:
"Give me a **animated connector** diagram of transformer's architecture", "Give me a **animated connector** diagram of transformer's architecture",
hasImage: false, hasImage: false,
xml: `<mxCell id="title" value="Transformer Architecture" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" vertex="1" parent="1"> xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="title" value="Transformer Architecture" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="300" y="20" width="250" height="30" as="geometry"/> <mxGeometry x="300" y="20" width="250" height="30" as="geometry"/>
</mxCell> </mxCell>
@@ -249,12 +254,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxCell id="output_label" value="Outputs&#xa;(shifted right)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1"> <mxCell id="output_label" value="Outputs&#xa;(shifted right)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="660" y="530" width="100" height="30" as="geometry"/> <mxGeometry x="660" y="530" width="100" height="30" as="geometry"/>
</mxCell>`, </mxCell>
</root>`,
}, },
{ {
promptText: "Replicate this in aws style", promptText: "Replicate this in aws style",
hasImage: true, hasImage: true,
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1"> xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/> <mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
</mxCell> </mxCell>
@@ -313,12 +324,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxPoint x="700" y="350" as="sourcePoint"/> <mxPoint x="700" y="350" as="sourcePoint"/>
<mxPoint x="750" y="300" as="targetPoint"/> <mxPoint x="750" y="300" as="targetPoint"/>
</mxGeometry> </mxGeometry>
</mxCell>`, </mxCell>
</root>`,
}, },
{ {
promptText: "Replicate this flowchart.", promptText: "Replicate this flowchart.",
hasImage: true, hasImage: true,
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/> <mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
</mxCell> </mxCell>
@@ -374,12 +391,16 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxCell id="12" value="Repair lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1"> <mxCell id="12" value="Repair lamp" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#99ff99;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/> <mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
</mxCell>`, </mxCell>
</root>`,
}, },
{ {
promptText: "Summarize this paper as a diagram", promptText: "Summarize this paper as a diagram",
hasImage: true, hasImage: true,
xml: `<mxCell id="title_bg" parent="1" xml: ` <root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="title_bg" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
value="" vertex="1"> value="" vertex="1">
<mxGeometry height="80" width="720" x="40" y="20" as="geometry" /> <mxGeometry height="80" width="720" x="40" y="20" as="geometry" />
@@ -730,12 +751,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
value="Foundational technique for modern LLM reasoning - inspired many follow-up works including Self-Consistency, Tree-of-Thought, etc." value="Foundational technique for modern LLM reasoning - inspired many follow-up works including Self-Consistency, Tree-of-Thought, etc."
vertex="1"> vertex="1">
<mxGeometry height="55" width="230" x="530" y="600" as="geometry" /> <mxGeometry height="55" width="230" x="530" y="600" as="geometry" />
</mxCell>`, </mxCell>
</root>`,
}, },
{ {
promptText: "Draw a cat for me", promptText: "Draw a cat for me",
hasImage: false, hasImage: false,
xml: `<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1"> xml: `<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="300" y="150" width="120" height="120" as="geometry"/> <mxGeometry x="300" y="150" width="120" height="120" as="geometry"/>
</mxCell> </mxCell>
@@ -875,7 +902,9 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxPoint x="235" y="290"/> <mxPoint x="235" y="290"/>
</Array> </Array>
</mxGeometry> </mxGeometry>
</mxCell>`, </mxCell>
</root>`,
}, },
] ]

View File

@@ -104,21 +104,22 @@ When using edit_diagram tool:
## Draw.io XML Structure Reference ## Draw.io XML Structure Reference
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically. Basic structure:
Example - generate ONLY this:
\`\`\`xml \`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1"> <mxGraphModel>
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/> <root>
</mxCell> <mxCell id="0"/>
<mxCell id="1" parent="0"/>
</root>
</mxGraphModel>
\`\`\` \`\`\`
Note: All other mxCell elements go as siblings after id="1".
CRITICAL RULES: CRITICAL RULES:
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>) 1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
2. Do NOT include root cells (id="0" or id="1") - they are added automatically 2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell 3. Use unique sequential IDs for all cells (start from "2" for user content)
4. Use unique sequential IDs starting from "2" 4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
Shape (vertex) example: Shape (vertex) example:
\`\`\`xml \`\`\`xml
@@ -132,90 +133,12 @@ Connector (edge) example:
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4"> <mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
**Rule 1: NEVER let multiple edges share the same path**
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
- Every edge MUST have these 4 attributes set in the style
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
- Before creating an edge, identify ALL shapes positioned between source and target
- If any shape is in the direct path, you MUST use waypoints to route around it
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
- NEVER draw a line that visually crosses over another shape's bounding box
**Rule 5: Plan layout strategically BEFORE generating XML**
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
- Space shapes 150-200px apart to create clear routing channels for edges
- Mentally trace each edge: "What shapes are between source and target?"
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
**Rule 6: Use multiple waypoints for complex routing**
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
- Each direction change needs a waypoint (corner point)
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
**Rule 7: Choose NATURAL connection points based on flow direction**
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
- For DIAGONAL connections: use the side closest to the target, not corners
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
**Before generating XML, mentally verify:**
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
\`\`\` \`\`\`
`
// Style instructions - only included when minimalStyle is false
const STYLE_INSTRUCTIONS = `
Common styles: Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex - Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle - Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right - Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
`
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
const MINIMAL_STYLE_INSTRUCTION = `
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
### No Styling - Plain Black/White Only
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
- NO color attributes (no hex colors like #ff69b4)
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
- IGNORE all color/style examples below
### Container/Group Shapes - MUST be Transparent
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
- This prevents containers from covering child elements
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
### Focus on Layout Quality
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
- SPACING: Minimum 50px gap between all elements
- NO OVERLAPS: Elements and edges must never overlap
- Follow ALL 7 Edge Routing Rules for arrow positioning
- Use waypoints to route edges AROUND obstacles
- Use different exitY/entryY values for multiple edges between same nodes
` `
@@ -228,30 +151,34 @@ const EXTENDED_ADDITIONS = `
### display_diagram Details ### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated): **VALIDATION RULES** (XML will be rejected if violated):
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically 1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
2. All mxCell elements must be siblings - never nested inside other mxCell elements 2. Every mxCell needs a unique id attribute
3. Every mxCell needs a unique id attribute (start from "2") 3. Every mxCell (except id="0") needs a valid parent attribute referencing an existing cell
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped) 4. Edge source/target attributes must reference existing cell IDs
5. Edge source/target attributes must reference existing cell IDs 5. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for " 6. Always start with the two root cells: <mxCell id="0"/><mxCell id="1" parent="0"/>
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags): **Example with swimlanes and edges** (note: all mxCells are siblings under <root>):
\`\`\`xml \`\`\`xml
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1"> <root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/> <mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1"> <mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/> <mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1"> <mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/> <mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2"> <mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/> <mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2"> <mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
</root>
\`\`\` \`\`\`
### append_diagram Details ### append_diagram Details
@@ -259,9 +186,9 @@ const EXTENDED_ADDITIONS = `
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation). **WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
**CRITICAL RULES:** **CRITICAL RULES:**
1. Do NOT include any wrapper tags - just continue the mxCell elements 1. Do NOT start with <mxGraphModel>, <root>, or <mxCell id="0"> - they already exist in the partial
2. Continue from EXACTLY where your previous output stopped 2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements 3. End with the closing </root> tag to complete the diagram
4. If still truncated, call append_diagram again with the next fragment 4. If still truncated, call append_diagram again with the next fragment
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements. **Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
@@ -335,6 +262,53 @@ If edit_diagram fails with "pattern not found":
### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
**Rule 1: NEVER let multiple edges share the same path**
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
- Every edge MUST have these 4 attributes set in the style
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
- Before creating an edge, identify ALL shapes positioned between source and target
- If any shape is in the direct path, you MUST use waypoints to route around it
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
- NEVER draw a line that visually crosses over another shape's bounding box
**Rule 5: Plan layout strategically BEFORE generating XML**
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
- Space shapes 150-200px apart to create clear routing channels for edges
- Mentally trace each edge: "What shapes are between source and target?"
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
**Rule 6: Use multiple waypoints for complex routing**
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
- Each direction change needs a waypoint (corner point)
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
**Rule 7: Choose NATURAL connection points based on flow direction**
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
- For DIAGONAL connections: use the side closest to the target, not corners
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
**Before generating XML, mentally verify:**
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
## Edge Examples ## Edge Examples
@@ -388,16 +362,12 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [
] ]
/** /**
* Get the appropriate system prompt based on the model ID and style preference * Get the appropriate system prompt based on the model ID
* Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum * Uses extended prompt for Opus 4.5 and Haiku 4.5 which have 4000 token cache minimum
* @param modelId - The AI model ID from environment * @param modelId - The AI model ID from environment
* @param minimalStyle - If true, removes style instructions to save tokens
* @returns The system prompt string * @returns The system prompt string
*/ */
export function getSystemPrompt( export function getSystemPrompt(modelId?: string): string {
modelId?: string,
minimalStyle?: boolean,
): string {
const modelName = modelId || "AI" const modelName = modelId || "AI"
let prompt: string let prompt: string
@@ -418,15 +388,5 @@ export function getSystemPrompt(
prompt = DEFAULT_SYSTEM_PROMPT prompt = DEFAULT_SYSTEM_PROMPT
} }
// Add style instructions based on preference
// Minimal style: prepend instruction at START (more prominent)
// Normal style: append at end
if (minimalStyle) {
console.log(`[System Prompt] Minimal style mode ENABLED`)
prompt = MINIMAL_STYLE_INSTRUCTION + prompt
} else {
prompt += STYLE_INSTRUCTIONS
}
return prompt.replace("{{MODEL_NAME}}", modelName) return prompt.replace("{{MODEL_NAME}}", modelName)
} }

View File

@@ -29,38 +29,6 @@ const STRUCTURAL_ATTRS = [
/** Valid XML entity names */ /** Valid XML entity names */
const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"]) const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
// ============================================================================
// mxCell XML Helpers
// ============================================================================
/**
* Check if mxCell XML output is complete (not truncated).
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
* Also handles function-calling wrapper tags that may be incorrectly included.
* @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty
*/
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
let trimmed = xml?.trim() || ""
if (!trimmed) return false
// Strip Anthropic function-calling wrapper tags if present
// These can leak into tool input due to AI SDK parsing issues
// Use loop because tags are nested: </mxCell></mxParameter></invoke>
let prev = ""
while (prev !== trimmed) {
prev = trimmed
trimmed = trimmed
.replace(/<\/mxParameter>\s*$/i, "")
.replace(/<\/invoke>\s*$/i, "")
.replace(/<\/antml:parameter>\s*$/i, "")
.replace(/<\/antml:invoke>\s*$/i, "")
.trim()
}
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
}
// ============================================================================ // ============================================================================
// XML Parsing Helpers // XML Parsing Helpers
// ============================================================================ // ============================================================================
@@ -214,13 +182,6 @@ export function convertToLegalXml(xmlString: string): string {
) )
} }
// Fix unescaped & characters in attribute values (but not valid entities)
// This prevents DOMParser from failing on content like "semantic & missing-step"
cellContent = cellContent.replace(
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
"&amp;",
)
// Indent each line of the matched block for readability. // Indent each line of the matched block for readability.
const formatted = cellContent const formatted = cellContent
.split("\n") .split("\n")
@@ -236,17 +197,13 @@ export function convertToLegalXml(xmlString: string): string {
/** /**
* Wrap XML content with the full mxfile structure required by draw.io. * Wrap XML content with the full mxfile structure required by draw.io.
* Always adds root cells (id="0" and id="1") automatically. * Handles cases where XML is just <root>, <mxGraphModel>, or already has <mxfile>.
* If input already contains root cells, they are removed to avoid duplication. * @param xml - The XML string (may be partial or complete)
* LLM should only generate mxCell elements starting from id="2". * @returns Full mxfile-wrapped XML string
* @param xml - The XML string (bare mxCells, <root>, <mxGraphModel>, or full <mxfile>)
* @returns Full mxfile-wrapped XML string with root cells included
*/ */
export function wrapWithMxFile(xml: string): string { export function wrapWithMxFile(xml: string): string {
const ROOT_CELLS = '<mxCell id="0"/><mxCell id="1" parent="0"/>' if (!xml) {
return `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
if (!xml || !xml.trim()) {
return `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root>${ROOT_CELLS}</root></mxGraphModel></diagram></mxfile>`
} }
// Already has full structure // Already has full structure
@@ -259,20 +216,9 @@ export function wrapWithMxFile(xml: string): string {
return `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>` return `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
} }
// Has <root> wrapper - extract inner content // Just <root> content - extract inner content and wrap fully
let content = xml const rootContent = xml.replace(/<\/?root>/g, "").trim()
if (xml.includes("<root>")) { return `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root>${rootContent}</root></mxGraphModel></diagram></mxfile>`
content = xml.replace(/<\/?root>/g, "").trim()
}
// Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
content = content
.replace(/<mxCell[^>]*\bid=["']0["'][^>]*(?:\/>|><\/mxCell>)/g, "")
.replace(/<mxCell[^>]*\bid=["']1["'][^>]*(?:\/>|><\/mxCell>)/g, "")
.trim()
return `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root>${ROOT_CELLS}${content}</root></mxGraphModel></diagram></mxfile>`
} }
/** /**
@@ -823,8 +769,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(
@@ -834,18 +778,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.`
} }
@@ -854,7 +790,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.`
} }
} }
@@ -868,18 +803,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) return dupAttrError
if (dupAttrError) {
console.timeEnd("perf:validateMxCellStructure")
return dupAttrError
}
// 3. Check for unescaped < in attribute values // 3. Check for unescaped < in attribute values
const attrValuePattern = /=\s*"([^"]*)"/g const attrValuePattern = /=\s*"([^"]*)"/g
@@ -887,67 +816,44 @@ 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) return dupIdError
if (dupIdError) {
console.timeEnd("perf:validateMxCellStructure")
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) return tagMismatchError
if (tagMismatchError) {
console.timeEnd("perf:validateMxCellStructure")
return tagMismatchError
}
// 6. Check invalid character references // 6. Check invalid character references
const charRefError = checkCharacterReferences(xml) const charRefError = checkCharacterReferences(xml)
if (charRefError) { if (charRefError) return charRefError
console.timeEnd("perf:validateMxCellStructure")
return charRefError
}
// 7. Check for invalid comment syntax (-- inside comments) // 7. Check for invalid comment syntax (-- inside comments)
const commentPattern = /<!--([\s\S]*?)-->/g const commentPattern = /<!--([\s\S]*?)-->/g
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"
} }
} }
// 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) return entityError
console.timeEnd("perf:validateMxCellStructure")
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) return nestedCellError
console.timeEnd("perf:validateMxCellStructure")
return nestedCellError
}
console.timeEnd("perf:validateMxCellStructure")
return null return null
} }
@@ -1151,56 +1057,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// This handles both opening and closing tags // This handles both opening and closing tags
const hasCellTags = /<\/?Cell[\s>]/i.test(fixed) const hasCellTags = /<\/?Cell[\s>]/i.test(fixed)
if (hasCellTags) { if (hasCellTags) {
console.log("[autoFixXml] Step 8: Found <Cell> tags to fix")
const beforeFix = fixed
fixed = fixed.replace(/<Cell(\s)/gi, "<mxCell$1") fixed = fixed.replace(/<Cell(\s)/gi, "<mxCell$1")
fixed = fixed.replace(/<Cell>/gi, "<mxCell>") fixed = fixed.replace(/<Cell>/gi, "<mxCell>")
fixed = fixed.replace(/<\/Cell>/gi, "</mxCell>") fixed = fixed.replace(/<\/Cell>/gi, "</mxCell>")
if (beforeFix !== fixed) {
console.log("[autoFixXml] Step 8: Fixed <Cell> tags")
}
fixes.push("Fixed <Cell> tags to <mxCell>") fixes.push("Fixed <Cell> tags to <mxCell>")
} }
// 8b. Remove non-draw.io tags (LLM sometimes includes Claude's function calling XML)
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
const validDrawioTags = new Set([
"mxfile",
"diagram",
"mxGraphModel",
"root",
"mxCell",
"mxGeometry",
"mxPoint",
"Array",
"Object",
"mxRectangle",
])
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
let foreignMatch
const foreignTags = new Set<string>()
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
const tagName = foreignMatch[1]
if (!validDrawioTags.has(tagName)) {
foreignTags.add(tagName)
}
}
if (foreignTags.size > 0) {
console.log(
"[autoFixXml] Step 8b: Found foreign tags:",
Array.from(foreignTags),
)
for (const tag of foreignTags) {
// Remove opening tags (with or without attributes)
fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "")
// Remove closing tags
fixed = fixed.replace(new RegExp(`</${tag}>`, "gi"), "")
}
fixes.push(
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
)
}
// 9. Fix common closing tag typos // 9. Fix common closing tag typos
const tagTypos = [ const tagTypos = [
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" }, { wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
@@ -1266,98 +1128,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
} }
} }
// 10b. Remove extra closing tags (more closes than opens)
// Need to properly count self-closing tags (they don't need closing tags)
const tagCounts = new Map<
string,
{ opens: number; closes: number; selfClosing: number }
>()
// Match full tags to detect self-closing by checking if ends with />
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
let tagCountMatch
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
const isClosing = tagPart.startsWith("/")
const isSelfClosing = fullMatch.endsWith("/>")
const tagName = isClosing ? tagPart.slice(1) : tagPart
let counts = tagCounts.get(tagName)
if (!counts) {
counts = { opens: 0, closes: 0, selfClosing: 0 }
tagCounts.set(tagName, counts)
}
if (isClosing) {
counts.closes++
} else if (isSelfClosing) {
counts.selfClosing++
} else {
counts.opens++
}
}
// Log tag counts for debugging
for (const [tagName, counts] of tagCounts) {
if (
tagName === "mxCell" ||
tagName === "mxGeometry" ||
counts.opens !== counts.closes
) {
console.log(
`[autoFixXml] Step 10b: ${tagName} - opens: ${counts.opens}, closes: ${counts.closes}, selfClosing: ${counts.selfClosing}`,
)
}
}
// Find tags with extra closing tags (self-closing tags are balanced, don't need closing)
for (const [tagName, counts] of tagCounts) {
const extraCloses = counts.closes - counts.opens // Only compare opens vs closes (self-closing are balanced)
if (extraCloses > 0) {
console.log(
`[autoFixXml] Step 10b: ${tagName} has ${counts.opens} opens, ${counts.closes} closes, removing ${extraCloses} extra`,
)
// Remove extra closing tags from the end
let removed = 0
const closeTagPattern = new RegExp(`</${tagName}>`, "g")
const matches = [...fixed.matchAll(closeTagPattern)]
// Remove from the end (last occurrences are likely the extras)
for (
let i = matches.length - 1;
i >= 0 && removed < extraCloses;
i--
) {
const match = matches[i]
const idx = match.index ?? 0
fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length)
removed++
}
if (removed > 0) {
console.log(
`[autoFixXml] Step 10b: Removed ${removed} extra </${tagName}>`,
)
fixes.push(
`Removed ${removed} extra </${tagName}> closing tag(s)`,
)
}
}
}
// 10c. Remove trailing garbage after last XML tag (e.g., stray backslashes, text)
// Find the last valid closing tag or self-closing tag
const closingTagPattern = /<\/[a-zA-Z][a-zA-Z0-9]*>|\/>/g
let lastValidTagEnd = -1
let closingMatch
while ((closingMatch = closingTagPattern.exec(fixed)) !== null) {
lastValidTagEnd = closingMatch.index + closingMatch[0].length
}
if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) {
const trailing = fixed.slice(lastValidTagEnd).trim()
if (trailing) {
fixed = fixed.slice(0, lastValidTagEnd)
fixes.push("Removed trailing garbage after last XML tag")
}
}
// 11. Fix nested mxCell by flattening // 11. Fix nested mxCell by flattening
// Pattern A: <mxCell id="X">...<mxCell id="X">...</mxCell></mxCell> (duplicate ID) // Pattern A: <mxCell id="X">...<mxCell id="X">...</mxCell></mxCell> (duplicate ID)
// Pattern B: <mxCell id="X">...<mxCell id="Y">...</mxCell></mxCell> (different ID - true nesting) // Pattern B: <mxCell id="X">...<mxCell id="Y">...</mxCell></mxCell> (different ID - true nesting)
@@ -1567,26 +1337,16 @@ export function validateAndFixXml(xml: string): {
// Try to fix // Try to fix
const { fixed, fixes } = autoFixXml(xml) const { fixed, fixes } = autoFixXml(xml)
console.log("[validateAndFixXml] Fixes applied:", fixes)
// Validate the fixed version // Validate the fixed version
error = validateMxCellStructure(fixed) error = validateMxCellStructure(fixed)
if (error) {
console.log("[validateAndFixXml] Still invalid after fix:", error)
}
if (!error) { if (!error) {
return { valid: true, error: null, fixed, fixes } return { valid: true, error: null, fixed, fixes }
} }
// Still invalid after fixes - but return the partially fixed XML // Still invalid after fixes
// so we can see what was fixed and what error remains return { valid: false, error, fixed: null, fixes }
return {
valid: false,
error,
fixed: fixes.length > 0 ? fixed : null,
fixes,
}
} }
export function extractDiagramXML(xml_svg_string: string): string { export function extractDiagramXML(xml_svg_string: string): string {

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.1", "version": "0.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"scripts": { "scripts": {