mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 06:42:27 +08:00
Compare commits
1 Commits
revert-293
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9d387c44 |
24
.github/workflows/docker-build.yml
vendored
24
.github/workflows/docker-build.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -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)
|
||||||
@@ -86,43 +85,11 @@ Here are some example prompts and their generated diagrams:
|
|||||||
|
|
||||||
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
|
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
|
||||||
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
|
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
|
||||||
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
|
|
||||||
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
|
|
||||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
||||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
- **AWS Architecture Diagram Support**: Specialized support for generating AWS architecture diagrams
|
||||||
- **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 +205,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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -20,7 +18,7 @@ import {
|
|||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
|
|
||||||
export const maxDuration = 120
|
export const maxDuration = 300
|
||||||
|
|
||||||
// File upload limits (must match client-side)
|
// File upload limits (must match client-side)
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -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,17 @@ 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 and avoid confusion
|
||||||
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
|
const placeholderMessages = replaceHistoricalToolInputs(fixedMessages)
|
||||||
const enableHistoryReplace =
|
|
||||||
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
|
|
||||||
const placeholderMessages = enableHistoryReplace
|
|
||||||
? replaceHistoricalToolInputs(modelMessages)
|
|
||||||
: modelMessages
|
|
||||||
|
|
||||||
// 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 +274,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 +349,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 +360,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 +396,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: < > & "
|
||||||
6. Escape special chars in values: < > & "
|
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 +438,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 +491,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||||
|
import { Analytics } from "@vercel/analytics/react"
|
||||||
import type { Metadata, Viewport } from "next"
|
import type { Metadata, Viewport } from "next"
|
||||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
||||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||||
@@ -116,6 +117,7 @@ export default function RootLayout({
|
|||||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<DiagramProvider>{children}</DiagramProvider>
|
<DiagramProvider>{children}</DiagramProvider>
|
||||||
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
app/page.tsx
33
app/page.tsx
@@ -15,13 +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,
|
|
||||||
} = 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")
|
||||||
@@ -40,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
|
||||||
@@ -61,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -17,13 +17,7 @@ import { HistoryDialog } from "@/components/history-dialog"
|
|||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
@@ -135,8 +129,6 @@ interface ChatInputProps {
|
|||||||
onToggleHistory?: (show: boolean) => void
|
onToggleHistory?: (show: boolean) => void
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
minimalStyle?: boolean
|
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -152,8 +144,6 @@ export function ChatInput({
|
|||||||
onToggleHistory = () => {},
|
onToggleHistory = () => {},
|
||||||
sessionId,
|
sessionId,
|
||||||
error = null,
|
error = null,
|
||||||
minimalStyle = false,
|
|
||||||
onMinimalStyleChange = () => {},
|
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
@@ -353,32 +343,6 @@ export function ChatInput({
|
|||||||
showHistory={showHistory}
|
showHistory={showHistory}
|
||||||
onToggleHistory={onToggleHistory}
|
onToggleHistory={onToggleHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Switch
|
|
||||||
id="minimal-style"
|
|
||||||
checked={minimalStyle}
|
|
||||||
onCheckedChange={onMinimalStyleChange}
|
|
||||||
className="scale-75"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="minimal-style"
|
|
||||||
className={`text-xs cursor-pointer select-none ${
|
|
||||||
minimalStyle
|
|
||||||
? "text-primary font-medium"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{minimalStyle ? "Minimal" : "Styled"}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
Use minimal for faster generation (no colors)
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right actions */}
|
{/* Right actions */}
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
|
Minus,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Plus,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import type { MutableRefObject } from "react"
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
import {
|
||||||
Reasoning,
|
Reasoning,
|
||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
@@ -28,36 +28,16 @@ import {
|
|||||||
} 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 {
|
||||||
applyDiagramOperations,
|
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
isMxCellXmlComplete,
|
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateMxCellStructure,
|
||||||
} from "@/lib/utils"
|
} 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 +45,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>
|
||||||
@@ -184,8 +169,6 @@ interface ChatMessageDisplayProps {
|
|||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
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
|
||||||
@@ -196,8 +179,6 @@ export function ChatMessageDisplay({
|
|||||||
messages,
|
messages,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
processedToolCallsRef,
|
|
||||||
editDiagramOriginalXmlRef,
|
|
||||||
sessionId,
|
sessionId,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
@@ -206,23 +187,7 @@ export function ChatMessageDisplay({
|
|||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
const processedToolCalls = processedToolCallsRef
|
const processedToolCalls = useRef<Set<string>>(new Set())
|
||||||
// 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>>(
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
@@ -248,32 +213,9 @@ export function ChatMessageDisplay({
|
|||||||
setCopiedMessageId(messageId)
|
setCopiedMessageId(messageId)
|
||||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fallback for non-secure contexts (HTTP) or permission denied
|
console.error("Failed to copy message:", err)
|
||||||
const textarea = document.createElement("textarea")
|
setCopyFailedMessageId(messageId)
|
||||||
textarea.value = text
|
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
||||||
textarea.style.position = "fixed"
|
|
||||||
textarea.style.left = "-9999px"
|
|
||||||
textarea.style.opacity = "0"
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
|
|
||||||
try {
|
|
||||||
textarea.select()
|
|
||||||
const success = document.execCommand("copy")
|
|
||||||
if (!success) {
|
|
||||||
throw new Error("Copy command failed")
|
|
||||||
}
|
|
||||||
setCopiedMessageId(messageId)
|
|
||||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
console.error("Failed to copy message:", fallbackErr)
|
|
||||||
toast.error(
|
|
||||||
"Failed to copy message. Please copy manually or check clipboard permissions.",
|
|
||||||
)
|
|
||||||
setCopyFailedMessageId(messageId)
|
|
||||||
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textarea)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,88 +243,32 @@ export function ChatMessageDisplay({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to log feedback:", error)
|
console.warn("Failed to log feedback:", error)
|
||||||
toast.error("Failed to record your feedback. Please try again.")
|
|
||||||
// Revert optimistic UI update
|
|
||||||
setFeedback((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[messageId]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string) => {
|
||||||
const currentXml = xml || ""
|
const currentXml = xml || ""
|
||||||
const convertedXml = convertToLegalXml(currentXml)
|
const convertedXml = convertToLegalXml(currentXml)
|
||||||
if (convertedXml !== previousXML.current) {
|
if (convertedXml !== previousXML.current) {
|
||||||
// Parse and validate XML BEFORE calling replaceNodes
|
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
|
||||||
const parser = new DOMParser()
|
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
|
||||||
const testDoc = parser.parseFromString(convertedXml, "text/xml")
|
const baseXML =
|
||||||
const parseError = testDoc.querySelector("parsererror")
|
chartXML ||
|
||||||
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
if (parseError) {
|
const validationError = validateMxCellStructure(replacedXML)
|
||||||
// Use console.warn instead of console.error to avoid triggering
|
if (!validationError) {
|
||||||
// Next.js dev mode error overlay for expected streaming states
|
previousXML.current = convertedXml
|
||||||
// (partial XML during streaming is normal and will be fixed by subsequent updates)
|
// Skip validation in loadDiagram since we already validated above
|
||||||
if (showToast) {
|
onDisplayChart(replacedXML, true)
|
||||||
// Only log as error and show toast if this is the final XML
|
} else {
|
||||||
console.error(
|
console.log(
|
||||||
"[ChatMessageDisplay] Malformed XML detected in final output",
|
"[ChatMessageDisplay] XML validation failed:",
|
||||||
)
|
validationError,
|
||||||
toast.error(
|
|
||||||
"AI generated invalid diagram XML. Please try regenerating.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return // Skip this update
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
|
|
||||||
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
|
|
||||||
const baseXML =
|
|
||||||
chartXML ||
|
|
||||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
|
||||||
|
|
||||||
// Validate and auto-fix the XML
|
|
||||||
const validation = validateAndFixXml(replacedXML)
|
|
||||||
if (validation.valid) {
|
|
||||||
previousXML.current = convertedXml
|
|
||||||
// Use fixed XML if available, otherwise use original
|
|
||||||
const xmlToLoad = validation.fixed || replacedXML
|
|
||||||
if (validation.fixes.length > 0) {
|
|
||||||
console.log(
|
|
||||||
"[ChatMessageDisplay] Auto-fixed XML issues:",
|
|
||||||
validation.fixes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Skip validation in loadDiagram since we already validated above
|
|
||||||
onDisplayChart(xmlToLoad, true)
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"[ChatMessageDisplay] XML validation failed:",
|
|
||||||
validation.error,
|
|
||||||
)
|
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
|
||||||
if (showToast) {
|
|
||||||
toast.error(
|
|
||||||
"Diagram validation failed. Please try regenerating.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[ChatMessageDisplay] Error processing XML:",
|
|
||||||
error,
|
|
||||||
)
|
)
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
|
||||||
if (showToast) {
|
|
||||||
toast.error(
|
|
||||||
"Failed to process diagram. Please try regenerating.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -402,12 +288,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 +307,24 @@ 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
|
handleDisplayChart(xml)
|
||||||
pendingXmlRef.current = xml
|
|
||||||
|
|
||||||
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)
|
handleDisplayChart(xml)
|
||||||
if (debounceTimeoutRef.current) {
|
|
||||||
clearTimeout(debounceTimeoutRef.current)
|
|
||||||
debounceTimeoutRef.current = null
|
|
||||||
pendingXmlRef.current = null
|
|
||||||
}
|
|
||||||
// Show toast only if final XML is malformed
|
|
||||||
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 +373,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 +398,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 +410,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useChat } from "@ai-sdk/react"
|
|||||||
import { DefaultChatTransport } from "ai"
|
import { DefaultChatTransport } from "ai"
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
MessageSquarePlus,
|
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -18,7 +17,6 @@ import { FaGithub } from "react-icons/fa"
|
|||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ChatInput } from "@/components/chat-input"
|
import { ChatInput } from "@/components/chat-input"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { getAIConfig } from "@/lib/ai-config"
|
import { getAIConfig } from "@/lib/ai-config"
|
||||||
@@ -26,7 +24,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 +38,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,15 +61,31 @@ 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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if auto-resubmit should happen based on tool errors.
|
* Custom auto-resubmit logic for the AI chat.
|
||||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - When tools return errors (e.g., invalid XML), automatically resubmit
|
||||||
|
* the conversation to let the AI retry with corrections
|
||||||
|
* - When tools succeed (e.g., diagram displayed), stop without AI acknowledgment
|
||||||
|
* to prevent unnecessary regeneration cycles
|
||||||
|
*
|
||||||
|
* This fixes the issue where successful diagrams were being regenerated
|
||||||
|
* multiple times because the previous logic (lastAssistantMessageIsCompleteWithToolCalls)
|
||||||
|
* auto-resubmitted on BOTH success and error.
|
||||||
|
*
|
||||||
|
* @param messages - Current conversation messages from AI SDK
|
||||||
|
* @returns true to auto-resubmit (for error recovery), false to stop
|
||||||
*/
|
*/
|
||||||
function hasToolErrors(messages: ChatMessage[]): boolean {
|
function shouldAutoResubmit(messages: ChatMessage[]): boolean {
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
if (!lastMessage || lastMessage.role !== "assistant") {
|
if (!lastMessage || lastMessage.role !== "assistant") {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[sendAutomaticallyWhen] No assistant message, returning false",
|
||||||
|
)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +95,31 @@ function hasToolErrors(messages: ChatMessage[]): boolean {
|
|||||||
) || []
|
) || []
|
||||||
|
|
||||||
if (toolParts.length === 0) {
|
if (toolParts.length === 0) {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[sendAutomaticallyWhen] No tool parts, returning false",
|
||||||
|
)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastToolPart = toolParts[toolParts.length - 1]
|
// Only auto-resubmit if ANY tool has an error
|
||||||
return lastToolPart?.state === TOOL_ERROR_STATE
|
const hasError = toolParts.some((part) => part.state === TOOL_ERROR_STATE)
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
if (hasError) {
|
||||||
|
console.log(
|
||||||
|
"[sendAutomaticallyWhen] Retrying due to errors in tools:",
|
||||||
|
toolParts
|
||||||
|
.filter((p) => p.state === TOOL_ERROR_STATE)
|
||||||
|
.map((p) => p.toolName),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log("[sendAutomaticallyWhen] No errors, stopping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasError
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({
|
export default function ChatPanel({
|
||||||
@@ -145,8 +178,6 @@ export default function ChatPanel({
|
|||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||||
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 [minimalStyle, setMinimalStyle] = useState(false)
|
|
||||||
|
|
||||||
// Check config on mount
|
// Check config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -192,29 +223,6 @@ export default function ChatPanel({
|
|||||||
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
||||||
const stopRef = useRef<(() => void) | null>(null)
|
const stopRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
// Ref to track consecutive auto-retry count (reset on user action)
|
|
||||||
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
|
|
||||||
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 +244,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 +277,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 +299,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 +349,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 +378,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 +387,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 +399,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 +426,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)
|
||||||
@@ -628,58 +441,8 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendAutomaticallyWhen: ({ messages }) => {
|
sendAutomaticallyWhen: ({ messages }) =>
|
||||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
shouldAutoResubmit(messages as unknown as ChatMessage[]),
|
||||||
|
|
||||||
const shouldRetry = hasToolErrors(
|
|
||||||
messages as unknown as ChatMessage[],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!shouldRetry) {
|
|
||||||
// No error, reset retry count and clear state
|
|
||||||
autoRetryCountRef.current = 0
|
|
||||||
partialXmlRef.current = ""
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continuation mode: unlimited retries (truncation continuation, not real errors)
|
|
||||||
// Server limits to 5 steps via stepCountIs(5)
|
|
||||||
if (isInContinuationMode) {
|
|
||||||
// Don't count against retry limit for continuation
|
|
||||||
// Quota checks still apply below
|
|
||||||
} 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
|
|
||||||
autoRetryCountRef.current++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check quota limits before auto-retry
|
|
||||||
const tokenLimitCheck = quotaManager.checkTokenLimit()
|
|
||||||
if (!tokenLimitCheck.allowed) {
|
|
||||||
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
|
|
||||||
autoRetryCountRef.current = 0
|
|
||||||
partialXmlRef.current = ""
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const tpmCheck = quotaManager.checkTPMLimit()
|
|
||||||
if (!tpmCheck.allowed) {
|
|
||||||
quotaManager.showTPMLimitToast()
|
|
||||||
autoRetryCountRef.current = 0
|
|
||||||
partialXmlRef.current = ""
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update stopRef so onToolCall can access it
|
// Update stopRef so onToolCall can access it
|
||||||
@@ -718,10 +481,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 +525,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])
|
||||||
|
|
||||||
@@ -970,32 +696,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
|
||||||
setMessages([])
|
|
||||||
clearDiagram()
|
|
||||||
handleFileChange([]) // Use handleFileChange to also clear pdfData
|
|
||||||
const newSessionId = `session-${Date.now()}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2, 9)}`
|
|
||||||
setSessionId(newSessionId)
|
|
||||||
xmlSnapshotsRef.current.clear()
|
|
||||||
// Clear localStorage with error handling
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
|
||||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
|
||||||
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
|
|
||||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
|
|
||||||
toast.success("Started a fresh chat")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to clear localStorage:", error)
|
|
||||||
toast.warning(
|
|
||||||
"Chat cleared but browser storage could not be updated",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowNewChatDialog(false)
|
|
||||||
}, [clearDiagram, handleFileChange, setMessages, setSessionId])
|
|
||||||
|
|
||||||
const handleInputChange = (
|
const handleInputChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) => {
|
) => {
|
||||||
@@ -1059,10 +759,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
previousXml: string,
|
previousXml: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
) => {
|
) => {
|
||||||
// Reset all retry/continuation state on user-initiated message
|
|
||||||
autoRetryCountRef.current = 0
|
|
||||||
partialXmlRef.current = ""
|
|
||||||
|
|
||||||
const config = getAIConfig()
|
const config = getAIConfig()
|
||||||
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
@@ -1073,17 +769,12 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
"x-access-code": config.accessCode,
|
"x-access-code": config.accessCode,
|
||||||
...(config.aiProvider && {
|
...(config.aiProvider && {
|
||||||
"x-ai-provider": config.aiProvider,
|
"x-ai-provider": config.aiProvider,
|
||||||
...(config.aiBaseUrl && {
|
|
||||||
"x-ai-base-url": config.aiBaseUrl,
|
|
||||||
}),
|
|
||||||
...(config.aiApiKey && {
|
|
||||||
"x-ai-api-key": config.aiApiKey,
|
|
||||||
}),
|
|
||||||
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
|
||||||
}),
|
}),
|
||||||
...(minimalStyle && {
|
...(config.aiBaseUrl && {
|
||||||
"x-minimal-style": "true",
|
"x-ai-base-url": config.aiBaseUrl,
|
||||||
}),
|
}),
|
||||||
|
...(config.aiApiKey && { "x-ai-api-key": config.aiApiKey }),
|
||||||
|
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1269,7 +960,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
style: {
|
style: {
|
||||||
maxWidth: "480px",
|
maxWidth: "480px",
|
||||||
},
|
},
|
||||||
duration: 2000,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -1320,18 +1010,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ButtonWithTooltip
|
|
||||||
tooltipContent="Start fresh chat"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowNewChatDialog(true)}
|
|
||||||
className="hover:bg-accent"
|
|
||||||
>
|
|
||||||
<MessageSquarePlus
|
|
||||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
|
||||||
/>
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1374,8 +1052,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
messages={messages}
|
messages={messages}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={handleFileChange}
|
setFiles={handleFileChange}
|
||||||
processedToolCallsRef={processedToolCallsRef}
|
|
||||||
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
|
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
status={status}
|
status={status}
|
||||||
@@ -1392,7 +1068,23 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
status={status}
|
status={status}
|
||||||
onSubmit={onFormSubmit}
|
onSubmit={onFormSubmit}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onClearChat={handleNewChat}
|
onClearChat={() => {
|
||||||
|
setMessages([])
|
||||||
|
clearDiagram()
|
||||||
|
const newSessionId = `session-${Date.now()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 9)}`
|
||||||
|
setSessionId(newSessionId)
|
||||||
|
xmlSnapshotsRef.current.clear()
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
||||||
|
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
||||||
|
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_SESSION_ID_KEY,
|
||||||
|
newSessionId,
|
||||||
|
)
|
||||||
|
}}
|
||||||
files={files}
|
files={files}
|
||||||
onFileChange={handleFileChange}
|
onFileChange={handleFileChange}
|
||||||
pdfData={pdfData}
|
pdfData={pdfData}
|
||||||
@@ -1400,8 +1092,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>
|
||||||
|
|
||||||
@@ -1414,12 +1104,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
onToggleDarkMode={onToggleDarkMode}
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResetWarningModal
|
|
||||||
open={showNewChatDialog}
|
|
||||||
onOpenChange={setShowNewChatDialog}
|
|
||||||
onClear={handleNewChat}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createContext, useContext, useRef, useState } from "react"
|
|||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef } from "react-drawio"
|
||||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
import { extractDiagramXML, validateMxCellStructure } from "../lib/utils"
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
@@ -23,7 +23,6 @@ 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
|
||||||
@@ -83,62 +82,25 @@ 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,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
let xmlToLoad = chart
|
|
||||||
|
|
||||||
// Validate XML structure before loading (unless skipped for internal use)
|
// Validate XML structure before loading (unless skipped for internal use)
|
||||||
if (!skipValidation) {
|
if (!skipValidation) {
|
||||||
const validation = validateAndFixXml(chart)
|
const validationError = validateMxCellStructure(chart)
|
||||||
if (!validation.valid) {
|
if (validationError) {
|
||||||
console.warn(
|
console.warn("[loadDiagram] Validation error:", validationError)
|
||||||
"[loadDiagram] Validation error:",
|
return validationError
|
||||||
validation.error,
|
|
||||||
)
|
|
||||||
return validation.error
|
|
||||||
}
|
|
||||||
// Use fixed XML if auto-fix was applied
|
|
||||||
if (validation.fixed) {
|
|
||||||
console.log(
|
|
||||||
"[loadDiagram] Auto-fixed XML issues:",
|
|
||||||
validation.fixes,
|
|
||||||
)
|
|
||||||
xmlToLoad = validation.fixed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||||
setChartXML(xmlToLoad)
|
setChartXML(chart)
|
||||||
|
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
drawioRef.current.load({
|
drawioRef.current.load({
|
||||||
xml: xmlToLoad,
|
xml: chart,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,20 +125,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +261,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
saveDiagramToStorage,
|
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [目录](#目录)
|
- [目录](#目录)
|
||||||
- [示例](#示例)
|
- [示例](#示例)
|
||||||
- [功能特性](#功能特性)
|
- [功能特性](#功能特性)
|
||||||
- [MCP服务器(预览)](#mcp服务器预览)
|
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [在线试用](#在线试用)
|
- [在线试用](#在线试用)
|
||||||
- [使用Docker运行(推荐)](#使用docker运行推荐)
|
- [使用Docker运行(推荐)](#使用docker运行推荐)
|
||||||
@@ -82,43 +81,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
|
|
||||||
- **LLM驱动的图表创建**:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
- **LLM驱动的图表创建**:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
||||||
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
|
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
|
||||||
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
|
|
||||||
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
|
|
||||||
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
||||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
- **AWS架构图支持**:专门支持生成AWS架构图
|
||||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|
||||||
## 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等客户端配置。
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 在线试用
|
### 在线试用
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [目次](#目次)
|
- [目次](#目次)
|
||||||
- [例](#例)
|
- [例](#例)
|
||||||
- [機能](#機能)
|
- [機能](#機能)
|
||||||
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
|
||||||
- [はじめに](#はじめに)
|
- [はじめに](#はじめに)
|
||||||
- [オンラインで試す](#オンラインで試す)
|
- [オンラインで試す](#オンラインで試す)
|
||||||
- [Dockerで実行(推奨)](#dockerで実行推奨)
|
- [Dockerで実行(推奨)](#dockerで実行推奨)
|
||||||
@@ -82,43 +81,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
|
|
||||||
- **LLM搭載のダイアグラム作成**:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
- **LLM搭載のダイアグラム作成**:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
||||||
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||||
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
|
|
||||||
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
|
|
||||||
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
- **AWSアーキテクチャダイアグラムサポート**:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
|
||||||
## MCPサーバー(プレビュー)
|
|
||||||
|
|
||||||
> **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
|
|
||||||
|
|
||||||
MCP(Model 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などのクライアント設定も含む)。
|
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
|
|
||||||
### オンラインで試す
|
### オンラインで試す
|
||||||
|
|||||||
@@ -80,23 +80,13 @@ SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflo
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
AZURE_API_KEY=your_api_key
|
AZURE_API_KEY=your_api_key
|
||||||
AZURE_RESOURCE_NAME=your-resource-name # Required: your Azure resource name
|
|
||||||
AI_MODEL=your-deployment-name
|
AI_MODEL=your-deployment-name
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use a custom endpoint instead of resource name:
|
Optional custom endpoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AZURE_API_KEY=your_api_key
|
AZURE_BASE_URL=https://your-resource.openai.azure.com
|
||||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # Alternative to AZURE_RESOURCE_NAME
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional reasoning configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_REASONING_EFFORT=low # Optional: low, medium, high
|
|
||||||
AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### AWS Bedrock
|
### AWS Bedrock
|
||||||
@@ -136,23 +126,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 +133,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
|
||||||
|
|||||||
14
env.example
14
env.example
@@ -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
|
||||||
|
|
||||||
@@ -41,13 +41,9 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
||||||
|
|
||||||
# Azure OpenAI Configuration
|
# Azure OpenAI Configuration
|
||||||
# Configure endpoint using ONE of these methods:
|
|
||||||
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
|
||||||
# 2. AZURE_BASE_URL - SDK appends /v1{path} to your URL
|
|
||||||
# If both are set, AZURE_BASE_URL takes precedence.
|
|
||||||
# AZURE_RESOURCE_NAME=your-resource-name
|
# AZURE_RESOURCE_NAME=your-resource-name
|
||||||
# AZURE_API_KEY=...
|
# AZURE_API_KEY=...
|
||||||
# AZURE_BASE_URL=https://your-resource.openai.azure.com/openai # Alternative: Custom endpoint
|
# AZURE_BASE_URL=https://your-resource.openai.azure.com # Optional: Custom endpoint (overrides resourceName)
|
||||||
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
||||||
# AZURE_REASONING_SUMMARY=detailed
|
# AZURE_REASONING_SUMMARY=detailed
|
||||||
|
|
||||||
@@ -68,11 +64,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-...
|
||||||
@@ -95,4 +86,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Enable PDF file upload to extract text and generate diagrams
|
# Enable PDF file upload to extract text and generate diagrams
|
||||||
# Enabled by default. Set to "false" to disable.
|
# Enabled by default. Set to "false" to disable.
|
||||||
# ENABLE_PDF_INPUT=true
|
# ENABLE_PDF_INPUT=true
|
||||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -377,16 +371,7 @@ function detectProvider(): ProviderName | null {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (process.env[envVar]) {
|
if (process.env[envVar]) {
|
||||||
// Azure requires additional config (baseURL or resourceName)
|
configuredProviders.push(provider as ProviderName)
|
||||||
if (provider === "azure") {
|
|
||||||
const hasBaseUrl = !!process.env.AZURE_BASE_URL
|
|
||||||
const hasResourceName = !!process.env.AZURE_RESOURCE_NAME
|
|
||||||
if (hasBaseUrl || hasResourceName) {
|
|
||||||
configuredProviders.push(provider as ProviderName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
configuredProviders.push(provider as ProviderName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,18 +393,6 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
`Please set it in your .env.local file.`,
|
`Please set it in your .env.local file.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key
|
|
||||||
if (provider === "azure") {
|
|
||||||
const hasBaseUrl = !!process.env.AZURE_BASE_URL
|
|
||||||
const hasResourceName = !!process.env.AZURE_RESOURCE_NAME
|
|
||||||
if (!hasBaseUrl && !hasResourceName) {
|
|
||||||
throw new Error(
|
|
||||||
`Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. ` +
|
|
||||||
`Please set one in your .env.local file.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,16 +417,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 +464,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` +
|
||||||
@@ -610,15 +572,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
case "azure": {
|
case "azure": {
|
||||||
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
|
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
|
||||||
const resourceName = process.env.AZURE_RESOURCE_NAME
|
if (baseURL || overrides?.apiKey) {
|
||||||
// Azure requires either baseURL or resourceName to construct the endpoint
|
|
||||||
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
|
||||||
if (baseURL || resourceName || overrides?.apiKey) {
|
|
||||||
const customAzure = createAzure({
|
const customAzure = createAzure({
|
||||||
apiKey,
|
apiKey,
|
||||||
// baseURL takes precedence over resourceName per SDK behavior
|
|
||||||
...(baseURL && { baseURL }),
|
...(baseURL && { baseURL }),
|
||||||
...(!baseURL && resourceName && { resourceName }),
|
|
||||||
})
|
})
|
||||||
model = customAzure(modelId)
|
model = customAzure(modelId)
|
||||||
} else {
|
} else {
|
||||||
@@ -679,17 +636,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`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
(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
(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>`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { extractText, getDocumentProxy } from "unpdf"
|
import { extractText, getDocumentProxy } from "unpdf"
|
||||||
|
|
||||||
// Maximum characters allowed for extracted text (configurable via env)
|
// Maximum characters allowed for extracted text
|
||||||
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
|
export const MAX_EXTRACTED_CHARS = 150000 // 150k chars
|
||||||
export const MAX_EXTRACTED_CHARS =
|
|
||||||
Number(process.env.NEXT_PUBLIC_MAX_EXTRACTED_CHARS) ||
|
|
||||||
DEFAULT_MAX_EXTRACTED_CHARS
|
|
||||||
|
|
||||||
// Text file extensions we support
|
// Text file extensions we support
|
||||||
const TEXT_EXTENSIONS = [
|
const TEXT_EXTENSIONS = [
|
||||||
|
|||||||
@@ -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: < for <, > for >, & for &, " 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: < for <, > for >, & for &, " 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)
|
||||||
}
|
}
|
||||||
|
|||||||
1601
lib/utils.ts
1601
lib/utils.ts
File diff suppressed because it is too large
Load Diff
235
package-lock.json
generated
235
package-lock.json
generated
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.2",
|
"version": "0.3.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.3.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",
|
||||||
@@ -34,6 +33,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@xmldom/xmldom": "^0.9.8",
|
"@xmldom/xmldom": "^0.9.8",
|
||||||
"ai": "^5.0.89",
|
"ai": "^5.0.89",
|
||||||
"base-64": "^1.0.0",
|
"base-64": "^1.0.0",
|
||||||
@@ -41,7 +41,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 +78,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 +97,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 +165,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 +181,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 +2489,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 +2505,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 +2521,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 +2537,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 +2553,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 +2569,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 +2585,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 +2601,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 +2617,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"
|
||||||
],
|
],
|
||||||
@@ -6080,6 +6028,44 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@vercel/analytics": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@remix-run/react": "^2",
|
||||||
|
"@sveltejs/kit": "^1 || ^2",
|
||||||
|
"next": ">= 13",
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"svelte": ">= 4",
|
||||||
|
"vue": "^3",
|
||||||
|
"vue-router": "^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@remix-run/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@sveltejs/kit": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue-router": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vercel/oidc": {
|
"node_modules/@vercel/oidc": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
|
||||||
@@ -6148,23 +6134,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 +8017,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 +9203,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 +10676,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 +10694,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": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.3",
|
"version": "0.3.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",
|
||||||
@@ -38,6 +37,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@xmldom/xmldom": "^0.9.8",
|
"@xmldom/xmldom": "^0.9.8",
|
||||||
"ai": "^5.0.89",
|
"ai": "^5.0.89",
|
||||||
"base-64": "^1.0.0",
|
"base-64": "^1.0.0",
|
||||||
@@ -45,7 +45,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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
2044
packages/mcp-server/package-lock.json
generated
2044
packages/mcp-server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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>`
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
@@ -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)
|
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
12
vercel.json
12
vercel.json
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"functions": {
|
|
||||||
"app/api/chat/route.ts": {
|
|
||||||
"memory": 512,
|
|
||||||
"maxDuration": 120
|
|
||||||
},
|
|
||||||
"app/api/**/route.ts": {
|
|
||||||
"memory": 256,
|
|
||||||
"maxDuration": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user