feat: add minimal style mode toggle for faster diagram generation (#260)

* feat: add minimal style mode toggle for faster diagram generation

- Add Minimal/Styled toggle switch in chat input UI
- When enabled, removes color/style instructions from system prompt
- Faster generation with plain black/white diagrams
- Improves XML auto-fix: handle foreign tags, extra closing tags, trailing garbage
- Fix isMxCellXmlComplete to strip Anthropic function-calling wrappers
- Add debug logging for truncation detection diagnosis

* fix: prevent false XML parse errors during streaming

- Escape unescaped & characters in convertToLegalXml() before DOMParser validation
- Only log console.error for final output, not during streaming updates
- Prevents Next.js dev mode error overlay from showing for expected streaming states
This commit is contained in:
Dayuan Jiang
2025-12-14 19:38:40 +09:00
committed by GitHub
parent ff34f0baf1
commit f743219c03
6 changed files with 336 additions and 57 deletions

View File

@@ -36,12 +36,28 @@ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
/**
* 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 {
const trimmed = xml?.trim() || ""
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>")
}
@@ -198,6 +214,13 @@ 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.
const formatted = cellContent
.split("\n")
@@ -813,6 +836,11 @@ export function validateMxCellStructure(xml: string): string | null {
const doc = parser.parseFromString(xml, "text/xml")
const parseError = doc.querySelector("parsererror")
if (parseError) {
const actualError = parseError.textContent || "Unknown parse error"
console.log(
"[validateMxCellStructure] DOMParser error:",
actualError,
)
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.`
}
@@ -1088,12 +1116,56 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// This handles both opening and closing tags
const hasCellTags = /<\/?Cell[\s>]/i.test(fixed)
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>/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>")
}
// 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
const tagTypos = [
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
@@ -1159,6 +1231,98 @@ 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
// 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)
@@ -1368,16 +1532,26 @@ export function validateAndFixXml(xml: string): {
// Try to fix
const { fixed, fixes } = autoFixXml(xml)
console.log("[validateAndFixXml] Fixes applied:", fixes)
// Validate the fixed version
error = validateMxCellStructure(fixed)
if (error) {
console.log("[validateAndFixXml] Still invalid after fix:", error)
}
if (!error) {
return { valid: true, error: null, fixed, fixes }
}
// Still invalid after fixes
return { valid: false, error, fixed: null, fixes }
// Still invalid after fixes - but return the partially fixed XML
// so we can see what was fixed and what error remains
return {
valid: false,
error,
fixed: fixes.length > 0 ? fixed : null,
fixes,
}
}
export function extractDiagramXML(xml_svg_string: string): string {