mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 14:22:28 +08:00
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:
@@ -202,6 +202,9 @@ 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)
|
||||||
@@ -213,7 +216,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)
|
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
|
|
||||||
// Extract file parts (images) from the last message
|
// Extract file parts (images) from the last message
|
||||||
const fileParts =
|
const fileParts =
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ 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"
|
||||||
@@ -129,6 +135,8 @@ 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({
|
||||||
@@ -144,6 +152,8 @@ 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)
|
||||||
@@ -343,6 +353,32 @@ 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 */}
|
||||||
|
|||||||
@@ -293,11 +293,14 @@ export function ChatMessageDisplay({
|
|||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
console.error(
|
// Use console.warn instead of console.error to avoid triggering
|
||||||
"[ChatMessageDisplay] Malformed XML detected - skipping update",
|
// Next.js dev mode error overlay for expected streaming states
|
||||||
)
|
// (partial XML during streaming is normal and will be fixed by subsequent updates)
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
|
||||||
if (showToast) {
|
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(
|
toast.error(
|
||||||
"AI generated invalid diagram XML. Please try regenerating.",
|
"AI generated invalid diagram XML. Please try regenerating.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ 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(() => {
|
||||||
@@ -222,8 +223,16 @@ 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
|
||||||
|
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)
|
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||||
const isTruncated = !isMxCellXmlComplete(xml)
|
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
|
||||||
@@ -504,6 +513,10 @@ 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)
|
||||||
@@ -935,6 +948,9 @@ 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",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1250,6 +1266,8 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
onToggleHistory={setShowHistory}
|
onToggleHistory={setShowHistory}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
error={error}
|
error={error}
|
||||||
|
minimalStyle={minimalStyle}
|
||||||
|
onMinimalStyleChange={setMinimalStyle}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -132,12 +132,90 @@ 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
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -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
|
## 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
|
* 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(modelId?: string): string {
|
export function getSystemPrompt(
|
||||||
|
modelId?: string,
|
||||||
|
minimalStyle?: boolean,
|
||||||
|
): string {
|
||||||
const modelName = modelId || "AI"
|
const modelName = modelId || "AI"
|
||||||
|
|
||||||
let prompt: string
|
let prompt: string
|
||||||
@@ -383,5 +418,15 @@ export function getSystemPrompt(modelId?: string): string {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
180
lib/utils.ts
180
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).
|
* Check if mxCell XML output is complete (not truncated).
|
||||||
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
* 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)
|
* @param xml - The XML string to check (can be undefined/null)
|
||||||
* @returns true if XML appears complete, false if truncated or empty
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
*/
|
*/
|
||||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
const trimmed = xml?.trim() || ""
|
let trimmed = xml?.trim() || ""
|
||||||
if (!trimmed) return false
|
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>")
|
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,
|
||||||
|
"&",
|
||||||
|
)
|
||||||
|
|
||||||
// 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")
|
||||||
@@ -813,6 +836,11 @@ export function validateMxCellStructure(xml: string): string | null {
|
|||||||
const doc = parser.parseFromString(xml, "text/xml")
|
const doc = parser.parseFromString(xml, "text/xml")
|
||||||
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,
|
||||||
|
)
|
||||||
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.`
|
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
|
// 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>" },
|
||||||
@@ -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
|
// 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)
|
||||||
@@ -1368,16 +1532,26 @@ 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
|
// Still invalid after fixes - but return the partially fixed XML
|
||||||
return { valid: false, error, fixed: null, fixes }
|
// 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 {
|
export function extractDiagramXML(xml_svg_string: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user