diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 05b9de1..acd3c09 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -202,6 +202,9 @@ async function handleChatRequest(req: Request): Promise { 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 const { model, providerOptions, headers, modelId } = getAIModel(clientOverrides) @@ -213,7 +216,7 @@ async function handleChatRequest(req: Request): Promise { ) // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) - const systemMessage = getSystemPrompt(modelId) + const systemMessage = getSystemPrompt(modelId, minimalStyle) // Extract file parts (images) from the last message const fileParts = diff --git a/components/chat-input.tsx b/components/chat-input.tsx index 5429c5a..9843484 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -17,7 +17,13 @@ import { HistoryDialog } from "@/components/history-dialog" import { ResetWarningModal } from "@/components/reset-warning-modal" import { SaveDialog } from "@/components/save-dialog" import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" import { Textarea } from "@/components/ui/textarea" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { useDiagram } from "@/contexts/diagram-context" import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { FilePreviewList } from "./file-preview-list" @@ -129,6 +135,8 @@ interface ChatInputProps { onToggleHistory?: (show: boolean) => void sessionId?: string error?: Error | null + minimalStyle?: boolean + onMinimalStyleChange?: (value: boolean) => void } export function ChatInput({ @@ -144,6 +152,8 @@ export function ChatInput({ onToggleHistory = () => {}, sessionId, error = null, + minimalStyle = false, + onMinimalStyleChange = () => {}, }: ChatInputProps) { const { diagramHistory, saveDiagramToFile } = useDiagram() const textareaRef = useRef(null) @@ -343,6 +353,32 @@ export function ChatInput({ showHistory={showHistory} onToggleHistory={onToggleHistory} /> + + + +
+ + +
+
+ + Use minimal for faster generation (no colors) + +
{/* Right actions */} diff --git a/components/chat-message-display.tsx b/components/chat-message-display.tsx index b3fc76c..2d52a02 100644 --- a/components/chat-message-display.tsx +++ b/components/chat-message-display.tsx @@ -293,11 +293,14 @@ export function ChatMessageDisplay({ const parseError = testDoc.querySelector("parsererror") if (parseError) { - console.error( - "[ChatMessageDisplay] Malformed XML detected - skipping update", - ) - // Only show toast if this is the final XML (not during streaming) + // 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( + "[ChatMessageDisplay] Malformed XML detected in final output", + ) toast.error( "AI generated invalid diagram XML. Please try regenerating.", ) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 9edb2d0..c862a12 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -145,6 +145,7 @@ export default function ChatPanel({ const [dailyTokenLimit, setDailyTokenLimit] = useState(0) const [tpmLimit, setTpmLimit] = useState(0) const [showNewChatDialog, setShowNewChatDialog] = useState(false) + const [minimalStyle, setMinimalStyle] = useState(false) // Check config on mount useEffect(() => { @@ -222,8 +223,16 @@ export default function ChatPanel({ if (toolCall.toolName === "display_diagram") { const { xml } = toolCall.input as { xml: string } + // DEBUG: Log raw input to diagnose false truncation detection + console.log( + "[display_diagram] XML ending (last 100 chars):", + 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) { // Store the partial XML for continuation via append_diagram @@ -504,6 +513,10 @@ Continue from EXACTLY where you stopped.`, | Record | undefined + // DEBUG: Log finish reason to diagnose truncation + console.log("[onFinish] finishReason:", metadata?.finishReason) + console.log("[onFinish] metadata:", metadata) + if (metadata) { // Use Number.isFinite to guard against NaN (typeof NaN === 'number' is true) const inputTokens = Number.isFinite(metadata.inputTokens) @@ -935,6 +948,9 @@ Continue from EXACTLY where you stopped.`, }), ...(config.aiModel && { "x-ai-model": config.aiModel }), }), + ...(minimalStyle && { + "x-minimal-style": "true", + }), }, }, ) @@ -1250,6 +1266,8 @@ Continue from EXACTLY where you stopped.`, onToggleHistory={setShowHistory} sessionId={sessionId} error={error} + minimalStyle={minimalStyle} + onMinimalStyleChange={setMinimalStyle} /> diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts index 08875e8..5bfed24 100644 --- a/lib/system-prompts.ts +++ b/lib/system-prompts.ts @@ -132,12 +132,90 @@ Connector (edge) example: + +### 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: - Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex - Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle - 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 ` @@ -257,53 +335,6 @@ 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 @@ -357,12 +388,16 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [ ] /** - * Get the appropriate system prompt based on the model ID + * Get the appropriate system prompt based on the model ID and style preference * 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 minimalStyle - If true, removes style instructions to save tokens * @returns The system prompt string */ -export function getSystemPrompt(modelId?: string): string { +export function getSystemPrompt( + modelId?: string, + minimalStyle?: boolean, +): string { const modelName = modelId || "AI" let prompt: string @@ -383,5 +418,15 @@ export function getSystemPrompt(modelId?: string): string { 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) } diff --git a/lib/utils.ts b/lib/utils.ts index 65c3276..c87f704 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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: + 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("") } @@ -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, + "&", + ) + // 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 < for <, > for >, & for &, " 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 tags to fix") + const beforeFix = fixed fixed = fixed.replace(//gi, "") fixed = fixed.replace(/<\/Cell>/gi, "") + if (beforeFix !== fixed) { + console.log("[autoFixXml] Step 8: Fixed tags") + } fixes.push("Fixed tags to ") } + // 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() + 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(``, "gi"), "") + } + fixes.push( + `Removed foreign tags: ${Array.from(foreignTags).join(", ")}`, + ) + } + // 9. Fix common closing tag typos const tagTypos = [ { wrong: /<\/mxElement>/gi, right: "", name: "" }, @@ -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., "" or "" + 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(``, "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 `, + ) + fixes.push( + `Removed ${removed} extra 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: ...... (duplicate ID) // Pattern B: ...... (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 {