Compare commits

..

2 Commits

Author SHA1 Message Date
dayuan.jiang
ecb23be112 fix: improve XML auto-fix with malformed quote pattern
- Fix ="..." pattern where " was used as delimiter instead of actual quotes
- Common in dashPattern attributes like dashPattern="1 1;"
2025-12-13 20:52:44 +09:00
dayuan.jiang
39c0497762 refactor: extract XML validation/fix helpers and add constants
- Add constants: MAX_XML_SIZE (1MB), MAX_DROP_ITERATIONS (10), STRUCTURAL_ATTRS, VALID_ENTITIES
- Extract parseXmlTags helper for shared tag parsing logic
- Extract validation helpers: checkDuplicateAttributes, checkDuplicateIds, checkTagMismatches, checkCharacterReferences, checkEntityReferences, checkNestedMxCells
- Simplify validateMxCellStructure from ~200 lines to ~55 lines
- Add logging to empty catch block in DOMParser section
- Add size warning for large XML documents
- Remove unused variables (isSelfClose, duplicate idPattern)
2025-12-13 16:31:50 +09:00
35 changed files with 832 additions and 5621 deletions

View File

@@ -64,27 +64,3 @@ jobs:
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
# Push to AWS ECR for App Runner auto-deploy
- name: Configure AWS credentials
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
- name: Login to Amazon ECR
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Push to ECR (triggers App Runner auto-deploy)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
REPO_LOWER: ${{ github.repository }}
run: |
REPO_LOWER=$(echo "$REPO_LOWER" | tr '[:upper:]' '[:lower:]')
docker pull ghcr.io/${REPO_LOWER}:latest
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest

4
.gitignore vendored
View File

@@ -2,8 +2,6 @@
# dependencies # dependencies
/node_modules /node_modules
packages/*/node_modules
packages/*/dist
/.pnp /.pnp
.pnp.* .pnp.*
.yarn/* .yarn/*
@@ -48,5 +46,3 @@ push-via-ec2.sh
.dev.vars .dev.vars
.open-next/ .open-next/
.wrangler/ .wrangler/
.env*.local

View File

@@ -54,6 +54,6 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
# Start the application (HOSTNAME override needed for AWS App Runner) # Start the application
CMD ["sh", "-c", "HOSTNAME=0.0.0.0 exec node server.js"] CMD ["node", "server.js"]

View File

@@ -30,7 +30,6 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Examples](#examples) - [Examples](#examples)
- [Features](#features) - [Features](#features)
- [MCP Server (Preview)](#mcp-server-preview)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Try it Online](#try-it-online) - [Try it Online](#try-it-online)
- [Run with Docker (Recommended)](#run-with-docker-recommended) - [Run with Docker (Recommended)](#run-with-docker-recommended)
@@ -93,36 +92,6 @@ Here are some example prompts and their generated diagrams:
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure) - **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization - **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
## MCP Server (Preview)
> **Preview Feature**: This feature is experimental and may not stable.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
Then ask Claude to create diagrams:
> "Create a flowchart showing user authentication with login, MFA, and session management"
The diagram appears in your browser in real-time!
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
## Getting Started ## Getting Started
### Try it Online ### Try it Online
@@ -238,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

@@ -3,12 +3,10 @@ import {
convertToModelMessages, convertToModelMessages,
createUIMessageStream, createUIMessageStream,
createUIMessageStreamResponse, createUIMessageStreamResponse,
InvalidToolInputError,
LoadAPIKeyError, LoadAPIKeyError,
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai" } from "ai"
import { jsonrepair } from "jsonrepair"
import { z } from "zod" import { z } from "zod"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers" import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
@@ -70,45 +68,62 @@ function isMinimalDiagram(xml: string): boolean {
// Helper function to replace historical tool call XML with placeholders // Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth) // This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
function replaceHistoricalToolInputs(messages: any[]): any[] { function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => { return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) { if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg return msg
} }
const replacedContent = msg.content const replacedContent = msg.content.map((part: any) => {
.map((part: any) => { if (part.type === "tool-call") {
if (part.type === "tool-call") { const toolName = part.toolName
const toolName = part.toolName if (
// Fix invalid/undefined inputs from interrupted streaming toolName === "display_diagram" ||
if ( toolName === "edit_diagram"
!part.input || ) {
typeof part.input !== "object" || return {
Object.keys(part.input).length === 0 ...part,
) { input: {
// Skip tool calls with invalid inputs entirely placeholder:
return null "[XML content replaced - see current diagram XML in system context]",
} },
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
} }
} }
return part }
}) return part
.filter(Boolean) // Remove null entries (invalid tool calls) })
return { ...msg, content: replacedContent } return { ...msg, content: replacedContent }
}) })
} }
// Helper function to fix tool call inputs for Bedrock API
// Bedrock requires toolUse.input to be a JSON object, not a string
function fixToolCallInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const fixedContent = msg.content.map((part: any) => {
if (part.type === "tool-call") {
if (typeof part.input === "string") {
try {
const parsed = JSON.parse(part.input)
return { ...part, input: parsed }
} catch {
// If parsing fails, wrap the string in an object
return { ...part, input: { rawInput: part.input } }
}
}
// Input is already an object, but verify it's not null/undefined
if (part.input === null || part.input === undefined) {
return { ...part, input: {} }
}
}
return part
})
return { ...msg, content: fixedContent }
})
}
// Helper function to create cached stream response // Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response { function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}` const toolCallId = `cached-${Date.now()}`
@@ -171,9 +186,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
: undefined : undefined
// Extract user input text for Langfuse trace // Extract user input text for Langfuse trace
const lastMessage = messages[messages.length - 1] const currentMessage = messages[messages.length - 1]
const userInputText = const userInputText =
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || "" currentMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user // Update Langfuse trace with input, session, and user
setTraceInput({ setTraceInput({
@@ -214,9 +229,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)
@@ -228,7 +240,13 @@ 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)
const lastMessage = messages[messages.length - 1]
// Extract text from the last message parts
const lastMessageText =
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
// Extract file parts (images) from the last message // Extract file parts (images) from the last message
const fileParts = const fileParts =
@@ -237,49 +255,22 @@ async function handleChatRequest(req: Request): Promise<Response> {
// User input only - XML is now in a separate cached system message // User input only - XML is now in a separate cached system message
const formattedUserInput = `User input: const formattedUserInput = `User input:
"""md """md
${userInputText} ${lastMessageText}
"""` """`
// Convert UIMessages to ModelMessages and add system message // Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages) const modelMessages = convertToModelMessages(messages)
// DEBUG: Log incoming messages structure // Fix tool call inputs for Bedrock API (requires JSON objects, not strings)
console.log("[route.ts] Incoming messages count:", messages.length) const fixedMessages = fixToolCallInputs(modelMessages)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
// Replace historical tool call XML with placeholders to reduce tokens // Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML // Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
const enableHistoryReplace = const enableHistoryReplace =
process.env.ENABLE_HISTORY_XML_REPLACE === "true" process.env.ENABLE_HISTORY_XML_REPLACE === "true"
const placeholderMessages = enableHistoryReplace const placeholderMessages = enableHistoryReplace
? replaceHistoricalToolInputs(modelMessages) ? replaceHistoricalToolInputs(fixedMessages)
: modelMessages : fixedMessages
// Filter out messages with empty content arrays (Bedrock API rejects these) // Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases // This is a safety measure - ideally convertToModelMessages should handle all cases
@@ -288,63 +279,6 @@ ${userInputText}
msg.content && Array.isArray(msg.content) && msg.content.length > 0, msg.content && Array.isArray(msg.content) && msg.content.length > 0,
) )
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
enhancedMessages = enhancedMessages
.map((msg: any) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const filteredContent = msg.content.filter((part: any) => {
if (part.type === "tool-call") {
// Check if input is a valid object (not null, undefined, or empty)
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
console.warn(
`[route.ts] Filtering out tool-call with invalid input:`,
{ toolName: part.toolName, input: part.input },
)
return false
}
}
return true
})
return { ...msg, content: filteredContent }
})
.filter((msg: any) => msg.content && msg.content.length > 0)
// DEBUG: Log modelMessages structure (what's being sent to AI)
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (part.type === "tool-call" || part.type === "tool-result") {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
// Update the last message with user input only (XML moved to separate cached system message) // Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) { if (enhancedMessages.length >= 1) {
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1] const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
@@ -420,71 +354,7 @@ ${userInputText}
const result = streamText({ const result = streamText({
model, model,
...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}),
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => {
// DEBUG: Log what we're trying to repair
console.log(`[repairToolCall] Tool: ${toolCall.toolName}`)
console.log(
`[repairToolCall] Error: ${error.name} - ${error.message}`,
)
console.log(`[repairToolCall] Input type: ${typeof toolCall.input}`)
console.log(`[repairToolCall] Input value:`, toolCall.input)
// Only attempt repair for invalid tool input (broken JSON from truncation)
if (
error instanceof InvalidToolInputError ||
error.name === "AI_InvalidToolInputError"
) {
try {
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
let inputToRepair = toolCall.input
if (typeof inputToRepair === "string") {
// Fix `:=` instead of `: ` (LLM sometimes generates this)
inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "`
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
}
// Use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(inputToRepair)
console.log(
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
)
return { ...toolCall, input: repairedInput }
} catch (repairError) {
console.warn(
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
repairError,
)
// Return a placeholder input to avoid API errors in multi-step
// The tool will fail gracefully on client side
if (toolCall.toolName === "edit_diagram") {
return {
...toolCall,
input: {
operations: [],
_error: "JSON repair failed - no operations to apply",
},
}
}
if (toolCall.toolName === "display_diagram") {
return {
...toolCall,
input: {
xml: "",
_error: "JSON repair failed - empty diagram",
},
}
}
return null
}
}
// Don't attempt to repair other errors (like NoSuchToolError)
return null
},
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs ...(providerOptions && { providerOptions }), // This now includes all reasoning configs
...(headers && { headers }), ...(headers && { headers }),
@@ -495,6 +365,32 @@ ${userInputText}
userId, userId,
}), }),
}), }),
// Repair malformed tool calls (model sometimes generates invalid JSON with unescaped quotes)
experimental_repairToolCall: async ({ toolCall }) => {
// The toolCall.input contains the raw JSON string that failed to parse
const rawJson =
typeof toolCall.input === "string" ? toolCall.input : null
if (rawJson) {
try {
// Fix unescaped quotes: x="520" should be x=\"520\"
const fixed = rawJson.replace(
/([a-zA-Z])="(\d+)"/g,
'$1=\\"$2\\"',
)
const parsed = JSON.parse(fixed)
return {
type: "tool-call" as const,
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
input: JSON.stringify(parsed),
}
} catch {
// Repair failed, return null
}
}
return null
},
onFinish: ({ text, usage }) => { onFinish: ({ text, usage }) => {
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry) // Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
setTraceOutput(text, { setTraceOutput(text, {
@@ -505,32 +401,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>
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/> <mxCell id="0"/>
</mxCell> <mxCell id="1" parent="0"/>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1"> <mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/> <mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1"> <mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/> <mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2"> <mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/> <mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2"> <mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry relative="1" 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">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
Notes: Notes:
- For AWS diagrams, use **AWS 2025 icons**. - For AWS diagrams, use **AWS 2025 icons**.
@@ -543,56 +443,32 @@ Notes:
}), }),
}, },
edit_diagram: { edit_diagram: {
description: `Edit the current diagram by ID-based operations (update/add/delete cells). description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
IMPORTANT: Keep edits concise:
- COPY the exact mxCell line from the current XML (attribute order matters!)
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element
Operations: ⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`,
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell by its id. Only cell_id is needed.
For update/add, new_xml must be a complete mxCell element including mxGeometry.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
inputSchema: z.object({ inputSchema: z.object({
operations: z edits: z
.array( .array(
z.object({ z.object({
type: z search: z
.enum(["update", "add", "delete"])
.describe("Operation type"),
cell_id: z
.string() .string()
.describe( .describe(
"The id of the mxCell. Must match the id attribute in new_xml.", "EXACT lines copied from current XML (preserve attribute order!)",
), ),
new_xml: z replace: z
.string() .string()
.optional() .describe("Replacement lines"),
.describe(
"Complete mxCell XML element (required for update/add)",
),
}), }),
) )
.describe("Array of operations to apply"),
}),
},
append_diagram: {
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
CRITICAL INSTRUCTIONS:
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
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.`,
inputSchema: z.object({
xml: z
.string()
.describe( .describe(
"Continuation XML fragment to append (NO wrapper tags)", "Array of search/replace pairs to apply sequentially",
), ),
}), }),
}, },
@@ -620,7 +496,6 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
return { return {
inputTokens: totalInputTokens, inputTokens: totalInputTokens,
outputTokens: usage.outputTokens ?? 0, outputTokens: usage.outputTokens ?? 0,
finishReason: (part as any).finishReason,
} }
} }
return undefined return undefined

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

@@ -1,27 +0,0 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Next AI Draw.io',
short_name: 'AIDraw.io',
description: 'Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.',
start_url: '/',
display: 'standalone',
background_color: '#f9fafb',
theme_color: '#171d26',
icons: [
{
src: '/favicon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/favicon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
],
}
}

View File

@@ -1,5 +1,5 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
@@ -15,15 +15,8 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
drawioRef, useDiagram()
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -32,24 +25,6 @@ export default function Home() {
const [closeProtection, setCloseProtection] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isSavingRef = useRef(false)
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
useEffect(() => {
if (!showSaveDialog && isSavingRef.current) {
const timeout = setTimeout(() => {
isSavingRef.current = false
}, 1000)
return () => clearTimeout(timeout)
}
}, [showSaveDialog])
// Handle save from draw.io's built-in save button
const handleDrawioSave = useCallback(() => {
if (isSavingRef.current) return
isSavingRef.current = true
setShowSaveDialog(true)
}, [setShowSaveDialog])
// Load preferences from localStorage after mount // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
@@ -60,10 +35,12 @@ export default function Home() {
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode") const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) { if (savedDarkMode !== null) {
// Use saved preference
const isDark = savedDarkMode === "true" const isDark = savedDarkMode === "true"
setDarkMode(isDark) setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark) document.documentElement.classList.toggle("dark", isDark)
} else { } else {
// First visit: match browser preference
const prefersDark = window.matchMedia( const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)", "(prefers-color-scheme: dark)",
).matches ).matches
@@ -81,20 +58,12 @@ export default function Home() {
setIsLoaded(true) setIsLoaded(true)
}, []) }, [])
const handleDarkModeChange = async () => { const toggleDarkMode = () => {
await saveDiagramToStorage()
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue)) localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue) document.documentElement.classList.toggle("dark", newValue)
resetDrawioReady() // Reset so onDrawioLoad fires again after remount
}
const handleDrawioUiChange = async () => {
await saveDiagramToStorage()
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady() resetDrawioReady()
} }
@@ -175,7 +144,6 @@ export default function Home() {
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={onDrawioLoad} onLoad={onDrawioLoad}
onSave={handleDrawioSave}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
@@ -214,9 +182,15 @@ export default function Home() {
isVisible={isChatVisible} isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel} onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange} onToggleDrawioUi={() => {
const newUi =
drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady()
}}
darkMode={darkMode} darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange} onToggleDarkMode={toggleDarkMode}
isMobile={isMobile} isMobile={isMobile}
onCloseProtectionChange={setCloseProtection} onCloseProtectionChange={setCloseProtection}
/> />

View File

@@ -1,13 +1,6 @@
"use client" "use client"
import { import { Cloud, FileText, GitBranch, Palette, Zap } from "lucide-react"
Cloud,
FileText,
GitBranch,
Palette,
Terminal,
Zap,
} from "lucide-react"
interface ExampleCardProps { interface ExampleCardProps {
icon: React.ReactNode icon: React.ReactNode
@@ -115,33 +108,6 @@ export default function ExamplePanel({
return ( return (
<div className="py-6 px-2 animate-fade-in"> <div className="py-6 px-2 animate-fade-in">
{/* MCP Server Notice */}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
MCP Server
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
PREVIEW
</span>
</div>
<p className="text-xs text-muted-foreground">
Use in Claude Desktop, VS Code & Cursor
</p>
</div>
</div>
</a>
{/* Welcome section */} {/* Welcome section */}
<div className="text-center mb-6"> <div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2"> <h2 className="text-lg font-semibold text-foreground mb-2">

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,19 +144,13 @@ export function ChatInput({
onToggleHistory = () => {}, onToggleHistory = () => {},
sessionId, sessionId,
error = null, error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const { const { diagramHistory, saveDiagramToFile } = useDiagram()
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false) const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = const isDisabled =
@@ -357,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 */}
@@ -405,7 +365,7 @@ export function ChatInput({
size="sm" size="sm"
onClick={() => setShowSaveDialog(true)} onClick={() => setShowSaveDialog(true)}
disabled={isDisabled} disabled={isDisabled}
tooltipContent="Save diagram (deprecated: use Save button in upper right corner of draw.io)" tooltipContent="Save diagram"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />

View File

@@ -10,7 +10,9 @@ import {
Cpu, Cpu,
FileCode, FileCode,
FileText, FileText,
Minus,
Pencil, Pencil,
Plus,
RotateCcw, RotateCcw,
ThumbsDown, ThumbsDown,
ThumbsUp, ThumbsUp,
@@ -27,37 +29,13 @@ 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"
applyDiagramOperations,
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"
interface DiagramOperation { interface EditPair {
type: "update" | "add" | "delete" search: string
cell_id: string replace: string
new_xml?: string
}
// Helper to extract complete operations from streaming input
function getCompleteOperations(
operations: DiagramOperation[] | undefined,
): DiagramOperation[] {
if (!operations || !Array.isArray(operations)) return []
return operations.filter(
(op) =>
op &&
typeof op.type === "string" &&
["update", "add", "delete"].includes(op.type) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do
(op.type === "delete" || typeof op.new_xml === "string"),
)
} }
// Tool part interface for type safety // Tool part interface for type safety
@@ -65,44 +43,49 @@ interface ToolPartLike {
type: string type: string
toolCallId: string toolCallId: string
state?: string state?: string
input?: { input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string output?: string
} }
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) { function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{operations.map((op, index) => ( {edits.map((edit, index) => (
<div <div
key={`${op.type}-${op.cell_id}-${index}`} key={`${(edit.search || "").slice(0, 50)}-${(edit.replace || "").slice(0, 50)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50" className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
> >
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span <span className="text-xs font-medium text-muted-foreground">
className={`text-[10px] font-medium uppercase tracking-wide ${ Change {index + 1}
op.type === "delete"
? "text-red-600"
: op.type === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.type}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span> </span>
</div> </div>
{op.new_xml && ( <div className="divide-y divide-border/30">
{/* Search (old) */}
<div className="px-3 py-2"> <div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all"> <div className="flex items-center gap-1.5 mb-1.5">
{op.new_xml} <Minus className="w-3 h-3 text-red-500" />
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
Remove
</span>
</div>
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.search}
</pre> </pre>
</div> </div>
)} {/* Replace (new) */}
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<Plus className="w-3 h-3 text-green-500" />
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
Add
</span>
</div>
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.replace}
</pre>
</div>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -185,7 +168,6 @@ interface ChatMessageDisplayProps {
setInput: (input: string) => void setInput: (input: string) => void
setFiles: (files: File[]) => void setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>> processedToolCallsRef: MutableRefObject<Set<string>>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
sessionId?: string sessionId?: string
onRegenerate?: (messageIndex: number) => void onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void onEditMessage?: (messageIndex: number, newText: string) => void
@@ -197,7 +179,6 @@ export function ChatMessageDisplay({
setInput, setInput,
setFiles, setFiles,
processedToolCallsRef, processedToolCallsRef,
editDiagramOriginalXmlRef,
sessionId, sessionId,
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
@@ -207,22 +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
// Refs for edit_diagram streaming
const pendingEditRef = useRef<{
operations: DiagramOperation[]
toolCallId: string
} | null>(null)
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>( const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{}, {},
) )
@@ -323,14 +288,11 @@ export function ChatMessageDisplay({
const parseError = testDoc.querySelector("parsererror") const parseError = testDoc.querySelector("parsererror")
if (parseError) { if (parseError) {
// Use console.warn instead of console.error to avoid triggering console.error(
// Next.js dev mode error overlay for expected streaming states "[ChatMessageDisplay] Malformed XML detected - skipping update",
// (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.",
) )
@@ -402,12 +364,7 @@ export function ChatMessageDisplay({
}, [editingMessageId]) }, [editingMessageId])
useEffect(() => { useEffect(() => {
// Only process the last message for streaming performance messages.forEach((message) => {
// 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-")) {
@@ -426,194 +383,26 @@ 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) {
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) {
handleDisplayChart(
pendingXml,
false,
)
lastProcessedXmlRef.current.set(
toolCallId,
pendingXml,
)
}
},
STREAMING_DEBOUNCE_MS,
)
}
} 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)
}
}
// Handle edit_diagram streaming - apply operations incrementally for preview
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
if (
part.type === "tool-edit_diagram" &&
input?.operations
) {
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) return
// Capture original XML when streaming starts (store in shared ref)
if (
!editDiagramOriginalXmlRef.current.has(
toolCallId,
)
) {
if (!chartXML) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
return
}
editDiagramOriginalXmlRef.current.set(
toolCallId,
chartXML,
)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (!originalXml) return
// Skip if no change from last processed state
const lastCount = lastProcessedXmlRef.current.get(
toolCallId + "-opCount",
)
if (lastCount === String(completeOps.length)) return
if (
state === "input-streaming" ||
state === "input-available"
) {
// Queue the operations for debounced processing
pendingEditRef.current = {
operations: completeOps,
toolCallId,
}
if (!editDebounceTimeoutRef.current) {
editDebounceTimeoutRef.current = setTimeout(
() => {
const pending =
pendingEditRef.current
editDebounceTimeoutRef.current =
null
pendingEditRef.current = null
if (pending) {
const origXml =
editDiagramOriginalXmlRef.current.get(
pending.toolCallId,
)
if (!origXml) return
try {
const {
result: editedXml,
} = applyDiagramOperations(
origXml,
pending.operations,
)
handleDisplayChart(
editedXml,
false,
)
lastProcessedXmlRef.current.set(
pending.toolCallId +
"-opCount",
String(
pending.operations
.length,
),
)
} catch (e) {
console.warn(
`[edit_diagram streaming] Operation failed:`,
e instanceof Error
? e.message
: e,
)
}
}
},
STREAMING_DEBOUNCE_MS,
)
}
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final state - cleanup streaming refs (tool handler does final application)
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
)
processedToolCalls.current.add(toolCallId)
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
} }
} }
} }
}) })
} }
}) })
}, [messages, handleDisplayChart])
// Cleanup: clear any pending debounce timeout on unmount
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
}
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
}
}, [messages, handleDisplayChart, chartXML])
const renderToolPart = (part: ToolPartLike) => { const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId const callId = part.toolCallId
@@ -662,23 +451,11 @@ export function ChatMessageDisplay({
Complete Complete
</span> </span>
)} )}
{state === "output-error" && {state === "output-error" && (
(() => { <span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
// Check if this is a truncation (incomplete XML) vs real error Error
const isTruncated = </span>
(toolName === "display_diagram" || )}
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
{input && Object.keys(input).length > 0 && ( {input && Object.keys(input).length > 0 && (
<button <button
type="button" type="button"
@@ -699,9 +476,9 @@ export function ChatMessageDisplay({
{typeof input === "object" && input.xml ? ( {typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" /> <CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" && ) : typeof input === "object" &&
input.operations && input.edits &&
Array.isArray(input.operations) ? ( Array.isArray(input.edits) ? (
<OperationsDisplay operations={input.operations} /> <EditDiffDisplay edits={input.edits} />
) : typeof input === "object" && ) : typeof input === "object" &&
Object.keys(input).length > 0 ? ( Object.keys(input).length > 0 ? (
<CodeBlock <CodeBlock
@@ -711,23 +488,11 @@ export function ChatMessageDisplay({
) : null} ) : null}
</div> </div>
)} )}
{output && {output && state === "output-error" && (
state === "output-error" && <div className="px-4 py-3 border-t border-border/40 text-sm text-red-600">
(() => { {output}
const isTruncated = </div>
(toolName === "display_diagram" || )}
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
</div> </div>
) )
} }

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
} }
@@ -64,11 +63,11 @@ interface ChatPanelProps {
// Constants for tool states // Constants for tool states
const TOOL_ERROR_STATE = "output-error" as const const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1 const MAX_AUTO_RETRY_COUNT = 3
/** /**
* Check if auto-resubmit should happen based on tool errors. * Check if auto-resubmit should happen based on tool errors.
* Only checks the LAST tool part (most recent tool call), not all tool parts. * Does NOT handle retry count or quota - those are handled by the caller.
*/ */
function hasToolErrors(messages: ChatMessage[]): boolean { function hasToolErrors(messages: ChatMessage[]): boolean {
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
@@ -85,8 +84,7 @@ function hasToolErrors(messages: ChatMessage[]): boolean {
return false return false
} }
const lastToolPart = toolParts[toolParts.length - 1] return toolParts.some((part) => part.state === TOOL_ERROR_STATE)
return lastToolPart?.state === TOOL_ERROR_STATE
} }
export default function ChatPanel({ export default function ChatPanel({
@@ -146,7 +144,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(() => {
@@ -195,26 +192,9 @@ export default function ChatPanel({
// Ref to track consecutive auto-retry count (reset on user action) // Ref to track consecutive auto-retry count (reset on user action)
const autoRetryCountRef = useRef(0) const autoRetryCountRef = useRef(0)
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
// When partialXmlRef.current.length > 0, we're in continuation mode
const partialXmlRef = useRef<string>("")
// Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs // Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs
const processedToolCallsRef = useRef<Set<string>>(new Set()) const processedToolCallsRef = useRef<Set<string>>(new Set())
// Store original XML for edit_diagram streaming - shared between streaming preview and tool handler
// Key: toolCallId, Value: original XML before any operations applied
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
// Debounce timeout for localStorage writes (prevents blocking during streaming)
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,
@@ -236,50 +216,14 @@ 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 }
if (DEBUG) {
// DEBUG: Log raw input to diagnose false truncation detection console.log(
console.log( `[display_diagram] Received XML length: ${xml.length}`,
"[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
partialXmlRef.current = xml
// Tell LLM to use append_diagram to continue
const partialEnding = partialXmlRef.current.slice(-500)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
Your output ended with:
\`\`\`
${partialEnding}
\`\`\`
NEXT STEP: Call append_diagram with the continuation XML.
- Do NOT include wrapper tags or root cells (id="0", id="1")
- Start from EXACTLY where you stopped
- Complete all remaining mxCell elements`,
})
return
} }
// Complete XML received - use it directly
// (continuation is now handled via append_diagram tool)
const finalXml = xml
partialXmlRef.current = "" // Reset any partial from previous truncation
// Wrap raw XML with full mxfile structure for draw.io // Wrap raw XML with full mxfile structure for draw.io
const fullXml = wrapWithMxFile(finalXml) const fullXml = wrapWithMxFile(xml)
// loadDiagram validates and returns error if invalid // loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(fullXml) const validationError = onDisplayChart(fullXml)
@@ -305,7 +249,7 @@ Please fix the XML issues and call display_diagram again with corrected XML.
Your failed XML: Your failed XML:
\`\`\`xml \`\`\`xml
${finalXml} ${xml}
\`\`\``, \`\`\``,
}) })
} else { } else {
@@ -327,69 +271,38 @@ ${finalXml}
} }
} }
} else if (toolCall.toolName === "edit_diagram") { } else if (toolCall.toolName === "edit_diagram") {
const { operations } = toolCall.input as { const { edits } = toolCall.input as {
operations: Array<{ edits: Array<{ search: string; replace: string }>
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}>
} }
let currentXml = "" let currentXml = ""
try { try {
// Use the original XML captured during streaming (shared with chat-message-display) console.log("[edit_diagram] Starting...")
// This ensures we apply operations to the same base XML that streaming used // Use chartXML from ref directly - more reliable than export
const originalXml = editDiagramOriginalXmlRef.current.get( // especially on Vercel where DrawIO iframe may have latency issues
toolCall.toolCallId, // Using ref to avoid stale closure in callback
) const cachedXML = chartXMLRef.current
if (originalXml) { if (cachedXML) {
currentXml = originalXml currentXml = cachedXML
} else { console.log(
// Fallback: use chartXML from ref if streaming didn't capture original "[edit_diagram] Using cached chartXML, length:",
const cachedXML = chartXMLRef.current currentXml.length,
if (cachedXML) { )
currentXml = cachedXML } else {
} else { // Fallback to export only if no cached XML
// Last resort: export from iframe console.log(
currentXml = await onFetchChart(false) "[edit_diagram] No cached XML, fetching from DrawIO...",
} )
} currentXml = await onFetchChart(false)
console.log(
const { applyDiagramOperations } = await import( "[edit_diagram] Got XML from export, length:",
"@/lib/utils" currentXml.length,
)
const { result: editedXml, errors } =
applyDiagramOperations(currentXml, operations)
// Check for operation errors
if (errors.length > 0) {
const errorMessages = errors
.map(
(e) =>
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
)
.join("\n")
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Some operations failed:\n${errorMessages}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please check the cell IDs and retry.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
) )
return
} }
const { replaceXMLParts } = await import("@/lib/utils")
const editedXml = replaceXMLParts(currentXml, edits)
// loadDiagram validates and returns error if invalid // loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(editedXml) const validationError = onDisplayChart(editedXml)
if (validationError) { if (validationError) {
@@ -408,30 +321,24 @@ Current diagram XML:
${currentXml} ${currentXml}
\`\`\` \`\`\`
Please fix the operations to avoid structural issues.`, Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid references).`,
}) })
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
return return
} }
onExport() onExport()
addToolOutput({ addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${operations.length} operation(s) to the diagram.`, output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
}) })
// Clean up the shared original XML ref console.log("[edit_diagram] Success")
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
} catch (error) { } catch (error) {
console.error("[edit_diagram] Failed:", error) console.error("[edit_diagram] Failed:", error)
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
// Use addToolOutput with state: 'output-error' for proper error signaling
addToolOutput({ addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
@@ -443,92 +350,7 @@ Current diagram XML:
${currentXml || "No XML available"} ${currentXml || "No XML available"}
\`\`\` \`\`\`
Please check cell IDs and retry, or use display_diagram to regenerate.`, Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
})
// Clean up the shared original XML ref even on error
editDiagramOriginalXmlRef.current.delete(
toolCall.toolCallId,
)
}
} else if (toolCall.toolName === "append_diagram") {
const { xml } = toolCall.input as { xml: string }
// 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 =
trimmed.startsWith("<mxGraphModel") ||
trimmed.startsWith("<root") ||
trimmed.startsWith("<mxfile") ||
trimmed.startsWith('<mxCell id="0"') ||
trimmed.startsWith('<mxCell id="1"')
if (isFreshStart) {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
Continue from EXACTLY where the partial ended:
\`\`\`
${partialXmlRef.current.slice(-500)}
\`\`\`
Start your continuation with the NEXT character after where it stopped.`,
})
return
}
// Append to accumulated XML
partialXmlRef.current += xml
// Check if XML is now complete (last mxCell is complete)
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
if (isComplete) {
// Wrap and display the complete diagram
const finalXml = partialXmlRef.current
partialXmlRef.current = "" // Reset
const fullXml = wrapWithMxFile(finalXml)
const validationError = onDisplayChart(fullXml)
if (validationError) {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Validation error after assembly: ${validationError}
Assembled XML:
\`\`\`xml
${finalXml.substring(0, 2000)}...
\`\`\`
Please use display_diagram with corrected XML.`,
})
} else {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
output: "Diagram assembly complete and displayed successfully.",
})
}
} else {
// Still incomplete - signal to continue
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
Current ending:
\`\`\`
${partialXmlRef.current.slice(-500)}
\`\`\`
Continue from EXACTLY where you stopped.`,
}) })
} }
} }
@@ -537,32 +359,6 @@ Continue from EXACTLY where you stopped.`,
// Silence access code error in console since it's handled by UI // Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) { if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error) console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
}
})
} }
// Translate technical errors into user-friendly messages // Translate technical errors into user-friendly messages
@@ -575,12 +371,6 @@ Continue from EXACTLY where you stopped.`,
friendlyMessage = "Network error. Please check your connection." friendlyMessage = "Network error. Please check your connection."
} }
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error // Translate image not supported error
if (friendlyMessage.includes("image content block")) { if (friendlyMessage.includes("image content block")) {
friendlyMessage = "This model doesn't support image input." friendlyMessage = "This model doesn't support image input."
@@ -608,11 +398,6 @@ Continue from EXACTLY where you stopped.`,
const metadata = message?.metadata as const metadata = message?.metadata as
| 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)
@@ -629,55 +414,65 @@ Continue from EXACTLY where you stopped.`,
} }
}, },
sendAutomaticallyWhen: ({ messages }) => { sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors( const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[], messages as unknown as ChatMessage[],
) )
if (!shouldRetry) { if (!shouldRetry) {
// No error, reset retry count and clear state // No error, reset retry count
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
partialXmlRef.current = "" if (DEBUG) {
console.log("[sendAutomaticallyWhen] No errors, stopping")
}
return false return false
} }
// Continuation mode: unlimited retries (truncation continuation, not real errors) // Check retry count limit
// Server limits to 5 steps via stepCountIs(5) if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
if (isInContinuationMode) { if (DEBUG) {
// Don't count against retry limit for continuation console.log(
// Quota checks still apply below `[sendAutomaticallyWhen] Max retry count (${MAX_AUTO_RETRY_COUNT}) reached, stopping`,
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
) )
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
} }
// Increment retry count for actual errors toast.error(
autoRetryCountRef.current++ `Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
)
autoRetryCountRef.current = 0
return false
} }
// Check quota limits before auto-retry // Check quota limits before auto-retry
const tokenLimitCheck = quotaManager.checkTokenLimit() const tokenLimitCheck = quotaManager.checkTokenLimit()
if (!tokenLimitCheck.allowed) { if (!tokenLimitCheck.allowed) {
if (DEBUG) {
console.log(
"[sendAutomaticallyWhen] Token limit exceeded, stopping",
)
}
quotaManager.showTokenLimitToast(tokenLimitCheck.used) quotaManager.showTokenLimitToast(tokenLimitCheck.used)
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false return false
} }
const tpmCheck = quotaManager.checkTPMLimit() const tpmCheck = quotaManager.checkTPMLimit()
if (!tpmCheck.allowed) { if (!tpmCheck.allowed) {
if (DEBUG) {
console.log(
"[sendAutomaticallyWhen] TPM limit exceeded, stopping",
)
}
quotaManager.showTPMLimitToast() quotaManager.showTPMLimitToast()
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false return false
} }
// Increment retry count and allow retry
autoRetryCountRef.current++
if (DEBUG) {
console.log(
`[sendAutomaticallyWhen] Retrying (${autoRetryCountRef.current}/${MAX_AUTO_RETRY_COUNT})`,
)
}
return true return true
}, },
}) })
@@ -718,10 +513,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])
@@ -766,54 +557,21 @@ 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
try {
// Clear any pending save localStorage.setItem(STORAGE_MESSAGES_KEY, JSON.stringify(messages))
if (localStorageDebounceRef.current) { } catch (error) {
clearTimeout(localStorageDebounceRef.current) console.error("Failed to save messages to localStorage:", error)
}
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(() => {
try {
localStorage.setItem(
STORAGE_MESSAGES_KEY,
JSON.stringify(messages),
)
} catch (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(() => {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML) localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}, LOCAL_STORAGE_DEBOUNCE_MS)
return () => {
if (xmlStorageDebounceRef.current) {
clearTimeout(xmlStorageDebounceRef.current)
}
} }
}, [chartXML, canSaveDiagram]) }, [chartXML, canSaveDiagram])
@@ -1059,9 +817,8 @@ Continue from EXACTLY where you stopped.`,
previousXml: string, previousXml: string,
sessionId: string, sessionId: string,
) => { ) => {
// Reset all retry/continuation state on user-initiated message // Reset auto-retry count on user-initiated message
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
partialXmlRef.current = ""
const config = getAIConfig() const config = getAIConfig()
@@ -1081,9 +838,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",
}),
}, },
}, },
) )
@@ -1269,7 +1023,6 @@ Continue from EXACTLY where you stopped.`,
style: { style: {
maxWidth: "480px", maxWidth: "480px",
}, },
duration: 2000,
}} }}
/> />
{/* Header */} {/* Header */}
@@ -1375,7 +1128,6 @@ Continue from EXACTLY where you stopped.`,
setInput={setInput} setInput={setInput}
setFiles={handleFileChange} setFiles={handleFileChange}
processedToolCallsRef={processedToolCallsRef} processedToolCallsRef={processedToolCallsRef}
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
sessionId={sessionId} sessionId={sessionId}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
status={status} status={status}
@@ -1400,8 +1152,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

@@ -23,12 +23,9 @@ interface DiagramContextType {
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
) => void ) => void
saveDiagramToStorage: () => Promise<void>
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void resetDrawioReady: () => void
showSaveDialog: boolean
setShowSaveDialog: (show: boolean) => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined) const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -40,7 +37,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
{ svg: string; xml: string }[] { svg: string; xml: string }[]
>([]) >([])
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null) const resolverRef = useRef<((value: string) => void) | null>(null)
@@ -86,30 +82,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// Save current diagram to localStorage (used before theme/UI changes)
const saveDiagramToStorage = async (): Promise<void> => {
if (!drawioRef.current) return
try {
const currentXml = await Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 2000),
),
])
// Only save if diagram has meaningful content (not empty template)
if (currentXml && currentXml.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
}
} catch (error) {
console.error("Failed to save diagram to storage:", error)
}
}
const loadDiagram = ( const loadDiagram = (
chart: string, chart: string,
skipValidation?: boolean, skipValidation?: boolean,
@@ -166,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
} }
@@ -308,12 +274,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport, handleDiagramExport,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
saveDiagramToStorage,
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,
showSaveDialog,
setShowSaveDialog,
}} }}
> >
{children} {children}

View File

@@ -26,7 +26,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目录](#目录) - [目录](#目录)
- [示例](#示例) - [示例](#示例)
- [功能特性](#功能特性) - [功能特性](#功能特性)
- [MCP服务器预览](#mcp服务器预览)
- [快速开始](#快速开始) - [快速开始](#快速开始)
- [在线试用](#在线试用) - [在线试用](#在线试用)
- [使用Docker运行推荐](#使用docker运行推荐) - [使用Docker运行推荐](#使用docker运行推荐)
@@ -89,36 +88,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **云架构图支持**专门支持生成云架构图AWS、GCP、Azure - **云架构图支持**专门支持生成云架构图AWS、GCP、Azure
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果 - **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器预览
> **预览功能**:此功能为实验性功能,可能会有变化。
通过MCP模型上下文协议在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
然后让Claude创建图表
> "创建一个展示用户认证流程的流程图包含登录、MFA和会话管理"
图表会实时显示在浏览器中!
详情请参阅[MCP服务器README](../packages/mcp-server/README.md)了解VS Code、Cursor等客户端配置。
## 快速开始 ## 快速开始
### 在线试用 ### 在线试用

View File

@@ -26,7 +26,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目次](#目次) - [目次](#目次)
- [](#例) - [](#例)
- [機能](#機能) - [機能](#機能)
- [MCPサーバープレビュー](#mcpサーバープレビュー)
- [はじめに](#はじめに) - [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す) - [オンラインで試す](#オンラインで試す)
- [Dockerで実行推奨](#dockerで実行推奨) - [Dockerで実行推奨](#dockerで実行推奨)
@@ -89,36 +88,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure - **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成 - **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバープレビュー
> **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
MCPModel Context Protocolを介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
Claudeにダイアグラムの作成を依頼
> 「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを作成してください」
ダイアグラムがリアルタイムでブラウザに表示されます!
詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧くださいVS Code、Cursorなどのクライアント設定も含む
## はじめに ## はじめに
### オンラインで試す ### オンラインで試す

View File

@@ -136,23 +136,6 @@ Optional custom URL:
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
``` ```
### Vercel AI Gateway
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
```bash
AI_GATEWAY_API_KEY=your_gateway_api_key
AI_MODEL=openai/gpt-4o
```
Model format uses `provider/model` syntax:
- `openai/gpt-4o` - OpenAI GPT-4o
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
## Auto-Detection ## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`. If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -160,7 +143,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`: If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama, gateway AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama
``` ```
## Model Capability Requirements ## Model Capability Requirements

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway # Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
@@ -68,11 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-... # SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed # SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# Vercel AI Gateway Configuration
# Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
# AI_GATEWAY_API_KEY=...
# Langfuse Observability (Optional) # Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com # Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-... # LANGFUSE_PUBLIC_KEY=pk-lf-...

View File

@@ -2,7 +2,6 @@ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic" import { createAnthropic } from "@ai-sdk/anthropic"
import { azure, createAzure } from "@ai-sdk/azure" import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createOpenAI, openai } from "@ai-sdk/openai" import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
@@ -19,7 +18,6 @@ export type ProviderName =
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow" | "siliconflow"
| "gateway"
interface ModelConfig { interface ModelConfig {
model: any model: any
@@ -44,7 +42,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openrouter", "openrouter",
"deepseek", "deepseek",
"siliconflow", "siliconflow",
"gateway",
] ]
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -336,10 +333,8 @@ function buildProviderOptions(
case "deepseek": case "deepseek":
case "openrouter": case "openrouter":
case "siliconflow": case "siliconflow": {
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break break
} }
@@ -361,7 +356,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY", deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY", siliconflow: "SILICONFLOW_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
} }
/** /**
@@ -444,16 +438,6 @@ function validateProviderCredentials(provider: ProviderName): void {
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1) * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
*/ */
export function getAIModel(overrides?: ClientOverrides): ModelConfig { export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
// If a custom baseUrl is provided, an API key MUST also be provided.
// This prevents attackers from redirecting server API keys to malicious endpoints.
if (overrides?.baseUrl && !overrides?.apiKey) {
throw new Error(
`API key is required when using a custom base URL. ` +
`Please provide your own API key in Settings.`,
)
}
// Check if client is providing their own provider override // Check if client is providing their own provider override
const isClientOverride = !!(overrides?.provider && overrides?.apiKey) const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
@@ -501,7 +485,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
if (configured.length === 0) { if (configured.length === 0) {
throw new Error( throw new Error(
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` + `No AI provider configured. Please set one of the following API keys in your .env.local file:\n` +
`- AI_GATEWAY_API_KEY for Vercel AI Gateway\n` +
`- DEEPSEEK_API_KEY for DeepSeek\n` + `- DEEPSEEK_API_KEY for DeepSeek\n` +
`- OPENAI_API_KEY for OpenAI\n` + `- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` + `- ANTHROPIC_API_KEY for Anthropic\n` +
@@ -679,17 +662,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "gateway": {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway
model = gateway(modelId)
break
}
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow`,
) )
} }

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

@@ -42,18 +42,11 @@ description: Edit specific parts of the EXISTING diagram. Use this when making s
parameters: { parameters: {
edits: Array<{search: string, replace: string}> edits: Array<{search: string, replace: string}>
} }
---Tool3---
tool name: append_diagram
description: Continue generating diagram XML when display_diagram was truncated due to output length limits. Only use this after display_diagram truncation.
parameters: {
xml: string // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)
}
---End of tools--- ---End of tools---
IMPORTANT: Choose the right tool: IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty - Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items - Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
Core capabilities: Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams - Generate valid, well-formed XML strings for draw.io diagrams
@@ -88,33 +81,38 @@ Note that:
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns. - NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool: When using edit_diagram tool:
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id) - CRITICAL: Copy search patterns EXACTLY from the "Current diagram XML" in system context - attribute order matters!
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry) - Always include the element's id attribute for unique targeting: {"search": "<mxCell id=\\"5\\"", ...}
- For delete: only cell_id is needed - Include complete elements (mxCell + mxGeometry) for reliable matching
- Find the cell_id from "Current diagram XML" in system context - Preserve exact whitespace, indentation, and line breaks
- Example update: {"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} - BAD: {"search": "value=\\"Label\\"", ...} - too vague, matches multiple elements
- Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]} - GOOD: {"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"...\\">", "replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"...\\">"}
- Example add: {"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} - For multiple changes, use separate edits in array
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" ⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values:
- CORRECT: "y=\\"119\\"" (both quotes escaped)
- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!)
- Every " inside a JSON string value needs \\" - no exceptions!
## 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
@@ -128,6 +126,122 @@ 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>
\`\`\`
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
`
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
2. Every mxCell needs a unique id attribute
3. Every mxCell (except id="0") needs a valid parent attribute referencing an existing cell
4. Edge source/target attributes must reference existing cell IDs
5. 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** (note: all mxCells are siblings under <root>):
\`\`\`xml
<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"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\`
### edit_diagram Details
**CRITICAL RULES:**
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
- Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element
**Input Format:**
\`\`\`json
{
"edits": [
{
"search": "EXACT lines copied from current XML (preserve attribute order!)",
"replace": "Replacement lines"
}
]
}
\`\`\`
## edit_diagram Best Practices
### Core Principle: Unique & Precise Patterns
Your search pattern MUST uniquely identify exactly ONE location in the XML. Before writing a search pattern:
1. Review the "Current diagram XML" in the system context
2. Identify the exact element(s) to modify by their unique id attribute
3. Include enough context to ensure uniqueness
### Pattern Construction Rules
**Rule 1: Always include the element's id attribute**
\`\`\`json
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
\`\`\`
**Rule 2: Include complete XML elements when possible**
\`\`\`json
{
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\`
**Rule 3: Preserve exact whitespace and formatting**
Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order.
### Good vs Bad Patterns
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
### ⚠️ JSON Escaping (CRITICAL)
Every double quote inside JSON string values MUST be escaped with backslash:
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error!
### Error Recovery
If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML
2. **Second retry**: Expand context - include more surrounding lines
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
### Edge Routing Rules: ### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines: When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
@@ -177,135 +291,6 @@ When creating edges/connectors, you MUST follow these rules to avoid overlapping
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead 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 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
`
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated):
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
2. All mxCell elements must be siblings - never nested inside other mxCell elements
3. Every mxCell needs a unique id attribute (start from "2")
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
5. Edge source/target attributes must reference existing cell IDs
6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
\`\`\`xml
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
### append_diagram Details
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
**CRITICAL RULES:**
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
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.
### edit_diagram Details
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
**Operations:**
- **update**: Replace an existing cell. Provide cell_id and new_xml.
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
- **delete**: Remove a cell. Only cell_id is needed.
**Input Format:**
\`\`\`json
{
"operations": [
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
{"type": "delete", "cell_id": "5"}
]
}
\`\`\`
**Examples:**
Change label:
\`\`\`json
{"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\`
Add new shape:
\`\`\`json
{"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\`
Delete cell:
\`\`\`json
{"operations": [{"type": "delete", "cell_id": "5"}]}
\`\`\`
**Error Recovery:**
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
## Edge Examples ## Edge Examples
### Two edges between same nodes (CORRECT - no overlap): ### Two edges between same nodes (CORRECT - no overlap):
@@ -358,16 +343,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
@@ -388,15 +369,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>`
} }
/** /**
@@ -377,223 +323,303 @@ export function replaceNodes(currentXML: string, nodes: string): string {
} }
} }
// ============================================================================ /**
// ID-based Diagram Operations * Create a character count dictionary from a string
// ============================================================================ * Used for attribute-order agnostic comparison
*/
export interface DiagramOperation { function charCountDict(str: string): Map<string, number> {
type: "update" | "add" | "delete" const dict = new Map<string, number>()
cell_id: string for (const char of str) {
new_xml?: string dict.set(char, (dict.get(char) || 0) + 1)
} }
return dict
export interface OperationError {
type: "update" | "add" | "delete"
cellId: string
message: string
}
export interface ApplyOperationsResult {
result: string
errors: OperationError[]
} }
/** /**
* Apply diagram operations (update/add/delete) using ID-based lookup. * Compare two strings by character frequency (order-agnostic)
* This replaces the text-matching approach with direct DOM manipulation.
*
* @param xmlContent - The full mxfile XML content
* @param operations - Array of operations to apply
* @returns Object with result XML and any errors
*/ */
export function applyDiagramOperations( function sameCharFrequency(a: string, b: string): boolean {
const trimmedA = a.trim()
const trimmedB = b.trim()
if (trimmedA.length !== trimmedB.length) return false
const dictA = charCountDict(trimmedA)
const dictB = charCountDict(trimmedB)
if (dictA.size !== dictB.size) return false
for (const [char, count] of dictA) {
if (dictB.get(char) !== count) return false
}
return true
}
/**
* Replace specific parts of XML content using search and replace pairs
* @param xmlContent - The original XML string
* @param searchReplacePairs - Array of {search: string, replace: string} objects
* @returns The updated XML string with replacements applied
*/
export function replaceXMLParts(
xmlContent: string, xmlContent: string,
operations: DiagramOperation[], searchReplacePairs: Array<{ search: string; replace: string }>,
): ApplyOperationsResult { ): string {
const errors: OperationError[] = [] // Format the XML first to ensure consistent line breaks
let result = formatXML(xmlContent)
// Parse the XML for (const { search, replace } of searchReplacePairs) {
const parser = new DOMParser() // Also format the search content for consistency
const doc = parser.parseFromString(xmlContent, "text/xml") const formattedSearch = formatXML(search)
const searchLines = formattedSearch.split("\n")
// Check for parse errors // Split into lines for exact line matching
const parseError = doc.querySelector("parsererror") const resultLines = result.split("\n")
if (parseError) {
return { // Remove trailing empty line if exists (from the trailing \n in search content)
result: xmlContent, if (searchLines[searchLines.length - 1] === "") {
errors: [ searchLines.pop()
{
type: "update",
cellId: "",
message: `XML parse error: ${parseError.textContent}`,
},
],
} }
}
// Find the root element (inside mxGraphModel) // Always search from the beginning - pairs may not be in document order
const root = doc.querySelector("root") const startLineNum = 0
if (!root) {
return { // Try to find match using multiple strategies
result: xmlContent, let matchFound = false
errors: [ let matchStartLine = -1
{ let matchEndLine = -1
type: "update",
cellId: "", // First try: exact match
message: "Could not find <root> element in XML", for (
}, let i = startLineNum;
], i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
if (resultLines[i + j] !== searchLines[j]) {
matches = false
break
}
}
if (matches) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
} }
}
// Build a map of cell IDs to elements // Second try: line-trimmed match (fallback)
const cellMap = new Map<string, Element>() if (!matchFound) {
root.querySelectorAll("mxCell").forEach((cell) => { for (
const id = cell.getAttribute("id") let i = startLineNum;
if (id) cellMap.set(id, cell) i <= resultLines.length - searchLines.length;
}) i++
) {
let matches = true
// Process each operation for (let j = 0; j < searchLines.length; j++) {
for (const op of operations) { const originalTrimmed = resultLines[i + j].trim()
if (op.type === "update") { const searchTrimmed = searchLines[j].trim()
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (originalTrimmed !== searchTrimmed) {
errors.push({ matches = false
type: "update", break
cellId: op.cell_id, }
message: `Cell with id="${op.cell_id}" not found`, }
})
continue if (matches) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
} }
}
if (!op.new_xml) { // Third try: substring match as last resort (for single-line XML)
errors.push({ if (!matchFound) {
type: "update", // Try to find as a substring in the entire content
cellId: op.cell_id, const searchStr = search.trim()
message: "new_xml is required for update operation", const resultStr = result
}) const index = resultStr.indexOf(searchStr)
continue
if (index !== -1) {
// Found as substring - replace it
result =
resultStr.substring(0, index) +
replace.trim() +
resultStr.substring(index + searchStr.length)
// Re-format after substring replacement
result = formatXML(result)
continue // Skip the line-based replacement below
} }
}
// Parse the new XML // Fourth try: character frequency match (attribute-order agnostic)
const newDoc = parser.parseFromString( // This handles cases where the model generates XML with different attribute order
`<wrapper>${op.new_xml}</wrapper>`, if (!matchFound) {
"text/xml", for (
) let i = startLineNum;
const newCell = newDoc.querySelector("mxCell") i <= resultLines.length - searchLines.length;
if (!newCell) { i++
errors.push({ ) {
type: "update", let matches = true
cellId: op.cell_id,
message: "new_xml must contain an mxCell element", for (let j = 0; j < searchLines.length; j++) {
}) if (
continue !sameCharFrequency(resultLines[i + j], searchLines[j])
) {
matches = false
break
}
}
if (matches) {
matchStartLine = i
matchEndLine = i + searchLines.length
matchFound = true
break
}
} }
}
// Validate ID matches // Fifth try: Match by mxCell id attribute
const newCellId = newCell.getAttribute("id") // Extract id from search pattern and find the element with that id
if (newCellId !== op.cell_id) { if (!matchFound) {
errors.push({ const idMatch = search.match(/id="([^"]+)"/)
type: "update", if (idMatch) {
cellId: op.cell_id, const searchId = idMatch[1]
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, // Find lines that contain this id
}) for (let i = startLineNum; i < resultLines.length; i++) {
continue if (resultLines[i].includes(`id="${searchId}"`)) {
// Found the element with matching id
// Now find the extent of this element (it might span multiple lines)
let endLine = i + 1
const line = resultLines[i].trim()
// Check if it's a self-closing tag or has children
if (!line.endsWith("/>")) {
// Find the closing tag or the end of the mxCell block
let depth = 1
while (endLine < resultLines.length && depth > 0) {
const currentLine = resultLines[endLine].trim()
if (
currentLine.startsWith("<") &&
!currentLine.startsWith("</") &&
!currentLine.endsWith("/>")
) {
depth++
} else if (currentLine.startsWith("</")) {
depth--
}
endLine++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
}
} }
}
// Import and replace the node // Sixth try: Match by value attribute (label text)
const importedNode = doc.importNode(newCell, true) // Extract value from search pattern and find elements with that value
existingCell.parentNode?.replaceChild(importedNode, existingCell) if (!matchFound) {
const valueMatch = search.match(/value="([^"]*)"/)
if (valueMatch) {
const searchValue = valueMatch[0] // Use full match like value="text"
for (let i = startLineNum; i < resultLines.length; i++) {
if (resultLines[i].includes(searchValue)) {
// Found element with matching value
let endLine = i + 1
const line = resultLines[i].trim()
// Update the map with the new element if (!line.endsWith("/>")) {
cellMap.set(op.cell_id, importedNode) let depth = 1
} else if (op.type === "add") { while (endLine < resultLines.length && depth > 0) {
// Check if ID already exists const currentLine = resultLines[endLine].trim()
if (cellMap.has(op.cell_id)) { if (
errors.push({ currentLine.startsWith("<") &&
type: "add", !currentLine.startsWith("</") &&
cellId: op.cell_id, !currentLine.endsWith("/>")
message: `Cell with id="${op.cell_id}" already exists`, ) {
}) depth++
continue } else if (currentLine.startsWith("</")) {
depth--
}
endLine++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
}
} }
}
if (!op.new_xml) { // Seventh try: Normalized whitespace match
errors.push({ // Collapse all whitespace and compare
type: "add", if (!matchFound) {
cellId: op.cell_id, const normalizeWs = (s: string) => s.replace(/\s+/g, " ").trim()
message: "new_xml is required for add operation", const normalizedSearch = normalizeWs(search)
})
continue
}
// Parse the new XML for (
const newDoc = parser.parseFromString( let i = startLineNum;
`<wrapper>${op.new_xml}</wrapper>`, i <= resultLines.length - searchLines.length;
"text/xml", i++
) ) {
const newCell = newDoc.querySelector("mxCell") // Build a normalized version of the candidate lines
if (!newCell) { const candidateLines = resultLines.slice(
errors.push({ i,
type: "add", i + searchLines.length,
cellId: op.cell_id, )
message: "new_xml must contain an mxCell element", const normalizedCandidate = normalizeWs(
}) candidateLines.join(" "),
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and append the node
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
// Check for edges referencing this cell (warning only, still delete)
const referencingEdges = root.querySelectorAll(
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
)
if (referencingEdges.length > 0) {
const edgeIds = Array.from(referencingEdges)
.map((e) => e.getAttribute("id"))
.join(", ")
console.warn(
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
) )
}
// Remove the node if (normalizedCandidate === normalizedSearch) {
existingCell.parentNode?.removeChild(existingCell) matchStartLine = i
cellMap.delete(op.cell_id) matchEndLine = i + searchLines.length
matchFound = true
break
}
}
} }
if (!matchFound) {
throw new Error(
`Search pattern not found in the diagram. The pattern may not exist in the current structure.`,
)
}
// Replace the matched lines
const replaceLines = replace.split("\n")
// Remove trailing empty line if exists
if (replaceLines[replaceLines.length - 1] === "") {
replaceLines.pop()
}
// Perform the replacement
const newResultLines = [
...resultLines.slice(0, matchStartLine),
...replaceLines,
...resultLines.slice(matchEndLine),
]
result = newResultLines.join("\n")
} }
// Serialize back to string return result
const serializer = new XMLSerializer()
const result = serializer.serializeToString(doc)
return { result, errors }
} }
// ============================================================================ // ============================================================================
@@ -782,9 +808,7 @@ export function validateMxCellStructure(xml: string): string | null {
// 2. Check for duplicate structural attributes // 2. Check for duplicate structural attributes
const dupAttrError = checkDuplicateAttributes(xml) const dupAttrError = checkDuplicateAttributes(xml)
if (dupAttrError) { if (dupAttrError) return dupAttrError
return dupAttrError
}
// 3. Check for unescaped < in attribute values // 3. Check for unescaped < in attribute values
const attrValuePattern = /=\s*"([^"]*)"/g const attrValuePattern = /=\s*"([^"]*)"/g
@@ -798,21 +822,15 @@ export function validateMxCellStructure(xml: string): string | null {
// 4. Check for duplicate IDs // 4. Check for duplicate IDs
const dupIdError = checkDuplicateIds(xml) const dupIdError = checkDuplicateIds(xml)
if (dupIdError) { if (dupIdError) return dupIdError
return dupIdError
}
// 5. Check for tag mismatches // 5. Check for tag mismatches
const tagMismatchError = checkTagMismatches(xml) const tagMismatchError = checkTagMismatches(xml)
if (tagMismatchError) { if (tagMismatchError) return tagMismatchError
return tagMismatchError
}
// 6. Check invalid character references // 6. Check invalid character references
const charRefError = checkCharacterReferences(xml) const charRefError = checkCharacterReferences(xml)
if (charRefError) { if (charRefError) return charRefError
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
@@ -825,9 +843,7 @@ export function validateMxCellStructure(xml: string): string | null {
// 8. Check for unescaped entity references and invalid entity names // 8. Check for unescaped entity references and invalid entity names
const entityError = checkEntityReferences(xml) const entityError = checkEntityReferences(xml)
if (entityError) { if (entityError) return entityError
return entityError
}
// 9. Check for empty id attributes on mxCell // 9. Check for empty id attributes on mxCell
if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) { if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) {
@@ -836,9 +852,7 @@ export function validateMxCellStructure(xml: string): string | null {
// 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
return nestedCellError
}
return null return null
} }
@@ -1043,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>" },
@@ -1158,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)
@@ -1459,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 {

196
package-lock.json generated
View File

@@ -1,19 +1,18 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.2", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.2", "version": "0.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.70", "@ai-sdk/amazon-bedrock": "^3.0.62",
"@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69", "@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30", "@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107", "@ai-sdk/react": "^2.0.107",
@@ -41,7 +40,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-tiktoken": "^1.0.21", "js-tiktoken": "^1.0.21",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.483.0", "lucide-react": "^0.483.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"next": "^16.0.7", "next": "^16.0.7",
@@ -79,14 +77,14 @@
} }
}, },
"node_modules/@ai-sdk/amazon-bedrock": { "node_modules/@ai-sdk/amazon-bedrock": {
"version": "3.0.70", "version": "3.0.62",
"resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-3.0.70.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-3.0.62.tgz",
"integrity": "sha512-4NIBlwuS/iLKq2ynOqqyJ9imk/oyHuOzhBx88Bfm5I0ihQPKJ0dMMD1IKKuyDZvLRYKmlOEpa//P+/ZBp10drw==", "integrity": "sha512-vVtndaj5zfHmgw8NSqN4baFDbFDTBZP6qufhKfqSNLtygEm8+8PL9XQX9urgzSzU3zp+zi3AmNNemvKLkkqblg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.56", "@ai-sdk/anthropic": "2.0.50",
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19", "@ai-sdk/provider-utils": "3.0.18",
"@smithy/eventstream-codec": "^4.0.1", "@smithy/eventstream-codec": "^4.0.1",
"@smithy/util-utf8": "^4.0.0", "@smithy/util-utf8": "^4.0.0",
"aws4fetch": "^1.0.20" "aws4fetch": "^1.0.20"
@@ -98,48 +96,14 @@
"zod": "^3.25.76 || ^4.1.8" "zod": "^3.25.76 || ^4.1.8"
} }
}, },
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.19",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz",
"integrity": "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/anthropic": { "node_modules/@ai-sdk/anthropic": {
"version": "2.0.56", "version": "2.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.56.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.50.tgz",
"integrity": "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ==", "integrity": "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19" "@ai-sdk/provider-utils": "3.0.18"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.19",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz",
"integrity": "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -200,13 +164,13 @@
} }
}, },
"node_modules/@ai-sdk/gateway": { "node_modules/@ai-sdk/gateway": {
"version": "2.0.21", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.21.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz",
"integrity": "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA==", "integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19", "@ai-sdk/provider-utils": "3.0.18",
"@vercel/oidc": "3.0.5" "@vercel/oidc": "3.0.5"
}, },
"engines": { "engines": {
@@ -216,23 +180,6 @@
"zod": "^3.25.76 || ^4.1.8" "zod": "^3.25.76 || ^4.1.8"
} }
}, },
"node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.19",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz",
"integrity": "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google": { "node_modules/@ai-sdk/google": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.0.tgz",
@@ -2541,9 +2488,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -2557,9 +2504,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
"integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==", "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2573,9 +2520,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
"integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==", "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2589,9 +2536,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
"integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==", "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2605,9 +2552,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
"integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==", "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2621,9 +2568,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
"integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==", "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2637,9 +2584,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
"integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==", "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2653,9 +2600,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
"integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==", "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2669,9 +2616,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
"integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==", "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -6148,23 +6095,6 @@
"zod": "^3.25.76 || ^4.1.8" "zod": "^3.25.76 || ^4.1.8"
} }
}, },
"node_modules/ai/node_modules/@ai-sdk/gateway": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz",
"integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@vercel/oidc": "3.0.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -8048,15 +7978,14 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {
@@ -9235,15 +9164,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonrepair": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz",
"integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==",
"license": "ISC",
"bin": {
"jsonrepair": "bin/cli.js"
}
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -10717,12 +10637,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.0.10", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
"integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.0.10", "@next/env": "16.0.7",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -10735,14 +10655,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-arm64": "16.0.7",
"@next/swc-darwin-x64": "16.0.10", "@next/swc-darwin-x64": "16.0.7",
"@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.7",
"@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.7",
"@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.7",
"@next/swc-linux-x64-musl": "16.0.10", "@next/swc-linux-x64-musl": "16.0.7",
"@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.7",
"@next/swc-win32-x64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.7",
"sharp": "^0.34.4" "sharp": "^0.34.4"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.3", "version": "0.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -13,11 +13,10 @@
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.70", "@ai-sdk/amazon-bedrock": "^3.0.62",
"@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69", "@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30", "@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107", "@ai-sdk/react": "^2.0.107",
@@ -45,7 +44,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-tiktoken": "^1.0.21", "js-tiktoken": "^1.0.21",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.483.0", "lucide-react": "^0.483.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"next": "^16.0.7", "next": "^16.0.7",

View File

@@ -1,162 +0,0 @@
# Next AI Draw.io MCP Server
MCP (Model Context Protocol) server that enables AI agents like Claude Desktop and Cursor to generate and edit draw.io diagrams with **real-time browser preview**.
**Self-contained** - includes an embedded HTTP server, no external dependencies required.
## Quick Start
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
## Installation
### Claude Desktop
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### VS Code
Add to your VS Code settings (`.vscode/mcp.json` in workspace or user settings):
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Cursor
Add to Cursor MCP config (`~/.cursor/mcp.json`):
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
### Other MCP Clients
Use the standard MCP configuration with:
- **Command**: `npx`
- **Args**: `["@next-ai-drawio/mcp-server@latest"]`
## Usage
1. Restart your MCP client after updating config
2. Ask the AI to create a diagram:
> "Create a flowchart showing user authentication with login, MFA, and session management"
3. The diagram appears in your browser in real-time!
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
## Available Tools
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `display_diagram` | Create a new diagram from XML |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |
## How It Works
```
┌─────────────────┐ stdio ┌─────────────────┐
│ Claude Desktop │ <───────────> │ MCP Server │
│ (AI Agent) │ │ (this package) │
└─────────────────┘ └────────┬────────┘
┌────────▼────────┐
│ Embedded HTTP │
│ Server (:6002) │
└────────┬────────┘
┌────────▼────────┐
│ User's Browser │
│ (draw.io embed) │
└─────────────────┘
```
1. **MCP Server** receives tool calls from Claude via stdio
2. **Embedded HTTP Server** serves the draw.io UI and handles state
3. **Browser** shows real-time diagram updates via polling
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
## Troubleshooting
### Port already in use
If port 6002 is in use, the server will automatically try the next available port (up to 6020).
Or set a custom port:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"],
"env": { "PORT": "6003" }
}
}
}
```
### "No active session"
Call `start_session` first to open the browser window.
### Browser not updating
Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server.
## License
Apache-2.0

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +0,0 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.2",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
"bin": {
"next-ai-drawio-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"prepublishOnly": "npm run build"
},
"keywords": [
"mcp",
"drawio",
"diagram",
"ai",
"claude",
"model-context-protocol"
],
"author": "Biki-dev",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/Biki-dev/next-ai-draw-io",
"directory": "packages/mcp-server"
},
"homepage": "https://next-ai-drawio.jiang.jp",
"bugs": {
"url": "https://github.com/Biki-dev/next-ai-draw-io/issues"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^10.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^20",
"tsx": "^4.19.0",
"typescript": "^5"
},
"engines": {
"node": ">=18"
},
"files": [
"dist"
]
}

View File

@@ -1,219 +0,0 @@
/**
* ID-based diagram operations
* Copied from lib/utils.ts to avoid cross-package imports
*/
export interface DiagramOperation {
type: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface OperationError {
type: "update" | "add" | "delete"
cellId: string
message: string
}
export interface ApplyOperationsResult {
result: string
errors: OperationError[]
}
/**
* Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
*
* @param xmlContent - The full mxfile XML content
* @param operations - Array of operations to apply
* @returns Object with result XML and any errors
*/
export function applyDiagramOperations(
xmlContent: string,
operations: DiagramOperation[],
): ApplyOperationsResult {
const errors: OperationError[] = []
// Parse the XML
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
// Check for parse errors
const parseError = doc.querySelector("parsererror")
if (parseError) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `XML parse error: ${parseError.textContent}`,
},
],
}
}
// Find the root element (inside mxGraphModel)
const root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
}
}
// Build a map of cell IDs to elements
const cellMap = new Map<string, Element>()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
if (id) cellMap.set(id, cell)
})
// Process each operation
for (const op of operations) {
if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
if (!op.new_xml) {
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml is required for update operation",
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and replace the node
const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
// Update the map with the new element
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") {
// Check if ID already exists
if (cellMap.has(op.cell_id)) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" already exists`,
})
continue
}
if (!op.new_xml) {
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml is required for add operation",
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
// Validate ID matches
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
// Import and append the node
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
// Check for edges referencing this cell (warning only, still delete)
const referencingEdges = root.querySelectorAll(
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
)
if (referencingEdges.length > 0) {
const edgeIds = Array.from(referencingEdges)
.map((e) => e.getAttribute("id"))
.join(", ")
console.warn(
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
)
}
// Remove the node
existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
}
}
// Serialize back to string
const serializer = new XMLSerializer()
const result = serializer.serializeToString(doc)
return { result, errors }
}

View File

@@ -1,384 +0,0 @@
/**
* Embedded HTTP Server for MCP
*
* Serves a static HTML page with draw.io embed and handles state sync.
* This eliminates the need for an external Next.js app.
*/
import http from "node:http"
import { log } from "./logger.js"
interface SessionState {
xml: string
version: number
lastUpdated: Date
}
// In-memory state store (shared with MCP server in same process)
export const stateStore = new Map<string, SessionState>()
let server: http.Server | null = null
let serverPort: number = 6002
const MAX_PORT = 6020 // Don't retry beyond this port
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
/**
* Get state for a session
*/
export function getState(sessionId: string): SessionState | undefined {
return stateStore.get(sessionId)
}
/**
* Set state for a session
*/
export function setState(sessionId: string, xml: string): number {
const existing = stateStore.get(sessionId)
const newVersion = (existing?.version || 0) + 1
stateStore.set(sessionId, {
xml,
version: newVersion,
lastUpdated: new Date(),
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion
}
/**
* Start the embedded HTTP server
*/
export function startHttpServer(port: number = 6002): Promise<number> {
return new Promise((resolve, reject) => {
if (server) {
resolve(serverPort)
return
}
serverPort = port
server = http.createServer(handleRequest)
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
if (port >= MAX_PORT) {
reject(
new Error(
`No available ports in range 6002-${MAX_PORT}`,
),
)
return
}
log.info(`Port ${port} in use, trying ${port + 1}`)
server = null
startHttpServer(port + 1)
.then(resolve)
.catch(reject)
} else {
reject(err)
}
})
server.listen(port, () => {
serverPort = port
log.info(`Embedded HTTP server running on http://localhost:${port}`)
resolve(port)
})
})
}
/**
* Stop the HTTP server
*/
export function stopHttpServer(): void {
if (server) {
server.close()
server = null
}
}
/**
* Clean up expired sessions
*/
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [sessionId, state] of stateStore) {
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
stateStore.delete(sessionId)
log.info(`Cleaned up expired session: ${sessionId}`)
}
}
}
// Run cleanup every 5 minutes
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
/**
* Get the current server port
*/
export function getServerPort(): number {
return serverPort
}
/**
* Handle HTTP requests
*/
function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
// CORS headers for local development
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
// Route handling
if (url.pathname === "/" || url.pathname === "/index.html") {
serveHtml(req, res, url)
} else if (
url.pathname === "/api/state" ||
url.pathname === "/api/mcp/state"
) {
handleStateApi(req, res, url)
} else if (
url.pathname === "/api/health" ||
url.pathname === "/api/mcp/health"
) {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ status: "ok", mcp: true }))
} else {
res.writeHead(404)
res.end("Not Found")
}
}
/**
* Serve the HTML page with draw.io embed
*/
function serveHtml(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
const sessionId = url.searchParams.get("mcp") || ""
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId))
}
/**
* Handle state API requests
*/
function handleStateApi(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
if (req.method === "GET") {
const sessionId = url.searchParams.get("sessionId")
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
xml: state?.xml || null,
version: state?.version || 0,
lastUpdated: state?.lastUpdated?.toISOString() || null,
}),
)
} else if (req.method === "POST") {
let body = ""
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try {
const { sessionId, xml } = JSON.parse(body)
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const version = setState(sessionId, xml)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version }))
} catch {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" }))
}
})
} else {
res.writeHead(405)
res.end("Method Not Allowed")
}
}
/**
* Generate the HTML page with draw.io embed
*/
function getHtmlPage(sessionId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header {
padding: 8px 16px;
background: #1a1a2e;
color: #eee;
font-family: system-ui, sans-serif;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
#header .session { color: #888; font-size: 12px; }
#header .status { font-size: 12px; }
#header .status.connected { color: #4ade80; }
#header .status.disconnected { color: #f87171; }
#drawio { flex: 1; border: none; }
</style>
</head>
<body>
<div id="container">
<div id="header">
<div>
<strong>Draw.io MCP</strong>
<span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
</div>
<div id="status" class="status disconnected">Connecting...</div>
</div>
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div>
<script>
const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio');
const statusEl = document.getElementById('status');
let currentVersion = 0;
let isDrawioReady = false;
let pendingXml = null;
let lastLoadedXml = null;
// Listen for messages from draw.io
window.addEventListener('message', (event) => {
if (event.origin !== 'https://embed.diagrams.net') return;
try {
const msg = JSON.parse(event.data);
handleDrawioMessage(msg);
} catch (e) {
// Ignore non-JSON messages
}
});
function handleDrawioMessage(msg) {
if (msg.event === 'init') {
isDrawioReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
// Load pending XML if any
if (pendingXml) {
loadDiagram(pendingXml);
pendingXml = null;
}
} else if (msg.event === 'save') {
// User saved - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
} else if (msg.event === 'export') {
// Export completed
if (msg.data) {
pushState(msg.data);
}
} else if (msg.event === 'autosave') {
// Autosave - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
}
}
function loadDiagram(xml) {
if (!isDrawioReady) {
pendingXml = xml;
return;
}
lastLoadedXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({
action: 'load',
xml: xml,
autosave: 1
}), '*');
}
async function pushState(xml) {
if (!sessionId) return;
try {
const response = await fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml })
});
if (response.ok) {
const result = await response.json();
currentVersion = result.version;
lastLoadedXml = xml;
}
} catch (e) {
console.error('Failed to push state:', e);
}
}
async function pollState() {
if (!sessionId) return;
try {
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!response.ok) return;
const state = await response.json();
if (state.version && state.version > currentVersion && state.xml) {
currentVersion = state.version;
loadDiagram(state.xml);
}
} catch (e) {
console.error('Failed to poll state:', e);
}
}
// Start polling if we have a session
if (sessionId) {
pollState();
setInterval(pollState, 2000);
}
</script>
</body>
</html>`
}

View File

@@ -1,476 +0,0 @@
#!/usr/bin/env node
/**
* MCP Server for Next AI Draw.io
*
* Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit
* draw.io diagrams with real-time browser preview.
*
* Uses an embedded HTTP server - no external dependencies required.
*/
// Setup DOM polyfill for Node.js (required for XML operations)
import { DOMParser } from "linkedom"
;(globalThis as any).DOMParser = DOMParser
// Create XMLSerializer polyfill using outerHTML
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) {
return node.outerHTML
}
if (node.documentElement) {
return node.documentElement.outerHTML
}
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open"
import { z } from "zod"
import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import {
getServerPort,
getState,
setState,
startHttpServer,
} from "./http-server.js"
import { log } from "./logger.js"
// Server configuration
const config = {
port: parseInt(process.env.PORT || "6002"),
}
// Session state (single session for simplicity)
let currentSession: {
id: string
xml: string
version: number
} | null = null
// Create MCP server
const server = new McpServer({
name: "next-ai-drawio",
version: "0.1.2",
})
// Register prompt with workflow guidance
server.prompt(
"diagram-workflow",
"Guidelines for creating and editing draw.io diagrams",
() => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `# Draw.io Diagram Workflow Guidelines
## Creating a New Diagram
1. Call start_session to open the browser preview
2. Use display_diagram with complete mxGraphModel XML to create a new diagram
## Adding Elements to Existing Diagram
1. Use edit_diagram with "add" operation
2. Provide a unique cell_id and complete mxCell XML
3. No need to call get_diagram first - the server fetches latest state automatically
## Modifying or Deleting Existing Elements
1. FIRST call get_diagram to see current cell IDs and structure
2. THEN call edit_diagram with "update" or "delete" operations
3. For update, provide the cell_id and complete new mxCell XML
## Important Notes
- display_diagram REPLACES the entire diagram - only use for new diagrams
- edit_diagram PRESERVES user's manual changes (fetches browser state first)
- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`,
},
},
],
}),
)
// Tool: start_session
server.registerTool(
"start_session",
{
description:
"Start a new diagram session and open the browser for real-time preview. " +
"Starts an embedded server and opens a browser window with draw.io. " +
"The browser will show diagram updates as they happen.",
inputSchema: {},
},
async () => {
try {
// Start embedded HTTP server
const port = await startHttpServer(config.port)
// Create session
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
currentSession = {
id: sessionId,
xml: "",
version: 0,
}
// Open browser
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
await open(browserUrl)
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
return {
content: [
{
type: "text",
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("start_session failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: display_diagram
server.registerTool(
"display_diagram",
{
description:
"Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " +
"Use this for creating new diagrams from scratch. " +
"To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " +
"You should generate valid draw.io/mxGraph XML format.",
inputSchema: {
xml: z
.string()
.describe("The draw.io XML to display (mxGraphModel format)"),
},
},
async ({ xml }) => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
log.info(`Displaying diagram, ${xml.length} chars`)
// Update session state
currentSession.xml = xml
currentSession.version++
// Push to embedded server state
setState(currentSession.id, xml)
log.info(`Diagram displayed successfully`)
return {
content: [
{
type: "text",
text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("display_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: edit_diagram
server.registerTool(
"edit_diagram",
{
description:
"Edit the current diagram by ID-based operations (update/add/delete cells). " +
"ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" +
"IMPORTANT workflow:\n" +
"- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" +
"- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\n\n" +
"Operations:\n" +
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry.",
inputSchema: {
operations: z
.array(
z.object({
type: z
.enum(["update", "add", "delete"])
.describe("Operation type"),
cell_id: z.string().describe("The id of the mxCell"),
new_xml: z
.string()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
}),
)
.describe("Array of operations to apply"),
},
},
async ({ operations }) => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
// Fetch latest state from browser
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
log.info("Fetched latest diagram state from browser")
}
if (!currentSession.xml) {
return {
content: [
{
type: "text",
text: "Error: No diagram to edit. Please create a diagram first with display_diagram.",
},
],
isError: true,
}
}
log.info(`Editing diagram with ${operations.length} operation(s)`)
// Apply operations
const { result, errors } = applyDiagramOperations(
currentSession.xml,
operations as DiagramOperation[],
)
if (errors.length > 0) {
const errorMessages = errors
.map((e) => `${e.type} ${e.cellId}: ${e.message}`)
.join("\n")
log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`)
}
// Update state
currentSession.xml = result
currentSession.version++
// Push to embedded server
setState(currentSession.id, result)
log.info(`Diagram edited successfully`)
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
const errorMsg =
errors.length > 0
? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}`
: ""
return {
content: [
{
type: "text",
text: successMsg + errorMsg,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("edit_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: get_diagram
server.registerTool(
"get_diagram",
{
description:
"Get the current diagram XML (fetches latest from browser, including user's manual edits). " +
"Call this BEFORE edit_diagram if you need to update or delete existing elements, " +
"so you can see the current cell IDs and structure.",
},
async () => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
// Fetch latest state from browser
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
if (!currentSession.xml) {
return {
content: [
{
type: "text",
text: "No diagram exists yet. Use display_diagram to create one.",
},
],
}
}
return {
content: [
{
type: "text",
text: `Current diagram XML:\n\n${currentSession.xml}`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("get_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: export_diagram
server.registerTool(
"export_diagram",
{
description: "Export the current diagram to a .drawio file.",
inputSchema: {
path: z
.string()
.describe(
"File path to save the diagram (e.g., ./diagram.drawio)",
),
},
},
async ({ path }) => {
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
// Fetch latest state
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
if (!currentSession.xml) {
return {
content: [
{
type: "text",
text: "Error: No diagram to export. Please create a diagram first.",
},
],
isError: true,
}
}
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
let filePath = path
if (!filePath.endsWith(".drawio")) {
filePath = `${filePath}.drawio`
}
const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
log.info(`Diagram exported to ${absolutePath}`)
return {
content: [
{
type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("export_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Start the MCP server
async function main() {
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
const transport = new StdioServerTransport()
await server.connect(transport)
log.info("MCP server running on stdio")
}
main().catch((error) => {
log.error("Fatal error:", error)
process.exit(1)
})

View File

@@ -1,24 +0,0 @@
/**
* Logger for MCP server
*
* CRITICAL: MCP servers communicate via STDIO (stdin/stdout).
* Using console.log() will corrupt the JSON-RPC protocol messages.
* ALL logging MUST use console.error() which writes to stderr.
*/
export const log = {
info: (msg: string, ...args: unknown[]) => {
console.error(`[MCP-DrawIO] [INFO] ${msg}`, ...args)
},
error: (msg: string, ...args: unknown[]) => {
console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args)
},
debug: (msg: string, ...args: unknown[]) => {
if (process.env.DEBUG === "true") {
console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args)
}
},
warn: (msg: string, ...args: unknown[]) => {
console.error(`[MCP-DrawIO] [WARN] ${msg}`, ...args)
},
}

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,251 +0,0 @@
/**
* Simple test script for applyDiagramOperations function
* Run with: node scripts/test-diagram-operations.mjs
*/
import { JSDOM } from "jsdom"
// Set up DOMParser for Node.js environment
const dom = new JSDOM()
globalThis.DOMParser = dom.window.DOMParser
globalThis.XMLSerializer = dom.window.XMLSerializer
// Import the function (we'll inline it since it's not ESM exported)
function applyDiagramOperations(xmlContent, operations) {
const errors = []
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
const parseError = doc.querySelector("parsererror")
if (parseError) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: `XML parse error: ${parseError.textContent}` }],
}
}
const root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: "Could not find <root> element in XML" }],
}
}
const cellMap = new Map()
root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id")
if (id) cellMap.set(id, cell)
})
for (const op of operations) {
if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
continue
}
if (!op.new_xml) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml is required for update operation" })
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
continue
}
const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell)
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") {
if (cellMap.has(op.cell_id)) {
errors.push({ type: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists` })
continue
}
if (!op.new_xml) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml is required for add operation" })
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
continue
}
const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode)
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "delete", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
continue
}
existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
}
}
const serializer = new XMLSerializer()
const result = serializer.serializeToString(doc)
return { result, errors }
}
// Test data
const sampleXml = `<?xml version="1.0" encoding="UTF-8"?>
<mxfile>
<diagram>
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Box A" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="Box B" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="300" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="4" value="" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>`
let passed = 0
let failed = 0
function test(name, fn) {
try {
fn()
console.log(`${name}`)
passed++
} catch (e) {
console.log(`${name}`)
console.log(` Error: ${e.message}`)
failed++
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || "Assertion failed")
}
// Tests
test("Update operation changes cell value", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated Box A"'), "Updated value should be in result")
assert(!result.includes('value="Box A"'), "Old value should not be in result")
})
test("Update operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "999", new_xml: '<mxCell id="999" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
})
test("Update operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "2", new_xml: '<mxCell id="WRONG" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
})
test("Add operation creates new cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('id="new1"'), "New cell should be in result")
assert(result.includes('value="New Box"'), "New cell value should be in result")
})
test("Add operation fails for duplicate ID", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "2", new_xml: '<mxCell id="2" value="Duplicate"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("already exists"), "Error should mention already exists")
})
test("Add operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "new1", new_xml: '<mxCell id="WRONG" value="Test"/>' },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
})
test("Delete operation removes cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "3" }])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(!result.includes('id="3"'), "Deleted cell should not be in result")
assert(result.includes('id="2"'), "Other cells should remain")
})
test("Delete operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "999" }])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
})
test("Multiple operations in sequence", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{ type: "delete", cell_id: "3" },
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated"'), "Updated value should be present")
assert(result.includes('id="new1"'), "Added cell should be present")
assert(!result.includes('id="3"'), "Deleted cell should not be present")
})
test("Invalid XML returns parse error", () => {
const { errors } = applyDiagramOperations("<not valid xml", [{ type: "delete", cell_id: "1" }])
assert(errors.length === 1, "Should have one error")
})
test("Missing root element returns error", () => {
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [{ type: "delete", cell_id: "1" }])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("root"), "Error should mention root element")
})
// Summary
console.log(`\n${passed} passed, ${failed} failed`)
process.exit(failed > 0 ? 1 : 0)

View File

@@ -29,5 +29,5 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules", "packages"] "exclude": ["node_modules"]
} }