Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
5c9d387c44 refactor: eliminate code duplication (DRY principle)
## Problem Solved
Previous refactoring added 105 lines (1476→1581) by extracting code into separate files without eliminating duplication. This refactor focuses on reducing code size through deduplication while maintaining file separation for maintainability.

## Summary
- Reduced total lines from 1581 to 1519 (-62 lines, 3.9% reduction)
- Eliminated duplicate patterns using generic helpers and factory functions
- Maintained file structure for maintainability
- Zero functional changes - same behavior

### Phase 1: DRY use-quota-manager.tsx
- Created parseStorageCount() helper (eliminates 6x localStorage read duplication)
- Created createQuotaChecker() factory (consolidates 3 check function bodies)
- Created createQuotaIncrementer() factory (consolidates 3 increment function bodies)
- Result: 242→247 lines (+5 lines, but fully DRY with eliminated duplication)

### Phase 2: DRY chat-panel.tsx (1176→1109 lines, -67 lines)

#### 2.1: Extract checkAllQuotaLimits helper
- Replaced 3 occurrences of 18-line quota check blocks
- Saved 36 lines

#### 2.2: Extract sendChatMessage helper
- Replaced 3 occurrences of 21-line sendMessage+headers blocks
- Saved 42 lines

#### 2.3: Extract processFilesAndAppendContent helper
- Replaced 2 occurrences of file processing loops
- Handles PDF, text, and image files uniformly
- Async helper with optional image parts parameter
2025-12-11 13:04:43 +09:00
128 changed files with 1860 additions and 26659 deletions

View File

@@ -1,47 +0,0 @@
name: Auto Format
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Biome
run: npm install --save-dev @biomejs/biome
- name: Run Biome format
run: npx @biomejs/biome check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Commit changes
if: steps.changes.outputs.has_changes == 'true'
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "style: auto-format with Biome"
git push

View File

@@ -64,27 +64,3 @@ jobs:
cache-to: type=gha,mode=max
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

View File

@@ -1,46 +0,0 @@
name: Electron Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Version tag (e.g., v0.4.5)"
required: false
jobs:
build:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest
platform: linux
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build and publish Electron app
run: npm run dist:${{ matrix.platform }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

17
.gitignore vendored
View File

@@ -2,8 +2,6 @@
# dependencies
/node_modules
packages/*/node_modules
packages/*/dist
/.pnp
.pnp.*
.yarn/*
@@ -48,18 +46,3 @@ push-via-ec2.sh
.dev.vars
.open-next/
.wrangler/
.env*.local
# Electron
/dist-electron/
/release/
/electron-standalone/
*.dmg
*.exe
*.AppImage
*.deb
*.rpm
*.snap
CLAUDE.md
.spec-workflow

View File

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

View File

@@ -30,10 +30,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Table of Contents](#table-of-contents)
- [Examples](#examples)
- [Features](#features)
- [MCP Server (Preview)](#mcp-server-preview)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
- [Desktop Application](#desktop-application)
- [Run with Docker (Recommended)](#run-with-docker-recommended)
- [Installation](#installation)
- [Deployment](#deployment)
@@ -87,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
- **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.
- **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
## 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
### Try it Online
@@ -136,28 +102,6 @@ No installation needed! Try the app directly on our demo site:
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
### Desktop Application
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
| Platform | Download |
|----------|----------|
| macOS | `.dmg` (Intel & Apple Silicon) |
| Windows | `.exe` installer (x64 & ARM64) |
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
**Features:**
- **Secure API key storage**: Credentials encrypted using OS keychain
- **Configuration presets**: Save and switch between AI providers via menu
- **Native file dialogs**: Open/save `.drawio` files directly
- **Offline capable**: Works without internet after first launch
**Quick Setup:**
1. Download and install for your platform
2. Open the app → **Menu → Configuration → Manage Presets**
3. Add your AI provider credentials
4. Start creating diagrams!
### Run with Docker (Recommended)
If you just want to run it locally, the best way is to use Docker.
@@ -261,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.
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

View File

@@ -1,172 +0,0 @@
import { GoogleAnalytics } from "@next/third-parties/google"
import type { Metadata, Viewport } from "next"
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { notFound } from "next/navigation"
import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import type { Locale } from "@/lib/i18n/config"
import { i18n } from "@/lib/i18n/config"
import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries"
import "../globals.css"
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
})
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
// Generate static params for all locales
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale }))
}
// Generate metadata per locale
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>
}): Promise<Metadata> {
const { lang: rawLang } = await params
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
// Default to English metadata
const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator",
}
const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
}
return {
title: titles[lang],
description: descriptions[lang],
keywords: [
"AI diagram generator",
"AWS architecture",
"flowchart creator",
"draw.io",
"AI drawing tool",
"technical diagrams",
"diagram automation",
"free diagram generator",
"online diagram maker",
],
authors: [{ name: "Next AI Draw.io" }],
creator: "Next AI Draw.io",
publisher: "Next AI Draw.io",
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
openGraph: {
title: titles[lang],
description: descriptions[lang],
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
images: [
{
url: "/architecture.png",
width: 1200,
height: 630,
alt: "Next AI Draw.io - AI-powered diagram creation tool",
},
],
},
twitter: {
card: "summary_large_image",
title: titles[lang],
description: descriptions[lang],
images: ["/architecture.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: "/favicon.ico",
},
alternates: {
languages: {
en: "/en",
zh: "/zh",
ja: "/ja",
},
},
}
}
export default async function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode
params: Promise<{ lang: string }>
}>) {
const { lang } = await params
if (!hasLocale(lang)) notFound()
const validLang = lang as Locale
const dictionary = await getDictionary(validLang)
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Next AI Draw.io",
applicationCategory: "DesignApplication",
operatingSystem: "Web Browser",
description:
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
url: "https://next-ai-drawio.jiang.jp",
inLanguage: validLang,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
}
return (
<html lang={validLang} suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DictionaryProvider dictionary={dictionary}>
<DiagramProvider>{children}</DiagramProvider>
</DictionaryProvider>
</body>
{process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
)}
</html>
)
}

View File

@@ -3,14 +3,10 @@ import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError,
LoadAPIKeyError,
stepCountIs,
streamText,
} from "ai"
import fs from "fs/promises"
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
@@ -22,7 +18,7 @@ import {
} from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts"
export const maxDuration = 120
export const maxDuration = 300
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -72,45 +68,62 @@ function isMinimalDiagram(xml: string): boolean {
// 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)
// Also fixes invalid/undefined inputs from interrupted streaming
function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
const replacedContent = msg.content.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
}
return part
})
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
function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`
@@ -173,9 +186,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
: undefined
// Extract user input text for Langfuse trace
const lastMessage = messages[messages.length - 1]
const currentMessage = messages[messages.length - 1]
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
setTraceInput({
@@ -214,16 +227,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
baseUrl: req.headers.get("x-ai-base-url"),
apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"),
// AWS Bedrock credentials
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"),
}
// Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true"
// Get AI model with optional client overrides
const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides)
@@ -235,7 +240,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
)
// 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
const fileParts =
@@ -244,49 +255,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
"""md
${userInputText}
${lastMessageText}
"""`
// Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages)
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
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,
})
}
})
}
})
// Fix tool call inputs for Bedrock API (requires JSON objects, not strings)
const fixedMessages = fixToolCallInputs(modelMessages)
// Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
const enableHistoryReplace =
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
const placeholderMessages = enableHistoryReplace
? replaceHistoricalToolInputs(modelMessages)
: modelMessages
// Replace historical tool call XML with placeholders to reduce tokens and avoid confusion
const placeholderMessages = replaceHistoricalToolInputs(fixedMessages)
// Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases
@@ -295,63 +274,6 @@ ${userInputText}
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)
if (enhancedMessages.length >= 1) {
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
@@ -427,71 +349,7 @@ ${userInputText}
const result = streamText({
model,
...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}),
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,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
...(headers && { headers }),
@@ -502,6 +360,32 @@ ${userInputText}
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 }) => {
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
setTraceOutput(text, {
@@ -512,32 +396,36 @@ ${userInputText}
tools: {
// Client-side tool that will be executed on the client
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):
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. All mxCell elements must be siblings - never nested
4. Every mxCell needs a unique id (start from "2")
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
6. Escape special chars in values: &lt; &gt; &amp; &quot;
1. All mxCell elements must be DIRECT children of <root> - never nested
2. Every mxCell needs a unique id
3. Every mxCell (except id="0") needs a valid parent attribute
4. Edge source/target must reference existing cell IDs
5. Escape special chars in values: &lt; &gt; &amp; &quot;
6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
Example (generate ONLY this - no wrapper tags):
<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>
Example with swimlanes and edges (note: all mxCells are siblings):
<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>
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
@@ -550,122 +438,35 @@ Notes:
}),
},
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:
- 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\\"`,
⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`,
inputSchema: z.object({
operations: z
edits: z
.array(
z.object({
type: z
.enum(["update", "add", "delete"])
.describe("Operation type"),
cell_id: z
search: z
.string()
.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()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
.describe("Replacement lines"),
}),
)
.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(
"Continuation XML fragment to append (NO wrapper tags)",
"Array of search/replace pairs to apply sequentially",
),
}),
},
get_shape_library: {
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
- Networking: cisco19, network, kubernetes, vvd, rack
- Business: bpmn, lean_mapping
- General: flowchart, basic, arrows2, infographic, sitemap
- UI/Mockups: android
- Enterprise: citrix, sap, mscae, atlassian
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
- Icons: webicons
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
library: z
.string()
.describe(
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
),
}),
execute: async ({ library }) => {
// Sanitize input - prevent path traversal attacks
const sanitizedLibrary = library
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
if (sanitizedLibrary !== library.toLowerCase()) {
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
}
const baseDir = path.join(
process.cwd(),
"docs/shape-libraries",
)
const filePath = path.join(
baseDir,
`${sanitizedLibrary}.md`,
)
// Verify path stays within expected directory
const resolvedPath = path.resolve(filePath)
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
return `Invalid library path.`
}
try {
const content = await fs.readFile(filePath, "utf-8")
return content
} catch (error) {
if (
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, lean_mapping, openstack, rack`
}
console.error(
`[get_shape_library] Error loading "${library}":`,
error,
)
return `Error loading library "${library}". Please try again.`
}
},
},
},
...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE),
@@ -690,7 +491,6 @@ Call this tool to get shape names and usage syntax for a specific library.`,
return {
inputTokens: totalInputTokens,
outputTokens: usage.outputTokens ?? 0,
finishReason: (part as any).finishReason,
}
}
return undefined

View File

@@ -81,15 +81,16 @@ Contains the actual diagram data.
## 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
<root>
<mxCell id="0"/> <!-- Auto-added -->
<mxCell id="1" parent="0"/> <!-- Auto-added -->
<!-- Your mxCell elements go here (start from id="2") -->
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- Other cells go here -->
</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
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
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
1. Start with the basic structure (`mxfile`, `diagram`, `mxGraphModel`, `root`)
2. Always include the two special cells (id = "0" and id = "1")
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
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

View File

@@ -1,281 +0,0 @@
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
export const runtime = "nodejs"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
interface ValidateRequest {
provider: string
apiKey: string
baseUrl?: string
modelId: string
// AWS Bedrock specific
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
}
export async function POST(req: Request) {
try {
const body: ValidateRequest = await req.json()
const {
provider,
apiKey,
baseUrl,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
} = body
if (!provider || !modelId) {
return NextResponse.json(
{ valid: false, error: "Provider and model ID are required" },
{ status: 400 },
)
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
)
}
// Validate credentials based on provider
if (provider === "bedrock") {
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
return NextResponse.json(
{
valid: false,
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
},
{ status: 400 },
)
}
} else if (provider !== "ollama" && !apiKey) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
let model: any
switch (provider) {
case "openai": {
const openai = createOpenAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openai.chat(modelId)
break
}
case "anthropic": {
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || "https://api.anthropic.com/v1",
})
model = anthropic(modelId)
break
}
case "google": {
const google = createGoogleGenerativeAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = google(modelId)
break
}
case "azure": {
const azure = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = azure.chat(modelId)
break
}
case "bedrock": {
const bedrock = createAmazonBedrock({
accessKeyId: awsAccessKeyId,
secretAccessKey: awsSecretAccessKey,
region: awsRegion,
})
model = bedrock(modelId)
break
}
case "openrouter": {
const openrouter = createOpenRouter({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openrouter(modelId)
break
}
case "deepseek": {
if (baseUrl || apiKey) {
const ds = createDeepSeek({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = ds(modelId)
} else {
model = deepseek(modelId)
}
break
}
case "siliconflow": {
const sf = createOpenAI({
apiKey,
baseURL: baseUrl || "https://api.siliconflow.com/v1",
})
model = sf.chat(modelId)
break
}
case "ollama": {
const ollama = createOllama({
baseURL: baseUrl || "http://localhost:11434",
})
model = ollama(modelId)
break
}
case "gateway": {
const gw = createGateway({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = gw(modelId)
break
}
default:
return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` },
{ status: 400 },
)
}
// Make a minimal test request
const startTime = Date.now()
await generateText({
model,
prompt: "Say 'OK'",
maxOutputTokens: 20,
})
const responseTime = Date.now() - startTime
return NextResponse.json({
valid: true,
responseTime,
})
} catch (error) {
console.error("[validate-model] Error:", error)
let errorMessage = "Validation failed"
if (error instanceof Error) {
// Extract meaningful error message
if (
error.message.includes("401") ||
error.message.includes("Unauthorized")
) {
errorMessage = "Invalid API key"
} else if (
error.message.includes("404") ||
error.message.includes("not found")
) {
errorMessage = "Model not found"
} else if (
error.message.includes("429") ||
error.message.includes("rate limit")
) {
errorMessage = "Rate limited - try again later"
} else if (error.message.includes("ECONNREFUSED")) {
errorMessage = "Cannot connect to server"
} else {
errorMessage = error.message.slice(0, 100)
}
}
return NextResponse.json(
{ valid: false, error: errorMessage },
{ status: 200 }, // Return 200 so client can read error message
)
}
}

127
app/layout.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { GoogleAnalytics } from "@next/third-parties/google"
import { Analytics } from "@vercel/analytics/react"
import type { Metadata, Viewport } from "next"
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { DiagramProvider } from "@/contexts/diagram-context"
import "./globals.css"
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
})
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export const metadata: Metadata = {
title: "Next AI Draw.io - AI-Powered Diagram Generator",
description:
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
keywords: [
"AI diagram generator",
"AWS architecture",
"flowchart creator",
"draw.io",
"AI drawing tool",
"technical diagrams",
"diagram automation",
"free diagram generator",
"online diagram maker",
],
authors: [{ name: "Next AI Draw.io" }],
creator: "Next AI Draw.io",
publisher: "Next AI Draw.io",
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
openGraph: {
title: "Next AI Draw.io - AI Diagram Generator",
description:
"Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale: "en_US",
images: [
{
url: "/architecture.png",
width: 1200,
height: 630,
alt: "Next AI Draw.io - AI-powered diagram creation tool",
},
],
},
twitter: {
card: "summary_large_image",
title: "Next AI Draw.io - AI Diagram Generator",
description:
"Create professional diagrams with AI assistance. Free, no login required.",
images: ["/architecture.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: "/favicon.ico",
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Next AI Draw.io",
applicationCategory: "DesignApplication",
operatingSystem: "Web Browser",
description:
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
url: "https://next-ai-drawio.jiang.jp",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
}
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DiagramProvider>{children}</DiagramProvider>
<Analytics />
</body>
{process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
)}
</html>
)
}

View File

@@ -1,28 +0,0 @@
import type { MetadataRoute } from "next"
import { getAssetUrl } from "@/lib/base-path"
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: getAssetUrl("/"),
display: "standalone",
background_color: "#f9fafb",
theme_color: "#171d26",
icons: [
{
src: getAssetUrl("/favicon-192x192.png"),
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: getAssetUrl("/favicon-512x512.png"),
sizes: "512x512",
type: "image/png",
purpose: "any",
},
],
}
}

View File

@@ -1,5 +1,5 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
@@ -15,15 +15,8 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
const {
drawioRef,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -32,29 +25,6 @@ export default function Home() {
const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isSavingRef = useRef(false)
const mouseOverDrawioRef = useRef(false)
const isMobileRef = useRef(false)
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
useEffect(() => {
if (!showSaveDialog) {
const timeout = setTimeout(() => {
isSavingRef.current = false
}, 1000)
return () => clearTimeout(timeout)
}
}, [showSaveDialog])
// Handle save from draw.io's built-in save button
// Note: draw.io sends save events for various reasons (focus changes, etc.)
// We use mouse position to determine if the user is interacting with draw.io
const handleDrawioSave = useCallback(() => {
if (!mouseOverDrawioRef.current) return
if (isSavingRef.current) return
isSavingRef.current = true
setShowSaveDialog(true)
}, [setShowSaveDialog])
// Load preferences from localStorage after mount
useEffect(() => {
@@ -65,10 +35,12 @@ export default function Home() {
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) {
// Use saved preference
const isDark = savedDarkMode === "true"
setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark)
} else {
// First visit: match browser preference
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches
@@ -86,44 +58,25 @@ export default function Home() {
setIsLoaded(true)
}, [])
const handleDarkModeChange = async () => {
await saveDiagramToStorage()
const toggleDarkMode = () => {
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
// Reset so onDrawioLoad fires again after remount
resetDrawioReady()
}
const handleDrawioUiChange = async () => {
await saveDiagramToStorage()
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady()
}
// Check mobile - save diagram and reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
// Check mobile
useEffect(() => {
const checkMobile = () => {
const newIsMobile = window.innerWidth < 768
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
saveDiagramToStorage().catch(() => {})
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [saveDiagramToStorage, resetDrawioReady])
}, [])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
@@ -169,9 +122,11 @@ export default function Home() {
<div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup
id="main-panel-group"
key={isMobile ? "mobile" : "desktop"}
direction={isMobile ? "vertical" : "horizontal"}
className="h-full"
>
{/* Draw.io Canvas */}
<ResizablePanel
id="drawio-panel"
defaultSize={isMobile ? 50 : 67}
@@ -181,12 +136,6 @@ export default function Home() {
className={`h-full relative ${
isMobile ? "p-1" : "p-2"
}`}
onMouseEnter={() => {
mouseOverDrawioRef.current = true
}}
onMouseLeave={() => {
mouseOverDrawioRef.current = false
}}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded ? (
@@ -195,7 +144,6 @@ export default function Home() {
ref={drawioRef}
onExport={handleDiagramExport}
onLoad={onDrawioLoad}
onSave={handleDrawioSave}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
@@ -219,7 +167,6 @@ export default function Home() {
{/* Chat Panel */}
<ResizablePanel
key={isMobile ? "mobile" : "desktop"}
id="chat-panel"
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
@@ -235,9 +182,15 @@ export default function Home() {
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
onToggleDrawioUi={() => {
const newUi =
drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady()
}}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
onToggleDarkMode={toggleDarkMode}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>

View File

@@ -1,156 +0,0 @@
import { Cloud } from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
export type ModelSelectorProps = ComponentProps<typeof Dialog>
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
)
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
)
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode
}
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
)
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
)
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
)
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
)
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
)
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
)
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
)
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
)
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
)
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider: string
}
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => {
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
if (provider === "amazon-bedrock") {
return <Cloud className={cn("size-4", className)} />
}
return (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${provider}.svg`}
width={16}
/>
)
}
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className,
)}
{...props}
/>
)
export type ModelSelectorNameProps = ComponentProps<"span">
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
)

View File

@@ -1,15 +1,6 @@
"use client"
import {
Cloud,
FileText,
GitBranch,
Palette,
Terminal,
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
import { Cloud, FileText, GitBranch, Palette, Zap } from "lucide-react"
interface ExampleCardProps {
icon: React.ReactNode
@@ -26,8 +17,6 @@ function ExampleCard({
onClick,
isNew,
}: ExampleCardProps) {
const dict = useDictionary()
return (
<button
onClick={onClick}
@@ -54,7 +43,7 @@ function ExampleCard({
</h3>
{isNew && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
{dict.common.new}
NEW
</span>
)}
</div>
@@ -74,18 +63,16 @@ export default function ExamplePanel({
setInput: (input: string) => void
setFiles: (files: File[]) => void
}) {
const dict = useDictionary()
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
try {
const response = await fetch(getAssetUrl("/example.png"))
const response = await fetch("/example.png")
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
console.error("Error loading example image:", error)
}
}
@@ -93,14 +80,14 @@ export default function ExamplePanel({
setInput("Replicate this in aws style")
try {
const response = await fetch(getAssetUrl("/architecture.png"))
const response = await fetch("/architecture.png")
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
console.error("Error loading architecture image:", error)
}
}
@@ -108,75 +95,49 @@ export default function ExamplePanel({
setInput("Summarize this paper as a diagram")
try {
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
const response = await fetch("/chain-of-thought.txt")
const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
console.error("Error loading text file:", error)
}
}
return (
<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">
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div>
</a>
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
Create diagrams with AI
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
Describe what you want to create or upload an image to
replicate
</p>
</div>
{/* Examples grid */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
Quick Examples
</p>
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title={dict.examples.paperToDiagram}
description={dict.examples.paperDescription}
title="Paper to Diagram"
description="Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more"
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title={dict.examples.animatedDiagram}
description={dict.examples.animatedDescription}
title="Animated Diagram"
description="Draw a transformer architecture with animated connectors"
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
@@ -187,22 +148,22 @@ export default function ExamplePanel({
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title={dict.examples.awsArchitecture}
description={dict.examples.awsDescription}
title="AWS Architecture"
description="Create a cloud architecture diagram with AWS icons"
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title={dict.examples.replicateFlowchart}
description={dict.examples.replicateDescription}
title="Replicate Flowchart"
description="Upload and replicate an existing flowchart"
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title={dict.examples.creativeDrawing}
description={dict.examples.creativeDescription}
title="Creative Drawing"
description="Draw something fun and creative"
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
@@ -211,7 +172,7 @@ export default function ExamplePanel({
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
{dict.examples.cachedNote}
Examples are cached for instant response
</p>
</div>
</div>

View File

@@ -14,22 +14,12 @@ import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import type { FlattenedModel } from "@/lib/types/model-config"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -62,7 +52,6 @@ interface ValidationResult {
function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
@@ -70,23 +59,17 @@ function validateFiles(
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
errors.push(`Maximum ${MAX_FILES} files allowed`)
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
errors.push(`Only ${availableSlots} more file(s) allowed`)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
errors.push(`"${file.name}" is not a supported file type`)
continue
}
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
@@ -94,11 +77,7 @@ function validateFiles(
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: maxSizeMB,
}),
`"${file.name}" is ${formatFileSize(file.size)} (exceeds ${maxSizeMB}MB)`,
)
} else {
validFiles.push(file)
@@ -108,7 +87,7 @@ function validateFiles(
return { validFiles, errors }
}
function showValidationErrors(errors: string[], dict: any) {
function showValidationErrors(errors: string[]) {
if (errors.length === 0) return
if (errors.length === 1) {
@@ -119,20 +98,14 @@ function showValidationErrors(errors: string[], dict: any) {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
{errors.length} files rejected:
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
<li>...and {errors.length - 3} more</li>
)}
</ul>
</div>,
@@ -156,13 +129,6 @@ interface ChatInputProps {
onToggleHistory?: (show: boolean) => void
sessionId?: string
error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
}
export function ChatInput({
@@ -178,24 +144,14 @@ export function ChatInput({
onToggleHistory = () => {},
sessionId,
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -207,6 +163,7 @@ export function ChatInput({
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
@@ -253,9 +210,8 @@ export function ChatInput({
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
@@ -264,16 +220,12 @@ export function ChatInput({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
const { validFiles, errors } = validateFiles(newFiles, files.length)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
@@ -317,9 +269,8 @@ export function ChatInput({
const { validFiles, errors } = validateFiles(
supportedFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
@@ -352,6 +303,8 @@ export function ChatInput({
/>
</div>
)}
{/* Input container */}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
@@ -359,20 +312,22 @@ export function ChatInput({
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
placeholder="Describe your diagram or upload a file..."
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
/>
{/* Action bar */}
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
{/* Left actions */}
<div className="flex items-center gap-1">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
tooltipContent={dict.chat.clearConversation}
tooltipContent="Clear conversation"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
@@ -388,44 +343,17 @@ export function ChatInput({
showHistory={showHistory}
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
? dict.chat.minimalStyle
: dict.chat.styledMode}
</label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{dict.chat.minimalTooltip}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1 overflow-hidden justify-end">
{/* Right actions */}
<div className="flex items-center gap-1">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
tooltipContent="Diagram history"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
@@ -437,7 +365,7 @@ export function ChatInput({
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
tooltipContent="Save diagram"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
@@ -460,7 +388,7 @@ export function ChatInput({
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
tooltipContent="Upload file (image, PDF, text)"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
@@ -476,14 +404,6 @@ export function ChatInput({
disabled={isDisabled}
/>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
@@ -492,7 +412,7 @@ export function ChatInput({
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
isDisabled ? "Sending..." : "Send message"
}
>
{isDisabled ? (
@@ -500,7 +420,7 @@ export function ChatInput({
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
Send
</>
)}
</Button>

View File

@@ -10,55 +10,34 @@ import {
Cpu,
FileCode,
FileText,
Minus,
Pencil,
Plus,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import type { MutableRefObject } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getApiEndpoint } from "@/lib/base-path"
import {
applyDiagramOperations,
convertToLegalXml,
isMxCellXmlComplete,
replaceNodes,
validateAndFixXml,
validateMxCellStructure,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface DiagramOperation {
type: "update" | "add" | "delete"
cell_id: 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"),
)
interface EditPair {
search: string
replace: string
}
// Tool part interface for type safety
@@ -66,44 +45,49 @@ interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
output?: string
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
{edits.map((edit, index) => (
<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"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
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 className="text-xs font-medium text-muted-foreground">
Change {index + 1}
</span>
</div>
{op.new_xml && (
<div className="divide-y divide-border/30">
{/* Search (old) */}
<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">
{op.new_xml}
<div className="flex items-center gap-1.5 mb-1.5">
<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>
</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>
@@ -185,8 +169,6 @@ interface ChatMessageDisplayProps {
messages: UIMessage[]
setInput: (input: string) => void
setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
sessionId?: string
onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void
@@ -197,8 +179,6 @@ export function ChatMessageDisplay({
messages,
setInput,
setFiles,
processedToolCallsRef,
editDiagramOriginalXmlRef,
sessionId,
onRegenerate,
onEditMessage,
@@ -207,23 +187,7 @@ export function ChatMessageDisplay({
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
const processedToolCalls = processedToolCallsRef
// Track the last processed XML per toolCallId to skip redundant processing during streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
// Refs for edit_diagram streaming
const pendingEditRef = useRef<{
operations: DiagramOperation[]
toolCallId: string
} | null>(null)
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const processedToolCalls = useRef<Set<string>>(new Set())
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{},
)
@@ -249,32 +213,9 @@ export function ChatMessageDisplay({
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
// Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea")
textarea.value = text
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)
}
console.error("Failed to copy message:", err)
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
}
}
@@ -292,7 +233,7 @@ export function ChatMessageDisplay({
setFeedback((prev) => ({ ...prev, [messageId]: value }))
try {
await fetch(getApiEndpoint("/api/log-feedback"), {
await fetch("/api/log-feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -302,88 +243,32 @@ export function ChatMessageDisplay({
}),
})
} catch (error) {
console.error("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
})
console.warn("Failed to log feedback:", error)
}
}
const handleDisplayChart = useCallback(
(xml: string, showToast = false) => {
(xml: string) => {
const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) {
// Parse and validate XML BEFORE calling replaceNodes
const parser = new DOMParser()
const testDoc = parser.parseFromString(convertedXml, "text/xml")
const parseError = testDoc.querySelector("parsererror")
// 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)
if (parseError) {
// Use console.warn instead of console.error to avoid triggering
// Next.js dev mode error overlay for expected streaming states
// (partial XML during streaming is normal and will be fixed by subsequent updates)
if (showToast) {
// Only log as error and show toast if this is the final XML
console.error(
"[ChatMessageDisplay] Malformed XML detected in final output",
)
toast.error(
"AI generated invalid diagram XML. Please try regenerating.",
)
}
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,
const validationError = validateMxCellStructure(replacedXML)
if (!validationError) {
previousXML.current = convertedXml
// Skip validation in loadDiagram since we already validated above
onDisplayChart(replacedXML, true)
} else {
console.log(
"[ChatMessageDisplay] XML validation failed:",
validationError,
)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(
"Failed to process diagram. Please try regenerating.",
)
}
}
}
},
@@ -403,12 +288,7 @@ export function ChatMessageDisplay({
}, [editingMessageId])
useEffect(() => {
// Only process the last message for streaming performance
// Previous messages are already processed and won't change
const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : []
messagesToProcess.forEach((message) => {
messages.forEach((message) => {
if (message.parts) {
message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) {
@@ -427,194 +307,24 @@ export function ChatMessageDisplay({
input?.xml
) {
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 (
state === "input-streaming" ||
state === "input-available"
) {
// Debounce streaming updates - queue the XML and process after delay
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,
)
}
handleDisplayChart(xml)
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final output - process immediately (clear any pending debounce)
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
pendingXmlRef.current = null
}
// Show toast only if final XML is malformed
handleDisplayChart(xml, true)
handleDisplayChart(xml)
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
}
}
}
})
}
})
// 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])
}, [messages, handleDisplayChart])
const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId
@@ -635,8 +345,6 @@ export function ChatMessageDisplay({
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
@@ -665,23 +373,11 @@ export function ChatMessageDisplay({
Complete
</span>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(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>
)
})()}
{state === "output-error" && (
<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 && (
<button
type="button"
@@ -702,9 +398,9 @@ export function ChatMessageDisplay({
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
input.edits &&
Array.isArray(input.edits) ? (
<EditDiffDisplay edits={input.edits} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
@@ -714,42 +410,11 @@ export function ChatMessageDisplay({
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(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>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
{output && state === "output-error" && (
<div className="px-4 py-3 border-t border-border/40 text-sm text-red-600">
{output}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@
import { FileCode, FileText, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
function formatCharCount(count: number): string {
@@ -27,10 +26,10 @@ export function FilePreviewList({
onRemoveFile,
pdfData = new Map(),
}: FilePreviewListProps) {
const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
// Create and cleanup object URLs when files change
useEffect(() => {
const currentUrls = imageUrlsRef.current
@@ -47,6 +46,7 @@ export function FilePreviewList({
}
}
})
// Revoke URLs for files that are no longer in the list
currentUrls.forEach((url, file) => {
if (!newUrls.has(file)) {
@@ -57,6 +57,7 @@ export function FilePreviewList({
imageUrlsRef.current = newUrls
setImageUrls(newUrls)
}, [files])
// Cleanup all URLs on unmount only
useEffect(() => {
return () => {
@@ -67,6 +68,7 @@ export function FilePreviewList({
imageUrlsRef.current = new Map()
}
}, [])
// Clear selected image if its URL was revoked
useEffect(() => {
if (
@@ -124,14 +126,14 @@ export function FilePreviewList({
</span>
{pdfInfo?.isExtracting ? (
<span className="text-[10px] text-muted-foreground">
{dict.file.reading}
Reading...
</span>
) : pdfInfo?.charCount ? (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
pdfInfo.charCount,
)}{" "}
{dict.file.chars}
chars
</span>
) : null}
</div>
@@ -145,7 +147,7 @@ export function FilePreviewList({
type="button"
onClick={() => onRemoveFile(file)}
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={dict.file.removeFile}
aria-label="Remove file"
>
<X className="h-3 w-3" />
</button>
@@ -153,6 +155,7 @@ export function FilePreviewList({
)
})}
</div>
{/* Image Modal/Lightbox */}
{selectedImage && (
<div
@@ -162,7 +165,7 @@ export function FilePreviewList({
<button
className="absolute top-4 right-4 z-10 bg-white rounded-full p-2 hover:bg-gray-200 transition-colors"
onClick={() => setSelectedImage(null)}
aria-label={dict.common.close}
aria-label="Close"
>
<X className="h-6 w-6" />
</button>

View File

@@ -12,8 +12,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface HistoryDialogProps {
showHistory: boolean
@@ -24,7 +22,6 @@ export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const dict = useDictionary()
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -45,15 +42,18 @@ export function HistoryDialog({
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogTitle>Diagram History</DialogTitle>
<DialogDescription>
{dict.history.description}
Here saved each diagram before AI modification.
<br />
Click on a diagram to restore it
</DialogDescription>
</DialogHeader>
{diagramHistory.length === 0 ? (
<div className="text-center p-4 text-gray-500">
{dict.history.noHistory}
No history available yet. Send messages to create
diagram history.
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
@@ -70,14 +70,14 @@ export function HistoryDialog({
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
src={item.svg}
alt={`${dict.history.version} ${index + 1}`}
alt={`Diagram version ${index + 1}`}
width={200}
height={100}
className="object-contain w-full h-full p-1"
/>
</div>
<div className="text-xs text-center mt-1 text-gray-500">
{dict.history.version} {index + 1}
Version {index + 1}
</div>
</div>
))}
@@ -88,23 +88,21 @@ export function HistoryDialog({
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
{formatMessage(dict.history.restoreTo, {
version: selectedIndex + 1,
})}
Restore to Version {selectedIndex + 1}?
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
{dict.common.cancel}
Cancel
</Button>
<Button onClick={handleConfirmRestore}>
{dict.common.confirm}
Confirm
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
{dict.common.close}
Close
</Button>
)}
</DialogFooter>

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +0,0 @@
"use client"
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
import { useMemo, useState } from "react"
import {
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorLogo,
ModelSelectorName,
ModelSelector as ModelSelectorRoot,
ModelSelectorSeparator,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import type { FlattenedModel } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
interface ModelSelectorProps {
models: FlattenedModel[]
selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void
onConfigure: () => void
disabled?: boolean
}
// Map our provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
gateway: "vercel",
}
// Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider(
models: FlattenedModel[],
): Map<string, { provider: string; models: FlattenedModel[] }> {
const groups = new Map<
string,
{ provider: string; models: FlattenedModel[] }
>()
for (const model of models) {
const key = model.providerLabel
const existing = groups.get(key)
if (existing) {
existing.models.push(model)
} else {
groups.set(key, { provider: model.provider, models: [model] })
}
}
return groups
}
export function ModelSelector({
models,
selectedModelId,
onSelect,
onConfigure,
disabled = false,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false)
// Only show validated models in the selector
const validatedModels = useMemo(
() => models.filter((m) => m.validated === true),
[models],
)
const groupedModels = useMemo(
() => groupModelsByProvider(validatedModels),
[validatedModels],
)
// Find selected model for display
const selectedModel = useMemo(
() => models.find((m) => m.id === selectedModelId),
[models, selectedModelId],
)
const handleSelect = (value: string) => {
if (value === "__configure__") {
onConfigure()
} else if (value === "__server_default__") {
onSelect(undefined)
} else {
onSelect(value)
}
setOpen(false)
}
const tooltipContent = selectedModel
? `${selectedModel.modelId} (click to change)`
: "Using server default model (click to change)"
return (
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
tooltipContent={tooltipContent}
variant="ghost"
size="sm"
disabled={disabled}
className="hover:bg-accent gap-1.5 h-8 max-w-[180px] px-2"
>
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="text-xs truncate">
{selectedModel ? selectedModel.modelId : "Default"}
</span>
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip>
</ModelSelectorTrigger>
<ModelSelectorContent title="Select Model">
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
<ModelSelectorEmpty>
{validatedModels.length === 0 && models.length > 0
? "No verified models. Test your models first."
: "No models found."}
</ModelSelectorEmpty>
{/* Server Default Option */}
<ModelSelectorGroup heading="Default">
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
Server Default
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Configured Models by Provider */}
{Array.from(groupedModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={providerLabel}
heading={providerLabel}
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() => handleSelect(model.id)}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId === model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[provider] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
{/* Configure Option */}
<ModelSelectorSeparator />
<ModelSelectorGroup>
<ModelSelectorItem
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer"
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
Configure Models...
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
Only verified models are shown
</div>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
)
}

View File

@@ -4,8 +4,6 @@ import { Coffee, X } from "lucide-react"
import Link from "next/link"
import type React from "react"
import { FaGithub } from "react-icons/fa"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface QuotaLimitToastProps {
type?: "request" | "token"
@@ -20,11 +18,9 @@ export function QuotaLimitToast({
limit,
onDismiss,
}: QuotaLimitToastProps) {
const dict = useDictionary()
const isTokenLimit = type === "token"
const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
@@ -48,6 +44,7 @@ export function QuotaLimitToast({
>
<X className="w-4 h-4" />
</button>
{/* Title row with icon */}
<div className="flex items-center gap-2.5 mb-3 pr-6">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
@@ -58,26 +55,40 @@ export function QuotaLimitToast({
</div>
<h3 className="font-semibold text-foreground text-sm">
{isTokenLimit
? dict.quota.tokenLimit
: dict.quota.dailyLimit}
? "Daily Token Limit Reached"
: "Daily Quota Reached"}
</h3>
<span className="px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground">
{formatMessage(dict.quota.usedOf, {
used: formatNumber(used),
limit: formatNumber(limit),
})}
{isTokenLimit
? `${formatNumber(used)}/${formatNumber(limit)} tokens`
: `${used}/${limit}`}
</span>
</div>
{/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>
{isTokenLimit
? dict.quota.messageToken
: dict.quota.messageApi}
Oops you've reached the daily{" "}
{isTokenLimit ? "token" : "API"} limit for this demo! As an
indie developer covering all the API costs myself, I have to
set these limits to keep things sustainable.{" "}
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-amber-600 font-medium hover:text-amber-700 hover:underline"
>
Learn more
</Link>
</p>
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p>
</div>{" "}
<p>
<strong>Tip:</strong> You can use your own API key (click
the Settings icon) or self-host the project to bypass these
limits.
</p>
<p>Your limit resets tomorrow. Thanks for understanding!</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<a
@@ -87,7 +98,7 @@ export function QuotaLimitToast({
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
{dict.quota.selfHost}
Self-host
</a>
<a
href="https://github.com/sponsors/DayuanJiang"
@@ -96,7 +107,7 @@ export function QuotaLimitToast({
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<Coffee className="w-3.5 h-3.5" />
{dict.quota.sponsor}
Sponsor
</a>
</div>
</div>

View File

@@ -9,7 +9,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDictionary } from "@/hooks/use-dictionary"
interface ResetWarningModalProps {
open: boolean
@@ -22,15 +21,14 @@ export function ResetWarningModal({
onOpenChange,
onClear,
}: ResetWarningModalProps) {
const dict = useDictionary()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
<DialogTitle>Clear Everything?</DialogTitle>
<DialogDescription>
{dict.dialogs.clearDescription}
This will clear the current conversation and reset the
diagram. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -38,10 +36,10 @@ export function ResetWarningModal({
variant="outline"
onClick={() => onOpenChange(false)}
>
{dict.common.cancel}
Cancel
</Button>
<Button variant="destructive" onClick={onClear}>
{dict.dialogs.clearEverything}
Clear Everything
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -18,10 +17,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
export type ExportFormat = "drawio" | "png" | "svg"
const FORMAT_OPTIONS: {
value: ExportFormat
label: string
extension: string
}[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" },
]
interface SaveDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -35,7 +43,6 @@ export function SaveDialog({
onSave,
defaultFilename,
}: SaveDialogProps) {
const dict = useDictionary()
const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio")
@@ -58,40 +65,17 @@ export function SaveDialog({
}
}
const FORMAT_OPTIONS = [
{
value: "drawio" as const,
label: dict.save.formats.drawio,
extension: ".drawio",
},
{
value: "png" as const,
label: dict.save.formats.png,
extension: ".png",
},
{
value: "svg" as const,
label: dict.save.formats.svg,
extension: ".svg",
},
]
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.save.title}</DialogTitle>
<DialogDescription>
{dict.save.description}
</DialogDescription>
<DialogTitle>Save Diagram</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{dict.save.format}
</label>
<label className="text-sm font-medium">Format</label>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
@@ -112,15 +96,13 @@ export function SaveDialog({
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{dict.save.filename}
</label>
<label className="text-sm font-medium">Filename</label>
<div className="flex items-stretch">
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={dict.save.filenamePlaceholder}
placeholder="Enter filename"
autoFocus
onFocus={(e) => e.target.select()}
className="rounded-r-none border-r-0 focus-visible:z-10"
@@ -136,9 +118,9 @@ export function SaveDialog({
variant="outline"
onClick={() => onOpenChange(false)}
>
{dict.common.cancel}
Cancel
</Button>
<Button onClick={handleSave}>{dict.common.save}</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,8 +1,7 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -21,15 +20,6 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
}
interface SettingsDialogProps {
open: boolean
@@ -44,6 +34,10 @@ interface SettingsDialogProps {
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
function getStoredAccessCodeRequired(): boolean | null {
if (typeof window === "undefined") return null
@@ -52,7 +46,7 @@ function getStoredAccessCodeRequired(): boolean | null {
return stored === "true"
}
function SettingsContent({
export function SettingsDialog({
open,
onOpenChange,
onCloseProtectionChange,
@@ -61,10 +55,6 @@ function SettingsContent({
darkMode,
onToggleDarkMode,
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
@@ -72,13 +62,16 @@ function SettingsContent({
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
const [provider, setProvider] = useState("")
const [baseUrl, setBaseUrl] = useState("")
const [apiKey, setApiKey] = useState("")
const [modelId, setModelId] = useState("")
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch(getApiEndpoint("/api/config"))
fetch("/api/config")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
@@ -97,17 +90,6 @@ function SettingsContent({
})
}, [])
// Detect current language from pathname
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale)) {
setCurrentLang(first)
} else {
setCurrentLang(i18n.defaultLocale)
}
}, [pathname])
useEffect(() => {
if (open) {
const storedCode =
@@ -120,22 +102,16 @@ function SettingsContent({
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
// Load AI provider settings
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
setError("")
}
}, [open])
const changeLanguage = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
router.push(newPath + searchStr)
}
const handleSave = async () => {
if (!accessCodeRequired) return
@@ -143,27 +119,24 @@ function SettingsContent({
setIsVerifying(true)
try {
const response = await fetch(
getApiEndpoint("/api/verify-access-code"),
{
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
const response = await fetch("/api/verify-access-code", {
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
)
})
const data = await response.json()
if (!data.valid) {
setError(data.message || dict.errors.invalidAccessCode)
setError(data.message || "Invalid access code")
return
}
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false)
} catch {
setError(dict.errors.networkError)
setError("Failed to verify access code")
} finally {
setIsVerifying(false)
}
@@ -177,166 +150,287 @@ function SettingsContent({
}
return (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="space-y-2">
<Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.accessCodeDescription}
</p>
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Configure your application settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="space-y-2">
<Label htmlFor="access-code">Access Code</Label>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder="Enter access code"
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : "Save"}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
Required to use this application.
</p>
)}
</div>
)}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="language-select">
{dict.settings.language}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.languageDescription}
</p>
</div>
<Select value={currentLang} onValueChange={changeLanguage}>
<SelectTrigger id="language-select" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.themeDescription}
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
{dict.settings.closeProtectionDescription}
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</div>
</div>
<div className="pt-4 border-t border-border/50">
<p className="text-[0.75rem] text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
</div>
</DialogContent>
)
}
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense
fallback={
<DialogContent className="sm:max-w-md">
<div className="h-64 flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
</DialogContent>
}
>
<SettingsContent {...props} />
</Suspense>
)}
<div className="space-y-2">
<Label>AI Provider Settings</Label>
<p className="text-[0.8rem] text-muted-foreground">
Use your own API key to bypass usage limits. Your
key is stored locally in your browser and is never
stored on the server.
</p>
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="ai-provider">Provider</Label>
<Select
value={provider || "default"}
onValueChange={(value) => {
const actualValue =
value === "default" ? "" : value
setProvider(actualValue)
localStorage.setItem(
STORAGE_AI_PROVIDER_KEY,
actualValue,
)
}}
>
<SelectTrigger id="ai-provider">
<SelectValue placeholder="Use Server Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
Use Server Default
</SelectItem>
<SelectItem value="openai">
OpenAI
</SelectItem>
<SelectItem value="anthropic">
Anthropic
</SelectItem>
<SelectItem value="google">
Google
</SelectItem>
<SelectItem value="azure">
Azure OpenAI
</SelectItem>
<SelectItem value="openrouter">
OpenRouter
</SelectItem>
<SelectItem value="deepseek">
DeepSeek
</SelectItem>
<SelectItem value="siliconflow">
SiliconFlow
</SelectItem>
</SelectContent>
</Select>
</div>
{provider && provider !== "default" && (
<>
<div className="space-y-2">
<Label htmlFor="ai-model">
Model ID
</Label>
<Input
id="ai-model"
value={modelId}
onChange={(e) => {
setModelId(e.target.value)
localStorage.setItem(
STORAGE_AI_MODEL_KEY,
e.target.value,
)
}}
placeholder={
provider === "openai"
? "e.g., gpt-4o"
: provider === "anthropic"
? "e.g., claude-sonnet-4-5"
: provider === "google"
? "e.g., gemini-2.0-flash-exp"
: provider ===
"deepseek"
? "e.g., deepseek-chat"
: "Model ID"
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">
API Key
</Label>
<Input
id="ai-api-key"
type="password"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value)
localStorage.setItem(
STORAGE_AI_API_KEY_KEY,
e.target.value,
)
}}
placeholder="Your API key"
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
Overrides{" "}
{provider === "openai"
? "OPENAI_API_KEY"
: provider === "anthropic"
? "ANTHROPIC_API_KEY"
: provider === "google"
? "GOOGLE_GENERATIVE_AI_API_KEY"
: provider === "azure"
? "AZURE_API_KEY"
: provider ===
"openrouter"
? "OPENROUTER_API_KEY"
: provider ===
"deepseek"
? "DEEPSEEK_API_KEY"
: provider ===
"siliconflow"
? "SILICONFLOW_API_KEY"
: "server API key"}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
Base URL (optional)
</Label>
<Input
id="ai-base-url"
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value)
localStorage.setItem(
STORAGE_AI_BASE_URL_KEY,
e.target.value,
)
}}
placeholder={
provider === "anthropic"
? "https://api.anthropic.com/v1"
: provider === "siliconflow"
? "https://api.siliconflow.com/v1"
: "Custom endpoint URL"
}
/>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
localStorage.removeItem(
STORAGE_AI_PROVIDER_KEY,
)
localStorage.removeItem(
STORAGE_AI_BASE_URL_KEY,
)
localStorage.removeItem(
STORAGE_AI_API_KEY_KEY,
)
localStorage.removeItem(
STORAGE_AI_MODEL_KEY,
)
setProvider("")
setBaseUrl("")
setApiKey("")
setModelId("")
}}
>
Clear Settings
</Button>
</>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">Theme</Label>
<p className="text-[0.8rem] text-muted-foreground">
Dark/Light mode for interface and DrawIO canvas.
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">DrawIO Style</Label>
<p className="text-[0.8rem] text-muted-foreground">
Canvas style:{" "}
{drawioUi === "min" ? "Minimal" : "Sketch"}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
Switch to{" "}
{drawioUi === "min" ? "Sketch" : "Minimal"}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
Close Protection
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Show confirmation when leaving the page.
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,157 +0,0 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -1,191 +0,0 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden p-0", className)}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
onMouseEnter={(e) => {
// Ensure hover updates selection for visual feedback
const item = e.currentTarget
item.setAttribute("data-selected", "true")
// Deselect siblings
const siblings = item.parentElement?.querySelectorAll("[cmdk-item]")
siblings?.forEach((sibling) => {
if (sibling !== item) {
sibling.setAttribute("data-selected", "false")
}
})
}}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -1,48 +0,0 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -1,12 +1,11 @@
"use client"
import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
import { extractDiagramXML, validateMxCellStructure } from "../lib/utils"
interface DiagramContextType {
chartXML: string
@@ -24,12 +23,9 @@ interface DiagramContextType {
format: ExportFormat,
sessionId?: string,
) => void
saveDiagramToStorage: () => Promise<void>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
showSaveDialog: boolean
setShowSaveDialog: (show: boolean) => void
}
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -41,15 +37,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
{ svg: string; xml: string }[]
>([])
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored from localStorage
const hasDiagramRestoredRef = useRef<boolean>(false)
const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops
@@ -65,48 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false)
}
// Restore diagram XML when DrawIO becomes ready
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
useEffect(() => {
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
setCanSaveDiagram(false)
return
}
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
try {
const savedDiagramXml = localStorage.getItem(
STORAGE_DIAGRAM_XML_KEY,
)
if (savedDiagramXml) {
// Skip validation for trusted saved diagrams
loadDiagram(savedDiagramXml, true)
}
} catch (error) {
console.error("Failed to restore diagram from localStorage:", error)
}
// Allow saving after restore is complete
setTimeout(() => {
setCanSaveDiagram(true)
}, 500)
}, [isDrawioReady])
// Save diagram XML to localStorage whenever it changes (debounced)
useEffect(() => {
if (!canSaveDiagram) return
if (!chartXML || chartXML.length <= 300) return
const timeoutId = setTimeout(() => {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}, 1000)
return () => clearTimeout(timeoutId)
}, [chartXML, canSaveDiagram])
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
@@ -132,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 = (
chart: string,
skipValidation?: boolean,
): string | null => {
let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal use)
if (!skipValidation) {
const validation = validateAndFixXml(chart)
if (!validation.valid) {
console.warn(
"[loadDiagram] Validation error:",
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
const validationError = validateMxCellStructure(chart)
if (validationError) {
console.warn("[loadDiagram] Validation error:", validationError)
return validationError
}
}
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
setChartXML(xmlToLoad)
setChartXML(chart)
if (drawioRef.current) {
drawioRef.current.load({
xml: xmlToLoad,
xml: chart,
})
}
@@ -212,20 +125,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setLatestSvg(data.data)
// 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) {
setDiagramHistory((prev) => {
const newHistory = [
...prev,
{
svg: data.data,
xml: extractedXML,
},
]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
return newHistory.slice(-MAX_HISTORY_SIZE)
})
setDiagramHistory((prev) => [
...prev,
{
svg: data.data,
xml: extractedXML,
},
])
expectHistoryExportRef.current = false
}
@@ -330,7 +237,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
sessionId?: string,
) => {
try {
await fetch(getApiEndpoint("/api/log-save"), {
await fetch("/api/log-save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }),
@@ -354,12 +261,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport,
clearDiagram,
saveDiagramToFile,
saveDiagramToStorage,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
showSaveDialog,
setShowSaveDialog,
}}
>
{children}

View File

@@ -7,11 +7,6 @@ services:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
environment:
# For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio]

View File

@@ -26,7 +26,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目录](#目录)
- [示例](#示例)
- [功能特性](#功能特性)
- [MCP服务器预览](#mcp服务器预览)
- [快速开始](#快速开始)
- [在线试用](#在线试用)
- [使用Docker运行推荐](#使用docker运行推荐)
@@ -82,43 +81,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **LLM驱动的图表创建**利用大语言模型通过自然语言命令直接创建和操作draw.io图表
- **基于图像的图表复制**上传现有图表或图像让AI自动复制和增强
- **PDF和文本文件上传**上传PDF文档和文本文件提取内容并从现有文档生成图表
- **AI推理过程显示**查看支持模型的AI思考过程OpenAI o1/o3、Gemini、Claude等
- **图表历史记录**全面的版本控制跟踪所有更改允许您查看和恢复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等客户端配置。
## 快速开始
### 在线试用

View File

@@ -26,7 +26,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目次](#目次)
- [](#例)
- [機能](#機能)
- [MCPサーバープレビュー](#mcpサーバープレビュー)
- [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す)
- [Dockerで実行推奨](#dockerで実行推奨)
@@ -82,43 +81,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **LLM搭載のダイアグラム作成**大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
- **画像ベースのダイアグラム複製**既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
- **PDFとテキストファイルのアップロード**PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
- **AI推論プロセス表示**サポートされているモデルOpenAI o1/o3、Gemini、ClaudeなどのAIの思考プロセスを表示
- **ダイアグラム履歴**すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
- **インタラクティブなチャットインターフェース**AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure
- **AWSアーキテクチャダイアグラムサポート**AWSアーキテクチャダイアグラムの生成を専門的にサポート
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバープレビュー
> **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
MCPModel Context Protocolを介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
Claudeにダイアグラムの作成を依頼
> 「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを作成してください」
ダイアグラムがリアルタイムでブラウザに表示されます!
詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧くださいVS Code、Cursorなどのクライアント設定も含む
## はじめに
### オンラインで試す

View File

@@ -80,23 +80,13 @@ SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflo
```bash
AZURE_API_KEY=your_api_key
AZURE_RESOURCE_NAME=your-resource-name # Required: your Azure resource name
AI_MODEL=your-deployment-name
```
Or use a custom endpoint instead of resource name:
Optional custom endpoint:
```bash
AZURE_API_KEY=your_api_key
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
AZURE_BASE_URL=https://your-resource.openai.azure.com
```
### AWS Bedrock
@@ -136,42 +126,6 @@ Optional custom URL:
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.
**Basic Usage (Vercel-hosted Gateway):**
```bash
AI_GATEWAY_API_KEY=your_gateway_api_key
AI_MODEL=openai/gpt-4o
```
**Custom Gateway URL (for local development or self-hosted Gateway):**
```bash
AI_GATEWAY_API_KEY=your_custom_api_key
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
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
**Configuration notes:**
- If `AI_GATEWAY_BASE_URL` is not set, the default Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) is used
- Custom base URL is useful for:
- Local development with a custom Gateway instance
- Self-hosted AI Gateway deployments
- Enterprise proxy configurations
- When using a custom base URL, you must also provide `AI_GATEWAY_API_KEY`
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
## 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`.
@@ -179,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`:
```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

View File

@@ -1,78 +0,0 @@
# Draw.io Shape Libraries
Reference: `style="shape=mxgraph.<library>.<shape_name>"`
## Cloud Providers
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| aws4 | 1031 | `mxgraph.aws4` | Amazon Web Services (2025) - EC2, S3, Lambda, RDS, etc. | [aws4.md](./aws4.md) |
| azure2 | 608 | `img/lib/azure2/` | Microsoft Azure (2024) - VMs, Storage, AI, Networking, etc. | [azure2.md](./azure2.md) |
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
## Networking & Infrastructure
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
## Business Process
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
## General Diagrams
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| flowchart | 34 | `mxgraph.flowchart` | Standard flowchart symbols - process, decision, data | [flowchart.md](./flowchart.md) |
| basic | 30 | `mxgraph.basic` | Basic shapes - stars, banners, callouts, hearts | [basic.md](./basic.md) |
| arrows2 | 34 | `mxgraph.arrows2` | Arrow shapes and connectors | [arrows2.md](./arrows2.md) |
| infographic | 29 | `mxgraph.infographic` | Infographic elements - charts, icons, badges | [infographic.md](./infographic.md) |
| sitemap | 50 | `mxgraph.sitemap` | Website sitemap icons - pages, forms, navigation | [sitemap.md](./sitemap.md) |
## UI/Mockups
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
## Enterprise Software
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| citrix | 97 | `mxgraph.citrix` | Citrix virtualization - XenApp, XenDesktop, NetScaler | [citrix.md](./citrix.md) |
| sap | 98 | `mxgraph.sap` | SAP enterprise software icons | [sap.md](./sap.md) |
| mscae | 73 | `mxgraph.mscae` | Microsoft Cloud and Enterprise symbols | [mscae.md](./mscae.md) |
| atlassian | 26 | `mxgraph.atlassian` | Atlassian - Jira, Confluence issue types | [atlassian.md](./atlassian.md) |
## Engineering
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| fluidpower | 246 | `mxgraph.fluid_power` | Hydraulic/pneumatic engineering symbols | [fluidpower.md](./fluidpower.md) |
| electrical | 50 | `mxgraph.electrical` | Electrical circuit symbols - resistors, capacitors | [electrical.md](./electrical.md) |
| pid | 18 | `mxgraph.pid2` | Piping and Instrumentation Diagram symbols | [pid.md](./pid.md) |
| cabinets | 53 | `mxgraph.cabinets` | Electrical cabinet components - breakers, terminals | [cabinets.md](./cabinets.md) |
| floorplan | 44 | `mxgraph.floorplan` | Floor plan furniture and fixtures | [floorplan.md](./floorplan.md) |
## Icons & Graphics
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
**Total: 33 libraries, 4,281 shapes**

View File

@@ -1,328 +0,0 @@
# alibaba_cloud
**Type:** mxgraph shapes
**Prefix:** `mxgraph.alibaba_cloud`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (311)
- `abap_business_application_platform`
- `acms_application_configuration_manangement`
- `acr_cloud_container_registry`
- `actiontrail`
- `adam_advanced_database_and_application_migration`
- `adb_analyticdb_for_mysql`
- `address_purification`
- `afs_fraud_service`
- `agw_aligateway`
- `ahas_application_high_availability_service`
- `airec_artificial_intelligence_recommendation`
- `alb_application_load_balancer_01`
- `alb_application_load_balancer_02`
- `alibaba_cloud_logo`
- `alibaba_cloud_logo_chinese`
- `alibaba_cloud_logo_english`
- `alimail`
- `alimt_machine_translation`
- `aliyun_linux`
- `amqp_advanced_message_queuing_protocol`
- `amscloudapp`
- `analyticdb_for_postgresql`
- `antibot`
- `apigateway`
- `apsara_file_storage_for_hdfs`
- `apsaravideo_vod`
- `arms_application_real-time_monitoring_service`
- `ask_ack_container_service_for_kubernetes`
- `asm_service_mesh`
- `assettech`
- `avds_vulnerability_db_scanning`
- `baas_blockchain_as_a_service`
- `bandwidth_bag`
- `bastionhost`
- `batchcompute`
- `bccluster`
- `beebot`
- `beian`
- `bizdevops`
- `bizworks`
- `bpstudio`
- `cas_ssl_central_authentication_service`
- `cassandra_wide-column_database_01`
- `cassandra_wide-column_database_02`
- `ccc_cloud_call_center`
- `ccn_cloud_connect_network`
- `ccs_customer_service_01`
- `ccs_customer_service_02`
- `cddc_cloud_database_dedicated_cluster`
- `cdn_content_distribution_network`
- `cdp_cloudera_cdp`
- `cdt_cloud_datatransfer`
- `cen_cloud_enterprise_network`
- `cfw_cloud_firewall`
- `cityvisual`
- `clb_classic_load_balancer_01`
- `clb_classic_load_balancer_02`
- `clickhouse`
- `cloud_auth`
- `cloud_config`
- `cloud_display`
- `cloud_governance_center`
- `cloud_security_center`
- `cloud_shield`
- `cloudap`
- `cloudbox`
- `clouddesktop`
- `clouddev`
- `cloudphoto`
- `cloudproc`
- `cloudshell`
- `cmn_cloud_managed_network`
- `cmp_cloud_mobile_push`
- `cms_cloud_monitor_service`
- `codepipeline`
- `codestore`
- `companyreg`
- `computenest`
- `content_security`
- `coo`
- `cpns_cell_phone_number_service`
- `csas_cloud_security_access_service`
- `cvc_cloud_video_conferencing`
- `cwh_cloud_web_hosting`
- `das_database_autonomy_service`
- `databot`
- `datahub`
- `dataphin`
- `dataquotient`
- `datav`
- `dataworks_dataide`
- `dbaudit`
- `dbes_database_expert_service`
- `dbfs_database_file_system`
- `dbs_database_backup`
- `dcdn_dynamic_route_for_cdn`
- `ddh_dedicated_host`
- `ddos-bgp`
- `ddos-dip`
- `ddos-pro`
- `ddos_protection`
- `devops`
- `dg_database_gateway`
- `directmail`
- `disk_block_storage`
- `dlf_data_lake_formation`
- `dms_data_management_service`
- `dns_domain_name_system`
- `dns_privatezone_01`
- `dns_privatezone_02`
- `domain`
- `domain_and_website`
- `drds_distribute_relational_database_service`
- `dsi_data_security_insurance`
- `dts_data_transmission_service`
- `e-mapreduce`
- `eais_elastic_accelerated_computing_instances`
- `eci_elastic_container_instance`
- `ecs_elastic_compute_service`
- `edas_enterprise_distributed_application_service`
- `ehpc_elastic_high_performance_computing`
- `eip_elastic_ip_address`
- `elastic_web_hosting`
- `elasticsearch`
- `emas_enterprise_mobile_application_studio`
- `energyexpert`
- `ens_edge_node_service`
- `enterprise_website`
- `eprofile`
- `esign`
- `ess_elastic_scaling_service`
- `eventbridge`
- `express_connect`
- `face_recognition`
- `fc_function_compute`
- `flow_service`
- `flowbag`
- `fnf_serverless_function_flow`
- `fpga_field_programmable_gate_array`
- `fraud_detection`
- `ga_global_accelerator`
- `gameshield`
- `gdb_graph_database`
- `graphanalytics`
- `graphcompute`
- `gtm_global_traffic_manager`
- `gts_global_transaction_service`
- `gws_graphic_workstation`
- `havip_high-availability_virtual_ip_address`
- `hbase`
- `hbr_hybrid_backup_recovery`
- `hcs-hgw_hybrid_cloud_storage_array`
- `hcs-mgw_hybrid_cloud_storage_datatransport`
- `hcs-sgw_hybrid_cloud_storage_gateway`
- `hdr_hybrid_disaster_recovery`
- `hologres`
- `holowatcher`
- `hsm_hardware_security_module`
- `httpdns`
- `idrsservice`
- `image_recognition`
- `imagesearch`
- `imarketing`
- `imm_intelligent_media_management`
- `imp_intelligent_media_production`
- `imp_low_code_video_factory`
- `indvi_industrial_visual_intelligence`
- `intelligent_advisor`
- `iot_internet_of_things_platform`
- `iot_wireless_connection_service`
- `iotid_identity`
- `iov_iot_vehicle_cloud`
- `ipv6_gateway`
- `isoc_iot_security_operations_center`
- `isu_intelligent_semantic_understanding`
- `ivision`
- `ivpd_intelligent_visual_production`
- `kafka`
- `linkedmall`
- `linkwan`
- `live`
- `livinglink`
- `log_streaming`
- `logic_composer`
- `machine_learning`
- `man_mobile_analytics`
- `mariadb`
- `mas_mobile_acceleration_service`
- `maxcompute`
- `memcache`
- `miniappdev`
- `mns_message_service`
- `mobile_hotfix`
- `mobsec`
- `mongodb`
- `mps-ai`
- `mps-censor`
- `mps-cover`
- `mps-dna`
- `mps-multimod`
- `mps-produce`
- `mps_apsaravideo_media_processing`
- `mq_message_queue`
- `mqc_mobile_quality_center`
- `mse_microservices_engine`
- `multi-cloud_finops`
- `multi-mode_database_lindorm`
- `multimediaai`
- `mxgraph.alibaba_cloud`
- `mysql`
- `nas_network_attached_storage`
- `nat_gateway`
- `network_acl_access_control_list`
- `nlb_network_load_balancer_01`
- `nlb_network_load_balancer_02`
- `nlp-address`
- `nlp-automl`
- `nlp-ie_text_information_extraction`
- `nlp-ke_keyword_extraction`
- `nlp-ner_named_entity_recognition`
- `nlp-pos_part-of-speech_tagging`
- `nlp-ra_reflexive_anaphora`
- `nlp-sa_sentiment_analysis`
- `nlp-tc_text_categorization`
- `nlp-ws_word_segmentation`
- `nlp_natural_language_processing`
- `nls`
- `nls-asrbag`
- `nls-asrcustommodel`
- `nls-filebag`
- `nls-service`
- `nls-shortasrbag`
- `nls-ttsbag`
- `nodejs_performance_platform`
- `oceanbase`
- `ocr_optical_character_recognition`
- `onsmqtt_micro_message_queuing_telemetry_transport`
- `oos_operation_orchestration_service`
- `openanalytics`
- `openapi_explorer`
- `opensearch`
- `oss_object_storage_service`
- `ots_tablestore`
- `outboundbot`
- `pcdn_p2p_cdn`
- `petadata_hybriddb_for_mysql`
- `physical_connection`
- `pnvs_phone_number_verification_service`
- `polardb`
- `porana_portrait_analysis`
- `postgresql`
- `ppas_pay-as-you-go_database`
- `privatelink`
- `prometheus`
- `prophet`
- `pts_performance_test_service`
- `quickbi`
- `ram_resource_access_management`
- `re_recommendation_engine`
- `realtime_compute`
- `redis_kvstore`
- `region`
- `retailir`
- `ros_resource_orchestration_service`
- `route_table`
- `router`
- `rsimganalys`
- `rtc_real-time_communication`
- `sae_serverless_app_engine`
- `sag_smart_access_gateway_01`
- `sag_smart_access_gateway_02`
- `sas_situational_awareness`
- `sca_smart_conversation_analysis_01`
- `sca_smart_conversation_analysis_02`
- `scc_super_computing_cluster`
- `scdn_secure_cdn`
- `scu_storage_capacity_unit`
- `sddp_sensitive_data_protection`
- `shared_bandwidth`
- `shared_flow_bag`
- `shc_shield_hybrid_cloud`
- `slb_server_load_balancer_01`
- `slb_server_load_balancer_02`
- `slb_server_load_balancer_03`
- `sls_simple_log_service`
- `smc_server_migration_center`
- `sms_short_message_service`
- `sos`
- `spark_data_insights`
- `sppc`
- `sqlserver`
- `swas_simple_application_server`
- `tr_transit_router`
- `trademark_service`
- `uis_ultimate_internet_service`
- `user`
- `user_feedback_01`
- `user_feedback_02`
- `vbr_virtual_border_router`
- `vcs_visual_computing_service`
- `vms_voice_messaging_service`
- `voicebot_intelligent_voice_navigation`
- `vpc_virtual_private_cloud`
- `vpn_gateway`
- `vs_video_surveillance`
- `vswitch`
- `waf_web_application_firewall`
- `webplus_web_app_service`
- `xdragon_bare_metal_server`
- `xtrace`
- `yida`

View File

@@ -1,62 +0,0 @@
# android
**Type:** mxgraph shapes
**Prefix:** `mxgraph.android`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.android.phone2;strokeColor=#c0c0c0;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="200" height="390" as="geometry" />
</mxCell>
```
## Shapes (47)
- `action_bar`
- `action_bar_landscape`
- `anchor`
- `checkbox`
- `contact_badge_focused`
- `contextual_action_bar`
- `contextual_action_bar_landscape`
- `contextual_split_action_bar`
- `contextual_split_action_bar_landscape`
- `contextual_split_action_bar_landscape_white`
- `indeterminateSpinner`
- `indeterminate_progress_bar`
- `keyboard`
- `navigation_bar_1`
- `navigation_bar_1_landscape`
- `navigation_bar_1_vertical`
- `navigation_bar_2`
- `navigation_bar_3`
- `navigation_bar_3_landscape`
- `navigation_bar_4`
- `navigation_bar_5`
- `navigation_bar_5_vertical`
- `navigation_bar_6`
- `phone2`
- `progressBar`
- `progressScrubberDisabled`
- `progressScrubberFocused`
- `progressScrubberPressed`
- `quick_contact`
- `quickscroll2`
- `quickscroll3`
- `rect`
- `rrect`
- `scrollbars2`
- `spinner2`
- `split_action_bar`
- `split_action_bar_landscape`
- `statusBar`
- `switch_off`
- `switch_on`
- `tab2`
- `textSelHandles`
- `text_insertion_point`
- `textfield`
- `time_picker`
- `time_picker_dark`
- `transparent`

View File

@@ -1,33 +0,0 @@
# arrows2
**Type:** mxgraph shapes
**Prefix:** `mxgraph.arrows2`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.arrows2.arrow;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="100" height="60" as="geometry" />
</mxCell>
```
## Shapes (18)
- `arrow`
- `bendArrow`
- `bendDoubleArrow`
- `calloutArrow`
- `calloutDouble90Arrow`
- `calloutDoubleArrow`
- `calloutQuadArrow`
- `jumpInArrow`
- `quadArrow`
- `sharpArrow`
- `sharpArrow2`
- `stripedArrow`
- `stylisedArrow`
- `tailedArrow`
- `tailedNotchedArrow`
- `triadArrow`
- `twoWayArrow`
- `uTurnArrow`

View File

@@ -1,32 +0,0 @@
# atlassian
**Type:** SVG images
**Path:** `img/lib/atlassian/`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (17)
- `Atlassian_Logo`
- `Bamboo_Logo`
- `Bitbucket_Logo`
- `Clover_Logo`
- `Confluence_Logo`
- `Crowd_Logo`
- `Crucible_Logo`
- `Fisheye_Logo`
- `Hipchat_Logo`
- `Jira_Core_Logo`
- `Jira_Logo`
- `Jira_Service_Desk_Logo`
- `Jira_Software_Logo`
- `Sourcetree_Logo`
- `Statuspage_Logo`
- `Stride_Logo`
- `Trello_Logo`

File diff suppressed because it is too large Load Diff

View File

@@ -1,431 +0,0 @@
# azure2
**Type:** SVG images
**Path:** `img/lib/azure2/`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (648)
Shapes are organized by category: `azure2/{category}/{shape}.svg`
### ai_machine_learning (30)
- `AI_Studio`
- `Anomaly_Detector`
- `Azure_Applied_AI`
- `Azure_Experimentation_Studio`
- `Azure_Object_Understanding`
- `Azure_OpenAI`
- `Batch_AI`
- `Bonsai`
- `Bot_Services`
- `Cognitive_Services`
- `Cognitive_Services_Decisions`
- `Computer_Vision`
- `Content_Moderators`
- `Content_Safety`
- `Custom_Vision`
- `Face_APIs`
- `Form_Recognizers`
- `Genomics`
- `Immersive_Readers`
- `Language_Services`
- `Language_Understanding`
- `Machine_Learning`
- `Machine_Learning_Studio_Classic_Web_Services`
- `Machine_Learning_Studio_Web_Service_Plans`
- `Machine_Learning_Studio_Workspaces`
- `Personalizers`
- `QnA_Makers`
- `Serverless_Search`
- `Speech_Services`
- `Translator_Text`
### analytics (14)
- `Analysis_Services`
- `Azure_Databricks`
- `Azure_Synapse_Analytics`
- `Azure_Workbooks`
- `Data_Lake_Analytics`
- `Data_Lake_Store_Gen1`
- `Endpoint_Analytics`
- `Event_Hub_Clusters`
- `Event_Hubs`
- `HD_Insight_Clusters`
- `Log_Analytics_Workspaces`
- `Power_BI_Embedded`
- `Power_Platform`
- `Stream_Analytics_Jobs`
### app_services (9)
- `API_Management_Services`
- `App_Service_Certificates`
- `App_Service_Domains`
- `App_Service_Environments`
- `App_Service_Plans`
- `App_Services`
- `CDN_Profiles`
- `Notification_Hubs`
- `Search_Services`
### compute (38)
- `App_Services`
- `Application_Group`
- `Automanaged_VM`
- `Availability_Sets`
- `Azure_Compute_Galleries`
- `Azure_Spring_Cloud`
- `Batch_Accounts`
- `Cloud_Services_Classic`
- `Container_Instances`
- `Container_Services_Deprecated`
- `Disk_Encryption_Sets`
- `Disks`
- `Disks_Classic`
- `Disks_Snapshots`
- `Function_Apps`
- `Host_Groups`
- `Host_Pools`
- `Hosts`
- `Image_Definitions`
- `Image_Templates`
- `Image_Versions`
- `Images`
- `Kubernetes_Services`
- `Maintenance_Configuration`
- `Managed_Service_Fabric`
- `Mesh_Applications`
- `Metrics_Advisor`
- `OS_Images_Classic`
- `Restore_Points`
- `Restore_Points_Collections`
- `Service_Fabric_Clusters`
- `Shared_Image_Galleries`
- `VM_Images_Classic`
- `VM_Scale_Sets`
- `Virtual_Machine`
- `Virtual_Machines_Classic`
- `Workspaces`
- `Workspaces2`
### containers (7)
- `App_Services`
- `Azure_Red_Hat_OpenShift`
- `Batch_Accounts`
- `Container_Instances`
- `Container_Registries`
- `Kubernetes_Services`
- `Service_Fabric_Clusters`
### databases (27)
- `Azure_Cosmos_DB`
- `Azure_Data_Explorer_Clusters`
- `Azure_Database_MariaDB_Server`
- `Azure_Database_Migration_Services`
- `Azure_Database_MySQL_Server`
- `Azure_Database_PostgreSQL_Server`
- `Azure_Database_PostgreSQL_Server_Group`
- `Azure_Purview_Accounts`
- `Azure_SQL`
- `Azure_SQL_Edge`
- `Azure_SQL_Server_Stretch_Databases`
- `Azure_SQL_VM`
- `Azure_Synapse_Analytics`
- `Cache_Redis`
- `Data_Factory`
- `Elastic_Job_Agents`
- `Instance_Pools`
- `Managed_Database`
- `Oracle_Database`
- `SQL_Data_Warehouses`
- `SQL_Database`
- `SQL_Elastic_Pools`
- `SQL_Managed_Instance`
- `SQL_Server`
- `SQL_Server_Registries`
- `SSIS_Lift_And_Shift_IR`
- `Virtual_Clusters`
### identity (35)
- `AAD_Licenses`
- `Active_Directory_Connect_Health`
- `Active_Directory_Connect_Health2`
- `Administrative_Units`
- `App_Registrations`
- `Azure_AD_B2C`
- `Azure_AD_B2C2`
- `Azure_AD_Domain_Services`
- `Azure_AD_Identity_Protection`
- `Azure_AD_Privilege_Identity_Management`
- `Azure_Active_Directory`
- `Azure_Information_Protection`
- `Custom_Azure_AD_Roles`
- `Enterprise_Applications`
- `Entra_Connect`
- `Entra_Domain_Services`
- `Entra_Global_Secure_Access`
- `Entra_ID_Protection`
- `Entra_Internet_Access`
- `Entra_Managed_Identities`
- `Entra_Private_Access`
- `Entra_Privileged_Identity_Management`
- `Entra_Verified_ID`
- `External_Identities`
- `Groups`
- `Identity_Governance`
- `Managed_Identities`
- `Multi_Factor_Authentication`
- `PIM`
- `Security`
- `Tenant_Properties`
- `User_Settings`
- `Users`
- `Verifiable_Credentials`
- `Verification_As_A_Service`
### networking (51)
- `ATM_Multistack`
- `Application_Gateway_Containers`
- `Application_Gateways`
- `Azure_Communications_Gateway`
- `Azure_Firewall_Manager`
- `Azure_Firewall_Policy`
- `Bastions`
- `CDN_Profiles`
- `Connections`
- `DDoS_Protection_Plans`
- `DNS_Multistack`
- `DNS_Private_Resolver`
- `DNS_Security_Policy`
- `DNS_Zones`
- `ExpressRoute_Circuits`
- `Firewalls`
- `Front_Doors`
- `IP_Address_manager`
- `IP_Groups`
- `Load_Balancer_Hub`
- `Load_Balancers`
- `Local_Network_Gateways`
- `NAT`
- `Network_Interfaces`
- `Network_Security_Groups`
- `Network_Watcher`
- `On_Premises_Data_Gateways`
- `Private_Endpoint`
- `Private_Link`
- `Private_Link_Hub`
- `Private_Link_Service`
- `Proximity_Placement_Groups`
- `Public_IP_Addresses`
- `Public_IP_Addresses_Classic`
- `Public_IP_Prefixes`
- `Reserved_IP_Addresses_Classic`
- `Resource_Management_Private_Link`
- `Route_Filters`
- `Route_Tables`
- `Service_Endpoint_Policies`
- `Spot_VM`
- `Spot_VMSS`
- `Subnet`
- `Traffic_Manager_Profiles`
- `Virtual_Network_Gateways`
- `Virtual_Networks`
- `Virtual_Networks_Classic`
- `Virtual_Router`
- `Virtual_WAN_Hub`
- `Virtual_WANs`
- `Web_Application_Firewall_Policies_WAF`
### security (14)
- `Application_Security_Groups`
- `Azure_AD_Risky_Signins`
- `Azure_AD_Risky_Users`
- `Azure_Defender`
- `Azure_Sentinel`
- `Conditional_Access`
- `Detonation`
- `ExtendedSecurityUpdates`
- `Identity_Secure_Score`
- `Key_Vaults`
- `Keys`
- `MS_Defender_EASM`
- `Multifactor_Authentication`
- `Security_Center`
### storage (17)
- `Azure_Fileshare`
- `Azure_HCP_Cache`
- `Azure_NetApp_Files`
- `Azure_Stack_Edge`
- `Data_Box`
- `Data_Box_Edge`
- `Data_Lake_Storage_Gen1`
- `Data_Share_Invitations`
- `Data_Shares`
- `Import_Export_Jobs`
- `Recovery_Services_Vaults`
- `StorSimple_Data_Managers`
- `StorSimple_Device_Managers`
- `Storage_Accounts`
- `Storage_Accounts_Classic`
- `Storage_Explorer`
- `Storage_Sync_Services`
### general (98)
- `All_Resources`
- `Backlog`
- `Biz_Talk`
- `Blob_Block`
- `Blob_Page`
- `Branch`
- `Browser`
- `Bug`
- `Builds`
- `Cache`
- `Code`
- `Commit`
- `Controls`
- `Controls_Horizontal`
- `Cost_Alerts`
- `Cost_Analysis`
- `Cost_Budgets`
- `Cost_Management`
- `Cost_Management_and_Billing`
- `Counter`
- `Cubes`
- `Dashboard`
- `Dashboard2`
- `Dev_Console`
- `Download`
- `Error`
- `Extensions`
- `FTP`
- `File`
- `Files`
- `Folder_Blank`
- `Folder_Website`
- `Free_Services`
- `Gear`
- `Globe`
- `Globe_Error`
- `Globe_Success`
- `Globe_Warning`
- `Guide`
- `Heart`
- `Help_and_Support`
- `Image`
- `Information`
- `Input_Output`
- `Journey_Hub`
- `Launch_Portal`
- `Learn`
- `Load_Test`
- `Location`
- `Log_Streaming`
- `Management_Groups`
- `Management_Portal`
- `Marketplace`
- `Media`
- `Media_File`
- `Mobile`
- `Mobile_Engagement`
- `Module`
- `Power`
- `Power_Up`
- `Powershell`
- `Preview`
- `Preview_Features`
- `Process_Explorer`
- `Production_Ready_Database`
- `Quickstart_Center`
- `Recent`
- `Reservations`
- `Resource_Explorer`
- `Resource_Group_List`
- `Resource_Groups`
- `Resource_Linked`
- `SSD`
- `Scale`
- `Scheduler`
- `Search`
- `Search_Grid`
- `Server_Farm`
- `Service_Bus`
- `Service_Health`
- `Storage_Azure_Files`
- `Storage_Container`
- `Storage_Queue`
- `Subscriptions`
- `TFS_VC_Repository`
- `Table`
- `Tag`
- `Tags`
- `Templates`
- `Toolbox`
- `Troubleshoot`
- `Versions`
- `Web_Slots`
- `Web_Test`
- `Website_Power`
- `Website_Staging`
- `Workbooks`
- `Workflow`
### other (149)
(See draw.io for complete list of 149 shapes in the "other" category)
Selected shapes:
- `Azure_Backup_Center`
- `Azure_Chaos_Studio`
- `Azure_Cloud_Shell`
- `Azure_Communication_Services`
- `Azure_Deployment_Environments`
- `Azure_Load_Testing`
- `Azure_Monitor_Dashboard`
- `Azure_Network_Manager`
- `Azure_Orbital`
- `Azure_Sphere`
- `Azure_Storage_Mover`
- `Grafana`
- `Kubernetes_Fleet_Manager`
- `SSH_Keys`
### Additional Categories
- **azure_ecosystem** (3): Applens, Azure_Hybrid_Center, Collaborative_Service
- **azure_stack** (8): Azure_Stack, Capacity, Infrastructure_Backup, Multi_Tenancy, Offers, Plans, Updates, User_Subscriptions
- **azure_vmware_solution** (1): AVS
- **blockchain** (6): ABS_Member, Azure_Blockchain_Service, Azure_Token_Service, Blockchain_Applications, Consortium, Outbound_Connection
- **cxp** (2): Elixir, Elixir_Purple
- **devops** (10): API_Connections, Application_Insights, Azure_DevOps, Change_Analysis, CloudTest, Code_Optimization, DevOps_Starter, DevTest_Labs, Lab_Accounts, Lab_Services
- **hybrid_multicloud** (5): Azure_Operator_5G_Core, Azure_Operator_Insights, Azure_Operator_Nexus, Azure_Operator_Service_Manager, Azure_Programmable_Connectivity
- **integration** (21): API_Management_Services, App_Configuration, Azure_API_for_FHIR, Azure_Data_Catalog, Event_Grid_Domains, Event_Grid_Subscriptions, Event_Grid_Topics, Integration_Accounts, Integration_Environments, Integration_Service_Environments, Logic_Apps, Logic_Apps_Custom_Connector, Partner_Namespace, Partner_Registration, Partner_Topic, Relays, SQL_Data_Warehouses, SendGrid_Accounts, Service_Bus, Software_as_a_Service, System_Topic
- **internet_of_things** (3): Digital_Twins, Logic_Apps, Time_Series_Insights_Access_Policies
- **intune** (17): Azure_AD_Roles_and_Administrators, Client_Apps, Device_Compliance, Device_Configuration, Device_Enrollment, Device_Security_Apple, Device_Security_Google, Device_Security_Windows, Devices, Exchange_Access, Intune, Intune_For_Education, Mindaro, Security_Baselines, Software_Updates, Tenant_Status, eBooks
- **iot** (19): Azure_IoT_Operations, Azure_Maps_Accounts, Azure_Stack_HCI_Sizer, Device_Provisioning_Services, Digital_Twins, Event_Hubs, Function_Apps, Industrial_IoT, IoT_Central_Applications, IoT_Edge, IoT_Hub, Logic_Apps, Notification_Hubs, Stack_HCI_Premium, Stream_Analytics_Jobs, Time_Series_Data_Sets, Time_Series_Insights_Environments, Time_Series_Insights_Event_Sources, Windows10_Core_Services
- **management_governance** (32): Activity_Log, Advisor, Alerts, Application_Insights, Arc_Machines, Automation_Accounts, Azure_Arc, Azure_Lighthouse, Blueprints, Compliance, Cost_Management_and_Billing, Customer_Lockbox_for_MS_Azure, Diagnostics_Settings, Education, Log_Analytics_Workspaces, MachinesAzureArc, Managed_Applications_Center, Managed_Desktop, Metrics, Monitor, My_Customers, Operation_Log_Classic, Policy, Recovery_Services_Vaults, Resource_Graph_Explorer, Resources_Provider, Scheduler_Job_Collections, Service_Catalog_MAD, Service_Providers, Solutions, Universal_Print, User_Privacy
- **menu** (1): Keys
- **migrate** (5): Azure_Migrate, Cost_Management_and_Billing, Data_Box, Data_Box_Edge, Recovery_Services_Vaults
- **mixed_reality** (2): Remote_Rendering, Spatial_Anchor_Accounts
- **monitor** (1): SAP_Azure_Monitor
- **power_platform** (9): AIBuilder, CopilotStudio, Dataverse, PowerApps, PowerAutomate, PowerBI, PowerFx, PowerPages, PowerPlatform
- **preview** (9): Azure_Cloud_Shell, Azure_Sphere, Azure_Workbooks, IoT_Edge, Private_Link_Hub, RTOS, Static_Apps, Time_Series_Data_Sets, Web_Environment
- **web** (5): API_Center, App_Space, Azure_Media_Service, Notification_Hub_Namespaces, SignalR

View File

@@ -1,48 +0,0 @@
# basic
**Type:** mxgraph shapes
**Prefix:** `mxgraph.basic`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.basic.{shape};fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (31)
- `4_point_star`
- `6_point_star`
- `8_point_star`
- `banner`
- `cloud_callout`
- `cloud_rect`
- `cone`
- `cross`
- `document`
- `flash`
- `half_circle`
- `heart`
- `loud_callout`
- `moon`
- `mxgraph.basic`
- `no_symbol`
- `octagon`
- `orthogonal_triangle`
- `oval_callout`
- `parallelepiped`
- `pentagon`
- `pointed_oval`
- `rectangular_callout`
- `rounded_rectangular_callout`
- `smiley`
- `star`
- `sun`
- `tick`
- `trapezoid`
- `wave`
- `x`

View File

@@ -1,60 +0,0 @@
# bpmn
**Type:** mxgraph shapes
**Prefix:** `mxgraph.bpmn`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Parameters
- `outline` - Event type: `start`, `end`, `catching`, `throwing`, `none`
- `symbol` - Icon inside: `message`, `timer`, `error`, `cancel`, `compensation`, `link`, `terminate`, `general`, `multiple`, `rule`
## Shapes (40)
- `ad_hoc`
- `business_rule_task`
- `cancel_end`
- `cancel_intermediate`
- `compensation`
- `compensation_end`
- `compensation_intermediate`
- `error_end`
- `error_intermediate`
- `gateway`
- `gateway_and`
- `gateway_complex`
- `gateway_or`
- `gateway_xor_(data)`
- `gateway_xor_(event)`
- `general_end`
- `general_intermediate`
- `general_start`
- `link_end`
- `link_intermediate`
- `link_start`
- `loop`
- `loop_marker`
- `manual_task`
- `message_end`
- `message_intermediate`
- `message_start`
- `multiple_end`
- `multiple_instances`
- `multiple_intermediate`
- `multiple_start`
- `mxgraph.bpmn`
- `rule_intermediate`
- `rule_start`
- `script_task`
- `service_task`
- `terminate`
- `timer_intermediate`
- `timer_start`
- `user_task`

View File

@@ -1,71 +0,0 @@
# cabinets
**Type:** mxgraph shapes
**Prefix:** `mxgraph.cabinets`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.cabinets.{shape};" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (54)
- `auxiliary_contact_contactor_1_32a`
- `auxiliary_contact_contactor_32_125a`
- `cb_1p`
- `cb_1p_x10`
- `cb_2p`
- `cb_2p_x10`
- `cb_3p`
- `cb_3p_x5`
- `cb_4p`
- `cb_4p_x5`
- `cb_auxiliary_contact`
- `contactor_125_400a`
- `contactor_1_32a`
- `contactor_32_125a`
- `din_rail`
- `distribution_block_4p_125a_11_connections`
- `distribution_block_4p_125a_11_connections_2`
- `mccb_25_63a_3p`
- `mccb_25_63a_4p`
- `mccb_63_250a_3p`
- `mccb_63_250a_4p`
- `motor_cb_125_400a`
- `motor_cb_1_32a`
- `motor_cb_32_125a`
- `motor_protection_cb`
- `motor_starter_125_400a`
- `motor_starter_1_32a`
- `motor_starter_32_125a`
- `motorized_switch_3p`
- `motorized_switch_4p`
- `mxgraph.cabinets`
- `overcurrent_relay_125_400a`
- `overcurrent_relay_1_32a`
- `overcurrent_relay_32_125a`
- `plugin_relay_1`
- `plugin_relay_2`
- `residual_current_device_2p`
- `residual_current_device_4p`
- `surge_protection_1p`
- `surge_protection_2p`
- `surge_protection_3p`
- `surge_protection_4p`
- `terminal_40mm2`
- `terminal_40mm2_x10`
- `terminal_4_6mm2`
- `terminal_4_6mm2_x10`
- `terminal_4mm2`
- `terminal_4mm2_x10`
- `terminal_50mm2`
- `terminal_50mm2_x10`
- `terminal_6_25mm2`
- `terminal_6_25mm2_x10`
- `terminal_75mm2`
- `terminal_75mm2_x10`

View File

@@ -1,250 +0,0 @@
# cisco19
**Type:** mxgraph shapes
**Prefix:** `mxgraph.cisco19`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (233)
- `3g_4g_indicator`
- `6500_vss`
- `6500_vss2`
- `access_control_and_trustsec`
- `aci`
- `aci2`
- `acibg`
- `acs`
- `ad_decoder`
- `ad_encoder`
- `analysis_correlation`
- `anomaly_detection`
- `anti_malware`
- `anti_malware2`
- `appnav`
- `asa_5500`
- `asr_1000`
- `asr_9000`
- `avc_application_visibility_control`
- `avc_application_visibility_control2`
- `bg1`
- `bg10`
- `bg2`
- `bg3`
- `bg4`
- `bg5`
- `bg6`
- `bg7`
- `bg8`
- `bg9`
- `blade_server`
- `branch`
- `branch2`
- `camera`
- `camera2`
- `cell_phone`
- `cell_phone2`
- `cisco_15800`
- `cisco_dna`
- `cisco_dna_center`
- `cisco_meetingplace_express`
- `cisco_security_manager`
- `cisco_unified_contact_center_enterprise_and_hosted`
- `cisco_unified_presence_service`
- `clock`
- `cloud`
- `cloud2`
- `cognitive`
- `collab1`
- `collab2`
- `collab3`
- `collab4`
- `communications_manager`
- `contact_center_express`
- `content_recording_streaming_server`
- `content_router`
- `csr_1000v`
- `da_decoder`
- `da_encoder`
- `data_center`
- `data_center2`
- `database_relational`
- `dns_server`
- `dns_server2`
- `dual_mode_access_point`
- `email_security`
- `fabric_interconnect`
- `fibre_channel_director_mds_9000`
- `fibre_channel_fabric_switch`
- `firewall`
- `flow_analytics`
- `flow_analytics2`
- `flow_collector`
- `h323`
- `handheld`
- `handheld2`
- `hdtv`
- `hdtv2`
- `home_office`
- `home_office2`
- `host_based_security`
- `hypervisor`
- `immersive_telepresence_endpoint`
- `ip_ip_gateway`
- `ip_phone`
- `ip_phone2`
- `ip_telephone_router`
- `ips_ids`
- `ironport`
- `ise`
- `joystick_keyboard`
- `joystick_keyboard2`
- `key`
- `key2`
- `l2_modular`
- `l2_modular2`
- `l2_switch`
- `l2_switch_with_dual_supervisor`
- `l3_modular`
- `l3_modular2`
- `l3_modular3`
- `l3_switch`
- `l3_switch_with_dual_supervisor`
- `laptop`
- `laptop2`
- `laptop_video_client`
- `laptop_video_client2`
- `layer3_nexus_5k_switch`
- `ldap`
- `ldap2`
- `load_balancer`
- `lock`
- `lock2`
- `media_server`
- `meeting_scheduling_and_management_server`
- `mesh_access_point`
- `monitor`
- `monitoring`
- `multipoint_meeting_server`
- `mxgraph.cisco19`
- `nac_appliance`
- `nam_virtual_service_blade`
- `net_mgmt_appliance`
- `netflow_router`
- `netflow_router2`
- `netflow_router3`
- `next_generation_intrusion_prevention_system`
- `nexus_1010`
- `nexus_1k`
- `nexus_1kv_vsm`
- `nexus_2000_10ge`
- `nexus_2k`
- `nexus_3k`
- `nexus_4k`
- `nexus_5k`
- `nexus_5k_with_integrated_vsm`
- `nexus_7k`
- `nexus_9300`
- `nexus_9500`
- `operations_manager`
- `phone_polycom`
- `phone_polycom2`
- `policy_configuration`
- `pos`
- `pos2`
- `posture_assessment`
- `primary_codec`
- `printer`
- `printer2`
- `router`
- `router_with_firewall`
- `router_with_firewall2`
- `router_with_voice`
- `rps`
- `secondary_codec`
- `secure_catalyst_switch_color`
- `secure_catalyst_switch_color2`
- `secure_catalyst_switch_color3`
- `secure_catalyst_switch_subdued`
- `secure_catalyst_switch_subdued2`
- `secure_endpoint_pc`
- `secure_endpoint_pc2`
- `secure_endpoints`
- `secure_endpoints2`
- `secure_router`
- `secure_server`
- `secure_server2`
- `secure_switch`
- `security_management`
- `server`
- `server2`
- `service_ready_engine`
- `set_top`
- `set_top2`
- `shield`
- `ssl_terminator`
- `stealthwatch_management_console_smc`
- `stealthwatch_management_console_smc2`
- `storage`
- `surveillance_camera`
- `surveillance_camera2`
- `tablet`
- `tablet2`
- `telepresence_endpoint`
- `telepresence_endpoint_twin_data_display`
- `telepresence_exchange`
- `threat_intelligence`
- `transcoder`
- `ucs_5108_blade_chassis`
- `ucs_c_series_server`
- `ucs_express`
- `unity`
- `upc_unified_personal_communicator`
- `upc_unified_personal_communicator2`
- `ups`
- `user`
- `user2`
- `vbond`
- `video_analytics`
- `video_call_server`
- `video_gateway`
- `virtual_desktop_service`
- `virtual_matrix_switch`
- `virtual_private_network`
- `virtual_private_network2`
- `virtual_private_network_connector`
- `vmanage`
- `vpn_concentrator`
- `vsmart`
- `vts`
- `vts2`
- `web_application_firewall`
- `web_reputation_filtering`
- `web_reputation_filtering_2`
- `web_security`
- `web_security_services`
- `web_security_services2`
- `webex`
- `wifi_indicator`
- `wireless_access_point`
- `wireless_access_point2`
- `wireless_bridge`
- `wireless_bridge2`
- `wireless_connector`
- `wireless_intrusion_prevention`
- `wireless_lan_controller`
- `wireless_location_appliance`
- `wireless_router`
- `workgroup_switch`
- `workstation`
- `workstation2`
- `x509_certificate`
- `x509_certificate2`

View File

@@ -1,115 +0,0 @@
# citrix
**Type:** mxgraph shapes
**Prefix:** `mxgraph.citrix`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (98)
- `1u_2u_server`
- `access_card`
- `branch_repeater`
- `browser`
- `cache_server`
- `calendar`
- `cell_phone`
- `chassis`
- `citrix_hdx`
- `citrix_logo`
- `cloud`
- `command_center`
- `database`
- `database_server`
- `datacenter`
- `desktop`
- `desktop_web`
- `dhcp_server`
- `directory_server`
- `dns_server`
- `document`
- `edgesight_server`
- `file_server`
- `firewall`
- `ftp_server`
- `geolocation_database`
- `globe`
- `goto_meeting`
- `government`
- `home_office`
- `hq_enterprise`
- `inspection`
- `ip_phone`
- `kiosk`
- `laptop_1`
- `laptop_2`
- `license_server`
- `merchandising_server`
- `middleware`
- `mxgraph.citrix`
- `netscaler_gateway`
- `netscaler_mpx`
- `netscaler_sdx`
- `netscaler_vpx`
- `pbx_server`
- `pda`
- `podio`
- `printer`
- `process`
- `provisioning_server`
- `proxy_server`
- `radius_server`
- `remote_office`
- `reporting`
- `role_appcontroller`
- `role_applications`
- `role_cloudbridge`
- `role_desktops`
- `role_load_testing_controller`
- `role_load_testing_launcher`
- `role_receiver`
- `role_repeater`
- `role_secure_access`
- `role_security`
- `role_services`
- `role_storefront`
- `role_storefront_services`
- `role_synchronizer`
- `role_xenmobile`
- `role_xenmobile_device_manager`
- `router`
- `security`
- `sharefile`
- `site`
- `smtp_server`
- `storefront_services`
- `switch`
- `tablet_1`
- `tablet_2`
- `thin_client`
- `tower_server`
- `user_control`
- `users`
- `web_server`
- `web_service`
- `worxenroll`
- `worxhome`
- `worxmail`
- `worxweb`
- `xenapp_server`
- `xenapp_services`
- `xenapp_web`
- `xencenter`
- `xenclient`
- `xenclient_synchronizer`
- `xendesktop_server`
- `xenmobile`
- `xenserver`

View File

@@ -1,50 +0,0 @@
# electrical
**Type:** mxgraph shapes
**Prefix:** `mxgraph.electrical`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.electrical.resistors.resistor_1;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="20" as="geometry" />
</mxCell>
```
Shapes are organized by category: `mxgraph.electrical.{category}.{shape}`
## Categories
### resistors
- `resistor_1`
- `resistor_2`
### capacitors
- `capacitor_1`
- `capacitor_3`
### inductors
- `inductor_3`
- `transformer_1`
### diodes
- `diode`
- `zener_diode_1`
### transistors
- `npn_transistor_1`
- `pnp_transistor_1`
### mosfets1
- `n-channel_mosfet_1`
- `p-channel_mosfet_1`
### logic_gates
- `logic_gate`
- `dual_inline_ic`
### electro-mechanical
- `singleSwitch`
- `pushbutton`
(See draw.io Electrical shape library for complete list)

View File

@@ -1,62 +0,0 @@
# floorplan
**Type:** mxgraph shapes
**Prefix:** `mxgraph.floorplan`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.floorplan.{shape};fillColor=#ffffff;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (45)
- `bathtub`
- `bathtub2`
- `bed_double`
- `bed_single`
- `bookcase`
- `chair`
- `copier`
- `couch`
- `crt_tv`
- `desk_corner`
- `desk_corner_2`
- `dresser`
- `drying_machine`
- `elevator`
- `fireplace`
- `flat_tv`
- `floor_lamp`
- `laptop`
- `mxgraph.floorplan`
- `office_chair`
- `piano`
- `plant`
- `printer`
- `range_1`
- `range_2`
- `refrigerator`
- `shower`
- `shower2`
- `sink_1`
- `sink_2`
- `sink_22`
- `sink_double`
- `sink_double2`
- `sofa`
- `spiral_stairs`
- `table`
- `table_1`
- `table_2`
- `table_3`
- `table_4`
- `table_5`
- `toilet`
- `washing_machine`
- `water_cooler`
- `workstation`

View File

@@ -1,52 +0,0 @@
# flowchart
**Type:** mxgraph shapes
**Prefix:** `mxgraph.flowchart`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.flowchart.{shape};fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (35)
- `annotation_1`
- `annotation_2`
- `card`
- `collate`
- `data`
- `database`
- `decision`
- `delay`
- `direct_data`
- `display`
- `document`
- `extract_or_measurement`
- `internal_storage`
- `loop_limit`
- `manual_input`
- `manual_operation`
- `merge_or_storage`
- `multi-document`
- `mxgraph.flowchart`
- `off-page_reference`
- `on-page_reference`
- `or`
- `paper_tape`
- `parallel_mode`
- `predefined_process`
- `preparation`
- `process`
- `sequential_data`
- `sort`
- `start_1`
- `start_2`
- `stored_data`
- `summing_function`
- `terminator`
- `transfer`

View File

@@ -1,264 +0,0 @@
# fluidpower
**Type:** mxgraph shapes
**Prefix:** `mxgraph.fluid_power`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.fluid_power.{shape};fillColor=strokeColor;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
Shapes are named like x10010, x10020, etc.
## Shapes (247)
- `mxgraph.fluid_power`
- `x10010`
- `x10020`
- `x10030`
- `x10040`
- `x10050`
- `x10060`
- `x10070`
- `x10080`
- `x10090`
- `x10100`
- `x10110`
- `x10120`
- `x10130`
- `x10140`
- `x10150`
- `x10160`
- `x10170`
- `x10180`
- `x10190`
- `x10200`
- `x10210`
- `x10220`
- `x10230`
- `x10240`
- `x10250`
- `x10260`
- `x10270`
- `x10280`
- `x10290`
- `x10300`
- `x10310`
- `x10320`
- `x10330`
- `x10340`
- `x10350`
- `x10360`
- `x10370`
- `x10380`
- `x10390`
- `x10400`
- `x10410`
- `x10420`
- `x10430`
- `x10440`
- `x10441`
- `x10442`
- `x10450`
- `x10460`
- `x10470`
- `x10480`
- `x10490`
- `x10500`
- `x10510`
- `x10520`
- `x10530`
- `x10540`
- `x10550`
- `x10560`
- `x10570`
- `x10580`
- `x10590`
- `x10600`
- `x10610`
- `x10620`
- `x10630`
- `x10640`
- `x10650`
- `x10660`
- `x10670`
- `x10680`
- `x10690`
- `x10700`
- `x10710`
- `x10720`
- `x10730`
- `x10740`
- `x10750`
- `x10760`
- `x10770`
- `x10780`
- `x10790`
- `x10800`
- `x10810`
- `x10820`
- `x10830`
- `x10840`
- `x10850`
- `x10860`
- `x10870`
- `x10880`
- `x10890`
- `x10900`
- `x10910`
- `x10920`
- `x10930`
- `x10940`
- `x10950`
- `x10960`
- `x10970`
- `x10980`
- `x10990`
- `x11000`
- `x11010`
- `x11020`
- `x11030`
- `x11040`
- `x11050`
- `x11060`
- `x11070`
- `x11080`
- `x11090`
- `x11100`
- `x11110`
- `x11120`
- `x11130`
- `x11140`
- `x11150`
- `x11160`
- `x11170`
- `x11180`
- `x11190`
- `x11200`
- `x11210`
- `x11220`
- `x11230`
- `x11240`
- `x11250`
- `x11260`
- `x11270`
- `x11280`
- `x11290`
- `x11300`
- `x11310`
- `x11320`
- `x11330`
- `x11340`
- `x11350`
- `x11360`
- `x11370`
- `x11380`
- `x11390`
- `x11400`
- `x11410`
- `x11420`
- `x11430`
- `x11440`
- `x11450`
- `x11460`
- `x11470`
- `x11480`
- `x11490`
- `x11500`
- `x11510`
- `x11520`
- `x11530`
- `x11540`
- `x11550`
- `x11560`
- `x11570`
- `x11580`
- `x11590`
- `x11600`
- `x11610`
- `x11620`
- `x11630`
- `x11640`
- `x11650`
- `x11660`
- `x11670`
- `x11680`
- `x11690`
- `x11700`
- `x11710`
- `x11720`
- `x11730`
- `x11740`
- `x11750`
- `x11760`
- `x11770`
- `x11780`
- `x11790`
- `x11800`
- `x11810`
- `x11820`
- `x11830`
- `x11840`
- `x11850`
- `x11860`
- `x11870`
- `x11880`
- `x11890`
- `x11900`
- `x11910`
- `x11920`
- `x11930`
- `x11940`
- `x11950`
- `x11960`
- `x11970`
- `x11980`
- `x11990`
- `x12000`
- `x12010`
- `x12020`
- `x12030`
- `x12040`
- `x12050`
- `x12060`
- `x12070`
- `x12080`
- `x12090`
- `x12100`
- `x12110`
- `x12120`
- `x12130`
- `x12140`
- `x12150`
- `x12160_detailed`
- `x12160_simplified`
- `x12170`
- `x12180`
- `x12190`
- `x12200`
- `x12210`
- `x12220`
- `x12230`
- `x12240`
- `x12250`
- `x12260`
- `x12270`
- `x12280`
- `x12290`
- `x12300`
- `x12310`
- `x12320`
- `x12330`
- `x12340`
- `x12350`
- `x12360`
- `x12370`
- `x12380`
- `x12390`
- `x12400`
- `x12410`
- `x12420`
- `x12430`

View File

@@ -1,315 +0,0 @@
# gcp2
**Type:** mxgraph shapes
**Prefix:** `mxgraph.gcp2`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (298)
- `a7_power`
- `admin_connected`
- `admob`
- `advanced_solutions_lab`
- `ai_hub`
- `anomaly_detection`
- `api_analytics`
- `api_monetization`
- `apigee_api_platform`
- `apigee_sense`
- `app_engine`
- `app_engine_icon`
- `application`
- `application_system`
- `arrow_cycle`
- `arrows_system`
- `aspect_ratio`
- `automl_natural_language`
- `automl_tables`
- `automl_translation`
- `automl_video_intelligence`
- `automl_vision`
- `avere`
- `beacon`
- `beyondcorp`
- `big_query`
- `bigquery`
- `biomedical_beaker`
- `biomedical_test_tube`
- `biomedical_trio`
- `blank`
- `blue_hexagon`
- `bucket`
- `bucket_scale`
- `calculator`
- `campaign_manager`
- `capabilities`
- `certified_industry_standard`
- `check`
- `check_2`
- `check_available`
- `check_scale`
- `circuit_board`
- `clock`
- `cloud`
- `cloud_apis`
- `cloud_armor`
- `cloud_automl`
- `cloud_bigtable`
- `cloud_cdn`
- `cloud_checkmark`
- `cloud_code`
- `cloud_composer`
- `cloud_computer`
- `cloud_connected_insight`
- `cloud_data_catalog`
- `cloud_data_fusion`
- `cloud_dataflow`
- `cloud_dataflow_icon`
- `cloud_datalab`
- `cloud_dataprep`
- `cloud_dataproc`
- `cloud_dataproc_icon`
- `cloud_datastore`
- `cloud_deployment_manager`
- `cloud_dns`
- `cloud_endpoints`
- `cloud_external_ip_addresses`
- `cloud_filestore`
- `cloud_firestore`
- `cloud_firewall_rules`
- `cloud_functions`
- `cloud_iam`
- `cloud_inference_api`
- `cloud_information`
- `cloud_iot_core`
- `cloud_iot_edge`
- `cloud_jobs_api`
- `cloud_load_balancing`
- `cloud_machine_learning`
- `cloud_memorystore`
- `cloud_messaging`
- `cloud_monitoring`
- `cloud_nat`
- `cloud_natural_language_api`
- `cloud_network`
- `cloud_pubsub`
- `cloud_router`
- `cloud_routes`
- `cloud_run`
- `cloud_scheduler`
- `cloud_security`
- `cloud_security_command_center`
- `cloud_security_scanner`
- `cloud_server`
- `cloud_service_mesh`
- `cloud_spanner`
- `cloud_speech_api`
- `cloud_sql`
- `cloud_storage`
- `cloud_sub_pub`
- `cloud_tasks`
- `cloud_test_lab`
- `cloud_text_to_speech`
- `cloud_tools_for_powershell`
- `cloud_tpu`
- `cloud_translation_api`
- `cloud_video_intelligence_api`
- `cloud_vision_api`
- `cloud_vpn`
- `cluster`
- `compute_engine`
- `compute_engine_2`
- `compute_engine_icon`
- `connected`
- `container_builder`
- `container_engine`
- `container_engine_icon`
- `container_optimized_os`
- `container_registry`
- `cost`
- `cost_arrows`
- `cost_savings`
- `data_access`
- `data_increase`
- `data_loss_prevention_api`
- `data_storage_cost`
- `data_studio`
- `database`
- `database_2`
- `database_3`
- `database_cycle`
- `database_speed`
- `database_uploading`
- `debugger`
- `dedicated_game_server`
- `dedicated_interconnect`
- `desktop`
- `desktop_and_mobile`
- `developer_portal`
- `dialogflow_enterprise_edition`
- `enhance_ui`
- `enhance_ui_2`
- `error_reporting`
- `external_data_center`
- `external_data_resource`
- `external_payment_form`
- `fastly`
- `files`
- `firebase`
- `folders`
- `forseti_lockup`
- `forseti_logo`
- `frontend_platform_services`
- `game`
- `gateway`
- `gateway_icon`
- `gear`
- `gear_arrow`
- `gear_chain`
- `gear_load`
- `genomics`
- `gke_on_prem`
- `globe_world`
- `google_ad_manager`
- `google_ads`
- `google_analytics`
- `google_analytics_360`
- `google_cloud_platform`
- `google_cloud_platform_lockup`
- `google_network`
- `google_network_edge_cache`
- `google_play_game_service`
- `gpu`
- `half_cloud`
- `https_load_balancer`
- `identity_aware_proxy`
- `image_services`
- `increase_cost_arrows`
- `internal_payment_authorization`
- `internet_connection`
- `istio_logo`
- `key`
- `key_management_service`
- `kubernetes_logo`
- `kubernetes_name`
- `laptop`
- `legacy_cloud`
- `legacy_cloud_2`
- `lifecycle`
- `lightbulb`
- `list`
- `live`
- `load_balancing`
- `loading`
- `loading_2`
- `loading_3`
- `lock`
- `logging`
- `logs_api`
- `management_security`
- `maps_api`
- `mem_instances`
- `memcache`
- `memory_card`
- `mobile_devices`
- `modifiers_autoscaling`
- `modifiers_custom_virtual_machine`
- `modifiers_high_cpu_machine`
- `modifiers_high_memory_machine`
- `modifiers_preemptable_vm`
- `modifiers_shared_core_machine_f1`
- `modifiers_shared_core_machine_g1`
- `modifiers_standard_machine`
- `modifiers_storage`
- `monitor`
- `monitor_2`
- `mxgraph.gcp2`
- `nat`
- `network`
- `network_load_balancer`
- `node`
- `outline_blank_1`
- `outline_blank_2`
- `outline_blank_3`
- `outline_highcomp`
- `outline_highmem`
- `partner_interconnect`
- `payment`
- `people_security_management`
- `persistent_disk`
- `persistent_disk_snapshot`
- `phone`
- `phone_android`
- `placeholder`
- `play_gear`
- `play_start`
- `prediction_api`
- `premium_network_tier`
- `primary`
- `process`
- `profiler`
- `push_notification_service`
- `recommendations_ai`
- `record`
- `replication_controller`
- `replication_controller_2`
- `replication_controller_3`
- `report`
- `repository`
- `repository_2`
- `repository_3`
- `repository_primary`
- `retail`
- `safety`
- `save`
- `scale`
- `scheduled_tasks`
- `search`
- `search_api`
- `security_key_enforcement`
- `segments`
- `segments_2`
- `segments_overlap`
- `servers_stacked`
- `service`
- `service_discovery`
- `social_media_time`
- `solution`
- `speaker`
- `speed`
- `squid_proxy`
- `stackdriver`
- `stacked_ownership`
- `standard_network_tier`
- `storage`
- `stream`
- `swap`
- `systems_check`
- `tape_record`
- `task_queues`
- `task_queues_2`
- `tensorflow_lockup`
- `tensorflow_logo`
- `thumbs_up`
- `time_clock`
- `trace`
- `traffic_director`
- `transfer_appliance`
- `users`
- `view_list`
- `virtual_file_system`
- `virtual_private_cloud`
- `visibility`
- `vpn`
- `vpn_gateway`
- `webcam`
- `website`

View File

@@ -1,24 +0,0 @@
# infographic
**Type:** mxgraph shapes
**Prefix:** `mxgraph.infographic`
## Usage
```xml
<mxCell value="label" style="html=1;shape=mxgraph.infographic.shadedCube;isoAngle=15;fillColor=#10739E;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes
- `shadedCube` (needs `isoAngle=15;`)
- `ribbonSimple` (needs `notch1=20;notch2=20;`)
- `ribbonRolled`
- `ribbonDoubleFolded`
- `shadedTriangle`
- `shadedPyramid`
- `cylinder`
- `banner`
- `flag`

View File

@@ -1,58 +0,0 @@
# kubernetes
**Type:** mxgraph shapes
**Prefix:** `mxgraph.kubernetes`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (41)
- `api`
- `c_c_m`
- `c_m`
- `c_role`
- `cm`
- `crb`
- `crd`
- `cronjob`
- `deploy`
- `ds`
- `ep`
- `etcd`
- `frame`
- `group`
- `hpa`
- `ing`
- `job`
- `k_proxy`
- `kubelet`
- `limits`
- `master`
- `mxgraph.kubernetes`
- `netpol`
- `node`
- `ns`
- `pod`
- `psp`
- `pv`
- `pvc`
- `quota`
- `rb`
- `role`
- `rs`
- `sa`
- `sc`
- `sched`
- `secret`
- `sts`
- `svc`
- `user`
- `vol`

View File

@@ -1,31 +0,0 @@
# lean_mapping
**Type:** mxgraph shapes
**Prefix:** `mxgraph.lean_mapping`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.lean_mapping.{shape};strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (14)
- `airplane_7`
- `electronic_info_flow`
- `finished_goods_to_customer`
- `go_see_production_scheduling`
- `kaizen_lightening_burst`
- `kanban_post`
- `load_leveling`
- `manual_info_flow`
- `move_by_forklift`
- `mrp_erp`
- `mxgraph.lean_mapping`
- `operator`
- `quality_problem`
- `verbal`

View File

@@ -1,22 +0,0 @@
# mscae
**Type:** mxgraph shapes
**Prefix:** `mxgraph.mscae`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Categories
Shapes are organized by category: `mscae.cloud`, `mscae.intune`, `mscae.oms`, `mscae.system_center`
- `conditional_access_exchange`
- `conditional_access_sharepoint`
- `primary_site`
(See draw.io for complete shape list within each category)

View File

@@ -1,72 +0,0 @@
# network
**Type:** mxgraph shapes
**Prefix:** `mxgraph.networks`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (57)
- `biometric_reader`
- `bus`
- `business_center`
- `cloud`
- `comm_link`
- `comm_link_edge`
- `community`
- `copier`
- `desktop_pc`
- `external_storage`
- `firewall`
- `gamepad`
- `hub`
- `laptop`
- `load_balancer`
- `mail_server`
- `mainframe`
- `mobile`
- `modem`
- `monitor`
- `nas_filer`
- `patch_panel`
- `phone_1`
- `phone_2`
- `printer`
- `proxy_server`
- `rack`
- `radio_tower`
- `router`
- `satellite`
- `satellite_dish`
- `scanner`
- `secured`
- `security_camera`
- `server`
- `server_storage`
- `storage`
- `supercomputer`
- `switch`
- `tablet`
- `tape_storage`
- `terminal`
- `unsecure`
- `ups_enterprise`
- `ups_small`
- `usb_stick`
- `user_female`
- `user_male`
- `users`
- `video_projector`
- `video_projector_screen`
- `virtual_pc`
- `virtual_server`
- `virus`
- `web_server`
- `wireless_hub`
- `wireless_modem`

View File

@@ -1,36 +0,0 @@
# openstack
**Type:** mxgraph shapes
**Prefix:** `mxgraph.openstack`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (19)
- `cinder_volume`
- `cinder_volumeattachment`
- `designate_recordset`
- `designate_zone`
- `heat_autoscalinggroup`
- `heat_resourcegroup`
- `heat_scalingpolicy`
- `mxgraph.openstack`
- `neutron_floatingip`
- `neutron_floatingipassociation`
- `neutron_net`
- `neutron_port`
- `neutron_router`
- `neutron_routerinterface`
- `neutron_securitygroup`
- `neutron_subnet`
- `nova_keypair`
- `nova_server`
- `swift_container`

View File

@@ -1,22 +0,0 @@
# pid
**Type:** mxgraph shapes
**Prefix:** `mxgraph.pid2valves`, `mxgraph.pid2inst`, `mxgraph.pid2misc`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.pid2valves.valve;valveType=gate;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Valve Types
For `mxgraph.pid2valves.valve`, use `valveType=` with:
- `gate`, `globe`, `needle`, `ball`, `butterfly`, `diaphragm`, `plug`, `check`
## Other Prefixes
- `mxgraph.pid2inst` - Instruments (discInst, sharedCont, compFunc)
- `mxgraph.pid2misc` - Miscellaneous

View File

@@ -1,57 +0,0 @@
# rack
**Type:** mxgraph shapes
**Prefix:** `mxgraph.rack`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.rack.f5.arx_500;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="200" height="30" as="geometry" />
</mxCell>
```
Shapes are organized by vendor: `mxgraph.rack.{vendor}.{model}`
## Vendors
### F5
- `arx_500`
- `big_ip_1600`
- `big_ip_2000`
- `big_ip_4000`
### Dell
- `dell_poweredge_1u`
- `poweredge_630`
- `poweredge_730`
### HPE Aruba
HPE Aruba shapes have subcategories: `mxgraph.rack.hpe_aruba.{category}.{model}`
**gateways_controllers:**
- `aruba_7010_mobility_controller_front`
- `aruba_7010_mobility_controller_rear`
- `aruba_7024_mobility_controller_front`
- `aruba_7205_mobility_controller_front`
**security:**
- `aruba_clearpass_c1000_front`
- `aruba_clearpass_c2000_front`
- `aruba_clearpass_c3000_front`
**switches:**
- `j9772a_2530_48g_poeplus_switch`
- `j9773a_2530_24g_poeplus_switch`
- `jl253a_aruba_2930f_24g_4sfpplus_switch`
### General (rackGeneral)
Use `mxgraph.rackGeneral.{shape}` for generic rack items:
- `rackCabinet3`
- `plate`
(See draw.io Rack shape library for complete list)

View File

@@ -1,116 +0,0 @@
# salesforce
**Type:** mxgraph shapes
**Prefix:** `mxgraph.salesforce`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
Replace `analytics` with any shape from the list below.
## Shapes (97)
- `analytics`
- `analytics2`
- `apps`
- `apps2`
- `automation`
- `automation2`
- `automotive`
- `automotive2`
- `bots`
- `bots2`
- `builders`
- `builders2`
- `channels`
- `channels2`
- `commerce`
- `commerce2`
- `communications`
- `communications2`
- `consumer_goods`
- `consumer_goods2`
- `customer_360`
- `customer_3602`
- `data`
- `data2`
- `education`
- `education2`
- `employees`
- `employees2`
- `energy`
- `energy2`
- `field_service`
- `field_service2`
- `financial_services`
- `financial_services2`
- `government`
- `government2`
- `health`
- `health2`
- `heroku`
- `heroku2`
- `inbox`
- `inbox2`
- `industries`
- `industries2`
- `integration`
- `integration2`
- `iot`
- `iot2`
- `learning`
- `learning2`
- `loyalty`
- `loyalty2`
- `manufacturing`
- `manufacturing2`
- `marketing`
- `marketing2`
- `media`
- `media2`
- `mxgraph.salesforce`
- `non_profit`
- `non_profit2`
- `partners`
- `partners2`
- `personalization`
- `personalization2`
- `philantrophy`
- `philantrophy2`
- `platform`
- `platform2`
- `privacy`
- `privacy2`
- `retail`
- `retail2`
- `sales`
- `sales2`
- `segments`
- `segments2`
- `service`
- `service2`
- `smb`
- `smb2`
- `social_studio`
- `social_studio2`
- `stream`
- `stream2`
- `success`
- `success2`
- `sustainability`
- `sustainability2`
- `transportation_and_technology`
- `transportation_and_technology2`
- `web`
- `web2`
- `work_com`
- `work_com2`
- `workflow`
- `workflow2`

View File

@@ -1,179 +0,0 @@
# sap
**Type:** SVG images
**Path:** `img/lib/sap/`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (164)
- `1`
- `2`
- `3`
- `4`
- `5`
- `6`
- `7`
- `8`
- `9`
- `10`
- `11`
- `12`
- `13`
- `Adapter`
- `Admin`
- `Alert`
- `API`
- `API_Business_Hub_Enterprise`
- `App`
- `Application_Autoscaler`
- `Application_Frontend_Service`
- `Application_Vulnerability_Report`
- `Building`
- `Business_Application_Studio`
- `Business_Entity_Recognition`
- `Business_Process_Model_Connector_for_SAP_Signavio_Solutions`
- `Cloud`
- `Cloud_Connector`
- `Cloud_Connector2`
- `Cloud_Integration_Automation`
- `Cloud_Integration_Automation2`
- `Cloud_Transport_Management`
- `Data_Attribute_Recommendation`
- `Deploy`
- `Desktop`
- `Devices`
- `Document`
- `Document_Information_Extraction`
- `Documents`
- `Edge_Integration_Cell`
- `Event`
- `Extensibility_Service`
- `Factory`
- `Feature`
- `HTML5_App_Repository`
- `Identity_Authentication`
- `Identity_Authentication2`
- `Identity_Directory`
- `Identity_Directory2`
- `Identity_Provisioning`
- `Identity_Provisioning2`
- `Info`
- `Intelligent_Situation_Automation`
- `Invoice_Object_Recommendation`
- `Invoice_Object_Recommendation2`
- `Key`
- `Landscape_Portal_for_SAP_S4HANA_Cloud_ABAP_Environment`
- `Link`
- `Locked`
- `Machine`
- `Message`
- `Mobile`
- `OAuth_20`
- `Object_Store_on_SAP_BTP`
- `On-Premise`
- `Personalized_Recommendation`
- `SAP_AI_Core`
- `SAP_AI_Launchpad`
- `SAP_Alert_Notification_service_for_SAP_BTP`
- `SAP_Analytics_Cloud`
- `SAP_Analytics_Cloud_Embedded_Edition`
- `SAP_Application_Logging_service_for_SAP_BTP`
- `SAP_Asset_Performance_Management`
- `SAP_Audit_Log_Service`
- `SAP_Authorization_Management_Service`
- `SAP_Authorization_and_Trust_Management_service`
- `SAP_Automation_Pilot`
- `SAP_BTP,_ABAP_environment`
- `SAP_BTP,_Cloud_Foundry_runtime`
- `SAP_BTP,_Kyma_runtime`
- `SAP_Build`
- `SAP_Build_Apps`
- `SAP_Build_Apps_-_Copy`
- `SAP_Build_Code`
- `SAP_Build_Process_Automation`
- `SAP_Build_Process_Automation_-_Copy`
- `SAP_Build_Work_Zone_-_Advanced_Edition`
- `SAP_Build_Work_Zone_-_Standard_Edition`
- `SAP_Business_Accelerator_Hub`
- `SAP_Business_Data_Cloud`
- `SAP_Cloud_ALM`
- `SAP_Cloud_Application_Programming_Model`
- `SAP_Cloud_Identity,_SAP_Malware_Scanning_Service`
- `SAP_Cloud_Identity_Service`
- `SAP_Cloud_Logging`
- `SAP_Cloud_Management_Service`
- `SAP_Cloud_Transport_Management`
- `SAP_Collaborative_Demand_and_Capacity_Management`
- `SAP_Connectivity_Service`
- `SAP_Content_Agent_Service`
- `SAP_Continuous_Integration_and_Delivery`
- `SAP_Credential_Store`
- `SAP_Custom_Domain_service`
- `SAP_Data_Privacy_Integration`
- `SAP_Data_Retention_Manager`
- `SAP_Datasphere`
- `SAP_Destination_service`
- `SAP_Digital_Assistant`
- `SAP_Digital_Assistant_Service`
- `SAP_Digital_Manufacturing`
- `SAP_Document_Grounding`
- `SAP_Document_Management_Service`
- `SAP_Event_Broker_for_SAP_Cloud_Applications`
- `SAP_Green_Token`
- `SAP_HANA_Cloud`
- `SAP_HANA_Spatial_Services`
- `SAP_Health_Data_Services_for_FHIR`
- `SAP_Integration_Suite`
- `SAP_Integration_Suite_-_API_Managment`
- `SAP_Integration_Suite_-_Advanced_Event_Mesh`
- `SAP_Integration_Suite_-_Cloud_Integration`
- `SAP_Integration_Suite_-_Data_Space_Integration`
- `SAP_Integration_Suite_-_Event_Mesh`
- `SAP_Integration_Suite_-_Integration_Advisor`
- `SAP_Integration_Suite_-_Integration_Assessment`
- `SAP_Integration_Suite_-_Migration_Assessment`
- `SAP_Integration_Suite_-_Open_Connectors`
- `SAP_Integration_Suite_-_SAP_Graph`
- `SAP_Integration_Suite_-_Trading_Partner_Management`
- `SAP_Job_Scheduling_service`
- `SAP_Keystore_Service`
- `SAP_Landscape_Management_Cloud`
- `SAP_Logo`
- `SAP_Master_Data_Governance`
- `SAP_Master_Data_Integration`
- `SAP_Mobile_Services`
- `SAP_Monitoring_service_for_SAP_BTP`
- `SAP_Omnichannel_Promotion_Pricing`
- `SAP_PKI_Certificate_Service`
- `SAP_Persistence_Service_ASE`
- `SAP_Personal_Data_Manager`
- `SAP_Private_Link_service`
- `SAP_Project_and_Resource_Management`
- `SAP_Responsibility_Management_Service`
- `SAP_S4HANA_Cloud_for_Intelligent_Intercompany_Reconciliation`
- `SAP_S4HANA_for_MS_Teams`
- `SAP_Secure_Login_Service_for_SAP_GUI`
- `SAP_Service_Manager`
- `SAP_Software_as_a_Service_Provisioning_Service`
- `SAP_Solution_Lifecycle_Management_Service`
- `SAP_Sustainability_Data_Exchange`
- `SAP_Task_Center`
- `SAP_Translation_Hub`
- `SAP_Variant_Configuration_and_Pricing`
- `SAP_Watch_List_Screening`
- `Service_Ticket_Intelligence`
- `Service_Ticket_Intelligence2`
- `Settings`
- `Success`
- `Third_Party`
- `UI5_flexibility_for_key_users`
- `UI_Theme_Designer`
- `User`
- `Web`

View File

@@ -1,68 +0,0 @@
# sitemap
**Type:** mxgraph shapes
**Prefix:** `mxgraph.sitemap`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.sitemap.{shape};fillColor=#7ea6e0;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (51)
- `about_us`
- `audio`
- `biography`
- `blog`
- `calendar`
- `chart`
- `chat`
- `cloud`
- `contact`
- `contact_us`
- `document`
- `download`
- `error`
- `faq`
- `form`
- `gallery`
- `game`
- `home`
- `info`
- `jobs`
- `log`
- `login`
- `mail`
- `map`
- `mxgraph.sitemap`
- `news`
- `page`
- `payment`
- `photo`
- `portfolio`
- `post`
- `pricing`
- `print`
- `products`
- `profile`
- `references`
- `script`
- `search`
- `security`
- `services`
- `settings`
- `shopping`
- `sitemap`
- `slideshow`
- `sports`
- `success`
- `text`
- `upload`
- `user`
- `video`
- `warning`

View File

@@ -1,112 +0,0 @@
# vvd
**Type:** mxgraph shapes
**Prefix:** `mxgraph.vvd`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (95)
- `administrator`
- `app`
- `app_volumes_manager`
- `appstack_volume`
- `array_manager`
- `blueprint`
- `business_continuity_data_protection`
- `cd`
- `cloud_computing`
- `collective_nsx_esg`
- `consumption_plane`
- `cpu`
- `datacenter`
- `datastore`
- `disk`
- `document`
- `edge_gateway`
- `endpoint`
- `ethernet_port`
- `external_networks`
- `flash_drive`
- `folder`
- `guest_agent_customization`
- `horizon`
- `infrastructure`
- `key`
- `keyboard`
- `laptop`
- `log_files`
- `logical_distribution`
- `logical_firewall`
- `machine`
- `memory`
- `monitor`
- `mouse`
- `mxgraph.vvd`
- `networking`
- `networks`
- `nfvo`
- `nsx`
- `nsx_controller`
- `nsx_dashboard`
- `nsx_edge_and_load_balancer`
- `nsx_esg`
- `nsx_manager`
- `nsx_public_cloud_gateway`
- `on_demand_self_service`
- `ovdc_networks`
- `pair_sites`
- `phone`
- `physical_network_adapter`
- `physical_storage`
- `physical_upstream_router`
- `platform_services_controller`
- `protection_group`
- `protection_group_config`
- `recovery_plan`
- `resource_pool`
- `scsi_controller`
- `security`
- `server`
- `service_provider_cloud_environment`
- `site`
- `site_container`
- `site_recovery`
- `site_recovery_functional_icon`
- `ssd`
- `storage`
- `switch`
- `telco_network`
- `template`
- `tenant_key`
- `user_group`
- `vapp_network`
- `vcenter_server`
- `vcloud_director`
- `virtual_appliance`
- `virtual_machine`
- `virtual_switch`
- `vm_group`
- `vnf_m`
- `volumes_agent`
- `vpn`
- `vrealize_automation`
- `vrealize_log_insight`
- `vrealize_operations`
- `vrealize_orchestrator`
- `vrops`
- `vsan`
- `vshield`
- `vxlan`
- `wavefront`
- `web_browser`
- `wi_fi`
- `writable_volume`

View File

@@ -1,194 +0,0 @@
# webicons
**Type:** mxgraph shapes
**Prefix:** `mxgraph.webicons`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (177)
- `adfty`
- `adobe_pdf`
- `aim`
- `allvoices`
- `amazon`
- `amazon_2`
- `android`
- `apache`
- `apple`
- `apple_classic`
- `arduino`
- `ask`
- `atlassian`
- `audioboo`
- `aws`
- `aws_s3`
- `baidu`
- `bebo`
- `behance`
- `bing`
- `bitbucket`
- `blinklist`
- `blogger`
- `blogmarks`
- `bookmarks.fr`
- `box`
- `buddymarks`
- `buffer`
- `buzzfeed`
- `chrome`
- `citeulike`
- `confluence`
- `connotea`
- `dealsplus`
- `delicious`
- `designfloat`
- `deviantart`
- `digg`
- `diigo`
- `dopplr`
- `drawio1`
- `drawio2`
- `dribbble`
- `dropbox`
- `dropbox2`
- `drupal`
- `dzone`
- `ebay`
- `edmodo`
- `evernote`
- `facebook`
- `fancy`
- `fark`
- `fashiolista`
- `feed`
- `feedburner`
- `flickr`
- `folkd`
- `forrst`
- `fotolog`
- `freshbump`
- `fresqui`
- `friendfeed`
- `funp`
- `fwisp`
- `gabbr`
- `gamespot`
- `github`
- `gmail`
- `google`
- `google_drive`
- `google_hangout`
- `google_photos`
- `google_play`
- `google_play_light`
- `google_plus`
- `grooveshark`
- `hatena`
- `html5`
- `identi.ca`
- `instagram`
- `instapaper`
- `ios`
- `jamespot`
- `java`
- `joomla`
- `jquery`
- `json`
- `json_2`
- `last.fm`
- `linkagogo`
- `linkedin`
- `livejournal`
- `mail.ru`
- `meetup`
- `meneame`
- `messenger`
- `messenger_2`
- `messenger_3`
- `mind_body_green`
- `mongodb`
- `mxgraph.webicons`
- `myspace`
- `n4g`
- `netlog`
- `netvibes`
- `netvouz`
- `networkedblogs`
- `newsvine`
- `odnoklassniki`
- `oknotizie`
- `onedrive`
- `oracle`
- `paypal`
- `phone`
- `phonefavs`
- `pinterest`
- `plaxo`
- `playfire`
- `plurk`
- `pocket`
- `protopage`
- `readernaut`
- `reddit`
- `rss`
- `scoopit`
- `scribd`
- `segnalo`
- `sina`
- `sitejot`
- `skype`
- `skyrock`
- `slashdot`
- `sms`
- `socialvibe`
- `society6`
- `sonico`
- `soundcloud`
- `sourceforge`
- `sourceforge_2`
- `spring.me`
- `stackexchange`
- `stackoverflow`
- `startaid`
- `startlap`
- `steam`
- `stumbleupon`
- `stumpedia`
- `technorati`
- `translate`
- `tumblr`
- `tunein`
- `twitter`
- `two`
- `typepad`
- `viadeo`
- `viber`
- `viddler`
- `vimeo`
- `virb`
- `vkontakte`
- `wakoopa`
- `weheartit`
- `whatsapp`
- `wix`
- `wordpress`
- `wordpress_2`
- `xanga`
- `xerpi`
- `xing`
- `yahoo`
- `yahoo_2`
- `yammer`
- `yandex`
- `yelp`
- `yoolink`
- `youmob`

View File

@@ -1,96 +0,0 @@
appId: com.nextaidrawio.app
productName: Next AI Draw.io
copyright: Copyright © 2024 Next AI Draw.io
electronVersion: 39.2.7
directories:
output: release
buildResources: resources
afterPack: ./scripts/afterPack.cjs
files:
- dist-electron/**/*
- "!node_modules"
asarUnpack:
- "**/*.node"
extraResources:
# Copy prepared standalone directory (includes node_modules)
- from: electron-standalone/
to: standalone/
# Copy icon for runtime use (Windows/Linux)
- from: resources/icon.png
to: icon.png
# macOS configuration
mac:
category: public.app-category.productivity
icon: resources/icon.png
target:
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
hardenedRuntime: true
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
dmg:
contents:
- x: 130
y: 220
- x: 410
y: 220
type: link
path: /Applications
window:
width: 540
height: 380
# Windows configuration
win:
icon: resources/icon.png
target:
- target: nsis
arch:
- x64
- arm64
- target: portable
arch:
- x64
- arm64
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
createDesktopShortcut: true
createStartMenuShortcut: true
# Linux configuration
linux:
icon: resources/icon.png
category: Office
maintainer: Next AI Draw.io <nextaidrawio@users.noreply.github.com>
target:
- target: AppImage
arch:
- x64
- arm64
- target: deb
arch:
- x64
- arm64
# Publish configuration (optional)
publish:
provider: github
releaseType: release

74
electron.d.ts vendored
View File

@@ -1,74 +0,0 @@
/**
* Type declarations for Electron API exposed via preload script
*/
/** Configuration preset interface */
interface ConfigPreset {
id: string
name: string
createdAt: number
updatedAt: number
config: {
AI_PROVIDER?: string
AI_MODEL?: string
AI_API_KEY?: string
AI_BASE_URL?: string
TEMPERATURE?: string
[key: string]: string | undefined
}
}
/** Result of applying a preset */
interface ApplyPresetResult {
success: boolean
error?: string
env?: Record<string, string>
}
declare global {
interface Window {
/** Main window Electron API */
electronAPI?: {
/** Current platform (darwin, win32, linux) */
platform: NodeJS.Platform
/** Whether running in Electron environment */
isElectron: boolean
/** Get application version */
getVersion: () => Promise<string>
/** Minimize the window */
minimize: () => void
/** Maximize/restore the window */
maximize: () => void
/** Close the window */
close: () => void
/** Open file dialog and return file path */
openFile: () => Promise<string | null>
/** Save data to file via save dialog */
saveFile: (data: string) => Promise<boolean>
}
/** Settings window Electron API */
settingsAPI?: {
/** Get all configuration presets */
getPresets: () => Promise<ConfigPreset[]>
/** Get current preset ID */
getCurrentPresetId: () => Promise<string | null>
/** Get current preset */
getCurrentPreset: () => Promise<ConfigPreset | null>
/** Save (create or update) a preset */
savePreset: (preset: {
id?: string
name: string
config: Record<string, string | undefined>
}) => Promise<ConfigPreset>
/** Delete a preset */
deletePreset: (id: string) => Promise<boolean>
/** Apply a preset (sets environment variables and restarts server) */
applyPreset: (id: string) => Promise<ApplyPresetResult>
/** Close settings window */
close: () => void
}
}
}
export { ConfigPreset, ApplyPresetResult }

View File

@@ -1,241 +0,0 @@
import {
app,
BrowserWindow,
dialog,
Menu,
type MenuItemConstructorOptions,
shell,
} from "electron"
import {
applyPresetToEnv,
getAllPresets,
getCurrentPresetId,
setCurrentPreset,
} from "./config-manager"
import { restartNextServer } from "./next-server"
import { showSettingsWindow } from "./settings-window"
/**
* Build and set the application menu
*/
export function buildAppMenu(): void {
const template = getMenuTemplate()
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
/**
* Rebuild the menu (call this when presets change)
*/
export function rebuildAppMenu(): void {
buildAppMenu()
}
/**
* Get the menu template
*/
function getMenuTemplate(): MenuItemConstructorOptions[] {
const isMac = process.platform === "darwin"
const template: MenuItemConstructorOptions[] = []
// macOS app menu
if (isMac) {
template.push({
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{
label: "Settings...",
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
})
}
// File menu
template.push({
label: "File",
submenu: [
...(isMac
? []
: [
{
label: "Settings",
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
{ type: "separator" } as MenuItemConstructorOptions,
]),
isMac ? { role: "close" } : { role: "quit" },
],
})
// Edit menu
template.push({
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
...(isMac
? [
{
role: "pasteAndMatchStyle",
} as MenuItemConstructorOptions,
{ role: "delete" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions,
]
: [
{ role: "delete" } as MenuItemConstructorOptions,
{ type: "separator" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions,
]),
],
})
// View menu
template.push({
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
})
// Configuration menu with presets
template.push(buildConfigMenu())
// Window menu
template.push({
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
...(isMac
? [
{ type: "separator" } as MenuItemConstructorOptions,
{ role: "front" } as MenuItemConstructorOptions,
]
: [{ role: "close" } as MenuItemConstructorOptions]),
],
})
// Help menu
template.push({
label: "Help",
submenu: [
{
label: "Documentation",
click: async () => {
await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io",
)
},
},
{
label: "Report Issue",
click: async () => {
await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
)
},
},
],
})
return template
}
/**
* Build the Configuration menu with presets
*/
function buildConfigMenu(): MenuItemConstructorOptions {
const presets = getAllPresets()
const currentPresetId = getCurrentPresetId()
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
label: preset.name,
type: "radio",
checked: preset.id === currentPresetId,
click: async () => {
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(preset.id)
if (env) {
try {
await restartNextServer()
rebuildAppMenu() // Rebuild menu to update checkmarks
} catch (error) {
console.error("Failed to restart server:", error)
// Revert to previous preset on failure
if (previousPresetId) {
applyPresetToEnv(previousPresetId)
} else {
setCurrentPreset(null)
}
// Rebuild menu to restore previous checkmark state
rebuildAppMenu()
// Show error dialog to notify user
dialog.showErrorBox(
"Configuration Error",
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
},
}))
return {
label: "Configuration",
submenu: [
...(presetItems.length > 0
? [
{ label: "Switch Preset", enabled: false },
{ type: "separator" } as MenuItemConstructorOptions,
...presetItems,
{ type: "separator" } as MenuItemConstructorOptions,
]
: []),
{
label:
presetItems.length > 0
? "Manage Presets..."
: "Add Configuration Preset...",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
],
}
}

View File

@@ -1,460 +0,0 @@
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import path from "node:path"
import { app, safeStorage } from "electron"
/**
* Fields that contain sensitive data and should be encrypted
*/
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
/**
* Prefix to identify encrypted values
*/
const ENCRYPTED_PREFIX = "encrypted:"
/**
* Check if safeStorage encryption is available
*/
function isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable()
}
/**
* Track if we've already warned about plaintext storage
*/
let hasWarnedAboutPlaintext = false
/**
* Encrypt a sensitive value using safeStorage
* Warns if encryption is not available (API key stored in plaintext)
*/
function encryptValue(value: string): string {
if (!value) {
return value
}
if (!isEncryptionAvailable()) {
if (!hasWarnedAboutPlaintext) {
console.warn(
"⚠️ SECURITY WARNING: safeStorage not available. " +
"API keys will be stored in PLAINTEXT. " +
"On Linux, install gnome-keyring or similar for secure storage.",
)
hasWarnedAboutPlaintext = true
}
return value
}
try {
const encrypted = safeStorage.encryptString(value)
return ENCRYPTED_PREFIX + encrypted.toString("base64")
} catch (error) {
console.error("Encryption failed:", error)
// Fail secure: don't store if encryption fails
throw new Error(
"Failed to encrypt API key. Cannot securely store credentials.",
)
}
}
/**
* Decrypt a sensitive value using safeStorage
* Returns the original value if it's not encrypted or decryption fails
*/
function decryptValue(value: string): string {
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
return value
}
if (!isEncryptionAvailable()) {
console.warn(
"Cannot decrypt value: safeStorage encryption is not available",
)
return value
}
try {
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
const buffer = Buffer.from(base64Data, "base64")
return safeStorage.decryptString(buffer)
} catch (error) {
console.error("Failed to decrypt value:", error)
return value
}
}
/**
* Encrypt sensitive fields in a config object
*/
function encryptConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const encrypted = { ...config }
for (const field of SENSITIVE_FIELDS) {
if (encrypted[field]) {
encrypted[field] = encryptValue(encrypted[field] as string)
}
}
return encrypted
}
/**
* Decrypt sensitive fields in a config object
*/
function decryptConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const decrypted = { ...config }
for (const field of SENSITIVE_FIELDS) {
if (decrypted[field]) {
decrypted[field] = decryptValue(decrypted[field] as string)
}
}
return decrypted
}
/**
* Configuration preset interface
*/
export interface ConfigPreset {
id: string
name: string
createdAt: number
updatedAt: number
config: {
AI_PROVIDER?: string
AI_MODEL?: string
AI_API_KEY?: string
AI_BASE_URL?: string
TEMPERATURE?: string
[key: string]: string | undefined
}
}
/**
* Configuration file structure
*/
interface ConfigPresetsFile {
version: 1
currentPresetId: string | null
presets: ConfigPreset[]
}
const CONFIG_FILE_NAME = "config-presets.json"
/**
* Get the path to the config file
*/
function getConfigFilePath(): string {
const userDataPath = app.getPath("userData")
return path.join(userDataPath, CONFIG_FILE_NAME)
}
/**
* Load presets from the config file
* Decrypts sensitive fields automatically
*/
export function loadPresets(): ConfigPresetsFile {
const configPath = getConfigFilePath()
if (!existsSync(configPath)) {
return {
version: 1,
currentPresetId: null,
presets: [],
}
}
try {
const content = readFileSync(configPath, "utf-8")
const data = JSON.parse(content) as ConfigPresetsFile
// Decrypt sensitive fields in each preset
data.presets = data.presets.map((preset) => ({
...preset,
config: decryptConfig(preset.config) as ConfigPreset["config"],
}))
return data
} catch (error) {
console.error("Failed to load config presets:", error)
return {
version: 1,
currentPresetId: null,
presets: [],
}
}
}
/**
* Save presets to the config file
* Encrypts sensitive fields automatically
*/
export function savePresets(data: ConfigPresetsFile): void {
const configPath = getConfigFilePath()
const userDataPath = app.getPath("userData")
// Ensure the directory exists
if (!existsSync(userDataPath)) {
mkdirSync(userDataPath, { recursive: true })
}
// Encrypt sensitive fields before saving
const dataToSave: ConfigPresetsFile = {
...data,
presets: data.presets.map((preset) => ({
...preset,
config: encryptConfig(preset.config) as ConfigPreset["config"],
})),
}
try {
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
} catch (error) {
console.error("Failed to save config presets:", error)
throw error
}
}
/**
* Get all presets
*/
export function getAllPresets(): ConfigPreset[] {
const data = loadPresets()
return data.presets
}
/**
* Get current preset ID
*/
export function getCurrentPresetId(): string | null {
const data = loadPresets()
return data.currentPresetId
}
/**
* Get current preset
*/
export function getCurrentPreset(): ConfigPreset | null {
const data = loadPresets()
if (!data.currentPresetId) {
return null
}
return data.presets.find((p) => p.id === data.currentPresetId) || null
}
/**
* Create a new preset
*/
export function createPreset(
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
): ConfigPreset {
const data = loadPresets()
const now = Date.now()
const newPreset: ConfigPreset = {
id: randomUUID(),
name: preset.name,
config: preset.config,
createdAt: now,
updatedAt: now,
}
data.presets.push(newPreset)
savePresets(data)
return newPreset
}
/**
* Update an existing preset
*/
export function updatePreset(
id: string,
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
): ConfigPreset | null {
const data = loadPresets()
const index = data.presets.findIndex((p) => p.id === id)
if (index === -1) {
return null
}
const updatedPreset: ConfigPreset = {
...data.presets[index],
...updates,
updatedAt: Date.now(),
}
data.presets[index] = updatedPreset
savePresets(data)
return updatedPreset
}
/**
* Delete a preset
*/
export function deletePreset(id: string): boolean {
const data = loadPresets()
const index = data.presets.findIndex((p) => p.id === id)
if (index === -1) {
return false
}
data.presets.splice(index, 1)
// Clear current preset if it was deleted
if (data.currentPresetId === id) {
data.currentPresetId = null
}
savePresets(data)
return true
}
/**
* Set the current preset
*/
export function setCurrentPreset(id: string | null): boolean {
const data = loadPresets()
if (id !== null) {
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return false
}
}
data.currentPresetId = id
savePresets(data)
return true
}
/**
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
*/
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
google: {
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
baseUrl: "GOOGLE_BASE_URL",
},
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
openrouter: {
apiKey: "OPENROUTER_API_KEY",
baseUrl: "OPENROUTER_BASE_URL",
},
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
siliconflow: {
apiKey: "SILICONFLOW_API_KEY",
baseUrl: "SILICONFLOW_BASE_URL",
},
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
// bedrock and ollama don't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
}
/**
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// Map generic AI_API_KEY to provider-specific key
if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
if (providerApiKey) {
process.env[providerApiKey] = value
appliedEnv[providerApiKey] = value
}
}
// Map generic AI_BASE_URL to provider-specific key
else if (
key === "AI_BASE_URL" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
if (providerBaseUrl) {
process.env[providerBaseUrl] = value
appliedEnv[providerBaseUrl] = value
}
}
// Apply other env vars directly
else {
process.env[key] = value
appliedEnv[key] = value
}
}
}
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
}
/**
* Get environment variables from current preset
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
*/
export function getCurrentPresetEnv(): Record<string, string> {
const preset = getCurrentPreset()
if (!preset) {
return {}
}
const env: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// Map generic AI_API_KEY to provider-specific key
if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
if (providerApiKey) {
env[providerApiKey] = value
}
}
// Map generic AI_BASE_URL to provider-specific key
else if (
key === "AI_BASE_URL" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
if (providerBaseUrl) {
env[providerBaseUrl] = value
}
}
// Apply other env vars directly
else {
env[key] = value
}
}
}
return env
}

View File

@@ -1,67 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import { app } from "electron"
/**
* Load environment variables from .env file
* Searches multiple locations in priority order
*/
export function loadEnvFile(): void {
const possiblePaths = [
// Next to the executable (for portable installations)
path.join(path.dirname(app.getPath("exe")), ".env"),
// User data directory (persists across updates)
path.join(app.getPath("userData"), ".env"),
// Development: project root
path.join(app.getAppPath(), ".env.local"),
path.join(app.getAppPath(), ".env"),
]
for (const envPath of possiblePaths) {
if (fs.existsSync(envPath)) {
console.log(`Loading environment from: ${envPath}`)
loadEnvFromFile(envPath)
return
}
}
console.log("No .env file found, using system environment variables")
}
/**
* Parse and load environment variables from a file
*/
function loadEnvFromFile(filePath: string): void {
try {
const content = fs.readFileSync(filePath, "utf-8")
const lines = content.split("\n")
for (const line of lines) {
const trimmed = line.trim()
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith("#")) continue
const equalIndex = trimmed.indexOf("=")
if (equalIndex === -1) continue
const key = trimmed.slice(0, equalIndex).trim()
let value = trimmed.slice(equalIndex + 1).trim()
// Remove surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
// Don't override existing environment variables
if (!(key in process.env)) {
process.env[key] = value
}
}
} catch (error) {
console.error(`Failed to load env file ${filePath}:`, error)
}
}

View File

@@ -1,105 +0,0 @@
import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu"
import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server"
import { registerSettingsWindowHandlers } from "./settings-window"
import { createWindow, getMainWindow } from "./window-manager"
// Single instance lock
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on("second-instance", () => {
const mainWindow = getMainWindow()
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
// Load environment variables from .env files
loadEnvFile()
// Apply saved preset environment variables (overrides .env)
const presetEnv = getCurrentPresetEnv()
for (const [key, value] of Object.entries(presetEnv)) {
process.env[key] = value
}
const isDev = process.env.NODE_ENV === "development"
let serverUrl: string | null = null
app.whenReady().then(async () => {
// Register IPC handlers
registerIpcHandlers()
registerSettingsWindowHandlers()
// Build application menu
buildAppMenu()
try {
if (isDev) {
// Development: use the dev server URL
serverUrl =
process.env.ELECTRON_DEV_URL || "http://localhost:6002"
console.log(`Development mode: connecting to ${serverUrl}`)
} else {
// Production: start Next.js standalone server
serverUrl = await startNextServer()
}
// Create main window
createWindow(serverUrl)
} catch (error) {
console.error("Failed to start application:", error)
dialog.showErrorBox(
"Startup Error",
`Failed to start the application: ${error instanceof Error ? error.message : "Unknown error"}`,
)
app.quit()
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
if (serverUrl) {
createWindow(serverUrl)
}
}
})
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
stopNextServer()
app.quit()
}
})
app.on("before-quit", () => {
stopNextServer()
})
// Open external links in default browser
app.on("web-contents-created", (_, contents) => {
contents.setWindowOpenHandler(({ url }) => {
// Allow diagrams.net iframe
if (
url.includes("diagrams.net") ||
url.includes("draw.io") ||
url.startsWith("http://localhost")
) {
return { action: "allow" }
}
// Open other links in external browser
if (url.startsWith("http://") || url.startsWith("https://")) {
shell.openExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
})
}

View File

@@ -1,212 +0,0 @@
import { app, BrowserWindow, dialog, ipcMain } from "electron"
import {
applyPresetToEnv,
type ConfigPreset,
createPreset,
deletePreset,
getAllPresets,
getCurrentPreset,
getCurrentPresetId,
setCurrentPreset,
updatePreset,
} from "./config-manager"
import { restartNextServer } from "./next-server"
/**
* Allowed configuration keys for presets
* This whitelist prevents arbitrary environment variable injection
*/
const ALLOWED_CONFIG_KEYS = new Set([
"AI_PROVIDER",
"AI_MODEL",
"AI_API_KEY",
"AI_BASE_URL",
"TEMPERATURE",
])
/**
* Sanitize preset config to only include allowed keys
*/
function sanitizePresetConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const sanitized: Record<string, string | undefined> = {}
for (const key of ALLOWED_CONFIG_KEYS) {
if (key in config && typeof config[key] === "string") {
sanitized[key] = config[key]
}
}
return sanitized
}
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
// ==================== App Info ====================
ipcMain.handle("get-version", () => {
return app.getVersion()
})
// ==================== Window Controls ====================
ipcMain.on("window-minimize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.minimize()
})
ipcMain.on("window-maximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win?.isMaximized()) {
win.unmaximize()
} else {
win?.maximize()
}
})
ipcMain.on("window-close", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.close()
})
// ==================== File Dialogs ====================
ipcMain.handle("dialog-open-file", async (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ["openFile"],
filters: [
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
{ name: "All Files", extensions: ["*"] },
],
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
// Read the file content
const fs = await import("node:fs/promises")
try {
const content = await fs.readFile(result.filePaths[0], "utf-8")
return content
} catch (error) {
console.error("Failed to read file:", error)
return null
}
})
ipcMain.handle("dialog-save-file", async (event, data: string) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return false
const result = await dialog.showSaveDialog(win, {
filters: [
{ name: "Draw.io Files", extensions: ["drawio"] },
{ name: "XML Files", extensions: ["xml"] },
],
})
if (result.canceled || !result.filePath) {
return false
}
const fs = await import("node:fs/promises")
try {
await fs.writeFile(result.filePath, data, "utf-8")
return true
} catch (error) {
console.error("Failed to save file:", error)
return false
}
})
// ==================== Config Presets ====================
ipcMain.handle("config-presets:get-all", () => {
return getAllPresets()
})
ipcMain.handle("config-presets:get-current", () => {
return getCurrentPreset()
})
ipcMain.handle("config-presets:get-current-id", () => {
return getCurrentPresetId()
})
ipcMain.handle(
"config-presets:save",
(
_event,
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
id?: string
},
) => {
// Validate preset name
if (typeof preset.name !== "string" || !preset.name.trim()) {
throw new Error("Invalid preset name")
}
// Sanitize config to only allow whitelisted keys
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
if (preset.id) {
// Update existing preset
return updatePreset(preset.id, {
name: preset.name.trim(),
config: sanitizedConfig,
})
}
// Create new preset
return createPreset({
name: preset.name.trim(),
config: sanitizedConfig,
})
},
)
ipcMain.handle("config-presets:delete", (_event, id: string) => {
return deletePreset(id)
})
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
const env = applyPresetToEnv(id)
if (!env) {
return { success: false, error: "Preset not found" }
}
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development mode, the config file change will trigger
// the file watcher in electron-dev.mjs to restart Next.js
// We just need to save the preset (already done in applyPresetToEnv)
return { success: true, env, devMode: true }
}
// Production mode: restart the Next.js server to apply new environment variables
try {
await restartNextServer()
return { success: true, env }
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to restart server",
}
}
})
ipcMain.handle(
"config-presets:set-current",
(_event, id: string | null) => {
return setCurrentPreset(id)
},
)
}

View File

@@ -1,161 +0,0 @@
import { existsSync } from "node:fs"
import path from "node:path"
import { app, type UtilityProcess, utilityProcess } from "electron"
import {
findAvailablePort,
getAllocatedPort,
getServerUrl,
isPortAvailable,
} from "./port-manager"
let serverProcess: UtilityProcess | null = null
/**
* Get the path to the standalone server resources
* In packaged app: resources/standalone
* In development: .next/standalone
*/
function getResourcePath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "standalone")
}
return path.join(app.getAppPath(), ".next", "standalone")
}
/**
* Wait for the server to be ready by polling the health endpoint
*/
async function waitForServer(url: string, timeout = 30000): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeout) {
try {
const response = await fetch(url)
if (response.ok || response.status < 500) {
return
}
} catch {
// Server not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`Server startup timeout after ${timeout}ms`)
}
/**
* Start the Next.js standalone server using Electron's utilityProcess
* This API is designed for running Node.js code in the background
*/
export async function startNextServer(): Promise<string> {
const resourcePath = getResourcePath()
const serverPath = path.join(resourcePath, "server.js")
console.log(`Starting Next.js server from: ${resourcePath}`)
console.log(`Server script path: ${serverPath}`)
// Verify server script exists before attempting to start
if (!existsSync(serverPath)) {
throw new Error(
`Server script not found at ${serverPath}. ` +
"Please ensure the app was built correctly with 'npm run build'.",
)
}
// Find an available port (random in production, fixed in development)
const port = await findAvailablePort()
console.log(`Using port: ${port}`)
// Set up environment variables
const env: Record<string, string> = {
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "localhost",
}
// Set cache directory to a writable location (user's app data folder)
// This is necessary because the packaged app might be on a read-only volume
if (app.isPackaged) {
const cacheDir = path.join(app.getPath("userData"), "cache")
env.NEXT_CACHE_DIR = cacheDir
}
// Copy existing environment variables
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !env[key]) {
env[key] = value
}
}
// Use Electron's utilityProcess API for running Node.js in background
// This is the recommended way to run Node.js code in Electron
serverProcess = utilityProcess.fork(serverPath, [], {
cwd: resourcePath,
env,
stdio: "pipe",
})
serverProcess.stdout?.on("data", (data) => {
console.log(`[Next.js] ${data.toString().trim()}`)
})
serverProcess.stderr?.on("data", (data) => {
console.error(`[Next.js Error] ${data.toString().trim()}`)
})
serverProcess.on("exit", (code) => {
console.log(`Next.js server exited with code ${code}`)
serverProcess = null
})
const url = getServerUrl()
await waitForServer(url)
console.log(`Next.js server started at ${url}`)
return url
}
/**
* Stop the Next.js server process
*/
export function stopNextServer(): void {
if (serverProcess) {
console.log("Stopping Next.js server...")
serverProcess.kill()
serverProcess = null
}
}
/**
* Wait for the server to fully stop
*/
async function waitForServerStop(timeout = 5000): Promise<void> {
const port = getAllocatedPort()
if (port === null) {
return
}
const start = Date.now()
while (Date.now() - start < timeout) {
const available = await isPortAvailable(port)
if (available) {
return
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
console.warn("Server stop timeout, port may still be in use")
}
/**
* Restart the Next.js server with new environment variables
*/
export async function restartNextServer(): Promise<string> {
console.log("Restarting Next.js server...")
// Stop the current server
stopNextServer()
// Wait for the port to be released
await waitForServerStop()
// Start the server again
return startNextServer()
}

View File

@@ -1,129 +0,0 @@
import net from "node:net"
import { app } from "electron"
/**
* Port configuration
*/
const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility
development: 6002,
// Production mode port range (will find first available)
production: {
min: 10000,
max: 65535,
},
// Maximum attempts to find an available port
maxAttempts: 100,
}
/**
* Currently allocated port (cached after first allocation)
*/
let allocatedPort: number | null = null
/**
* Check if a specific port is available
*/
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once("error", () => resolve(false))
server.once("listening", () => {
server.close()
resolve(true)
})
server.listen(port, "127.0.0.1")
})
}
/**
* Generate a random port within the production range
*/
function getRandomPort(): number {
const { min, max } = PORT_CONFIG.production
return Math.floor(Math.random() * (max - min + 1)) + min
}
/**
* Find an available port
* - In development: uses fixed port (6002)
* - In production: finds a random available port
* - If a port was previously allocated, verifies it's still available
*
* @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port
* @throws Error if no available port found after max attempts
*/
export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged
// Try to reuse cached port if requested and available
if (reuseExisting && allocatedPort !== null) {
const available = await isPortAvailable(allocatedPort)
if (available) {
return allocatedPort
}
console.warn(
`Previously allocated port ${allocatedPort} is no longer available`,
)
allocatedPort = null
}
if (isDev) {
// Development mode: use fixed port
const port = PORT_CONFIG.development
const available = await isPortAvailable(port)
if (available) {
allocatedPort = port
return port
}
console.warn(
`Development port ${port} is in use, finding alternative...`,
)
}
// Production mode or dev port unavailable: find random available port
for (let attempt = 0; attempt < PORT_CONFIG.maxAttempts; attempt++) {
const port = isDev
? PORT_CONFIG.development + attempt + 1
: getRandomPort()
const available = await isPortAvailable(port)
if (available) {
allocatedPort = port
console.log(`Allocated port: ${port}`)
return port
}
}
throw new Error(
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
)
}
/**
* Get the currently allocated port
* Returns null if no port has been allocated yet
*/
export function getAllocatedPort(): number | null {
return allocatedPort
}
/**
* Reset the allocated port (useful for testing or restart scenarios)
*/
export function resetAllocatedPort(): void {
allocatedPort = null
}
/**
* Get the server URL with the allocated port
*/
export function getServerUrl(): string {
if (allocatedPort === null) {
throw new Error(
"No port allocated yet. Call findAvailablePort() first.",
)
}
return `http://localhost:${allocatedPort}`
}

View File

@@ -1,78 +0,0 @@
import path from "node:path"
import { app, BrowserWindow, ipcMain } from "electron"
let settingsWindow: BrowserWindow | null = null
/**
* Create and show the settings window
*/
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
// If settings window already exists, focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus()
return
}
// Determine path to settings preload script
// In compiled output: dist-electron/preload/settings.js
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
// Determine path to settings HTML
// In packaged app: app.asar/dist-electron/settings/index.html
// In development: electron/settings/index.html
const settingsHtmlPath = app.isPackaged
? path.join(__dirname, "..", "settings", "index.html")
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
settingsWindow = new BrowserWindow({
width: 600,
height: 700,
minWidth: 500,
minHeight: 500,
parent: parentWindow,
modal: false,
show: false,
title: "Settings - Next AI Draw.io",
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
settingsWindow.loadFile(settingsHtmlPath)
settingsWindow.once("ready-to-show", () => {
settingsWindow?.show()
})
settingsWindow.on("closed", () => {
settingsWindow = null
})
}
/**
* Close the settings window if it exists
*/
export function closeSettingsWindow(): void {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.close()
settingsWindow = null
}
}
/**
* Check if settings window is open
*/
export function isSettingsWindowOpen(): boolean {
return settingsWindow !== null && !settingsWindow.isDestroyed()
}
/**
* Register settings window IPC handlers
*/
export function registerSettingsWindowHandlers(): void {
ipcMain.on("settings:close", () => {
closeSettingsWindow()
})
}

View File

@@ -1,84 +0,0 @@
import path from "node:path"
import { app, BrowserWindow, screen } from "electron"
let mainWindow: BrowserWindow | null = null
/**
* Get the icon path based on platform
* Note: electron-builder converts icon.png during packaging,
* but at runtime we use PNG directly - Electron handles it
*/
function getIconPath(): string | undefined {
// macOS doesn't need explicit icon - it's embedded in the app bundle
if (process.platform === "darwin" && app.isPackaged) {
return undefined
}
const iconName = "icon.png"
if (app.isPackaged) {
return path.join(process.resourcesPath, iconName)
}
// Development: use icon.png from resources
return path.join(__dirname, "../../resources/icon.png")
}
/**
* Create the main application window
*/
export function createWindow(serverUrl: string): BrowserWindow {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
mainWindow = new BrowserWindow({
width: Math.min(1400, Math.floor(width * 0.9)),
height: Math.min(900, Math.floor(height * 0.9)),
minWidth: 800,
minHeight: 600,
title: "Next AI Draw.io",
icon: getIconPath(),
show: false, // Don't show until ready
webPreferences: {
preload: path.join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
})
// Load the Next.js application
mainWindow.loadURL(serverUrl)
// Show window when ready to prevent flashing
mainWindow.once("ready-to-show", () => {
mainWindow?.show()
})
// Open DevTools in development
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools()
}
mainWindow.on("closed", () => {
mainWindow = null
})
// Handle page title updates
mainWindow.webContents.on("page-title-updated", (event, title) => {
if (title && !title.includes("localhost")) {
mainWindow?.setTitle(title)
} else {
event.preventDefault()
}
})
return mainWindow
}
/**
* Get the main window instance
*/
export function getMainWindow(): BrowserWindow | null {
return mainWindow
}

View File

@@ -1,24 +0,0 @@
import { contextBridge, ipcRenderer } from "electron"
/**
* Expose safe APIs to the renderer process
*/
contextBridge.exposeInMainWorld("electronAPI", {
// Platform information
platform: process.platform,
// Check if running in Electron
isElectron: true,
// Application version
getVersion: () => ipcRenderer.invoke("get-version"),
// Window controls (optional, for custom title bar)
minimize: () => ipcRenderer.send("window-minimize"),
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
// File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"),
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
})

View File

@@ -1,35 +0,0 @@
/**
* Preload script for settings window
* Exposes APIs for managing configuration presets
*/
import { contextBridge, ipcRenderer } from "electron"
// Expose settings API to the renderer process
contextBridge.exposeInMainWorld("settingsAPI", {
// Get all presets
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
// Get current preset ID
getCurrentPresetId: () =>
ipcRenderer.invoke("config-presets:get-current-id"),
// Get current preset
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
// Save (create or update) a preset
savePreset: (preset: {
id?: string
name: string
config: Record<string, string | undefined>
}) => ipcRenderer.invoke("config-presets:save", preset),
// Delete a preset
deletePreset: (id: string) =>
ipcRenderer.invoke("config-presets:delete", id),
// Apply a preset (sets environment variables and restarts server)
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
// Close settings window
close: () => ipcRenderer.send("settings:close"),
})

View File

@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
<title>Settings - Next AI Draw.io</title>
<link rel="stylesheet" href="./settings.css">
</head>
<body>
<div class="container">
<h1>Configuration Presets</h1>
<div class="section">
<h2>Presets</h2>
<div id="preset-list" class="preset-list">
<!-- Presets will be loaded here -->
</div>
<button id="add-preset-btn" class="btn btn-primary">
+ Add New Preset
</button>
</div>
</div>
<!-- Add/Edit Preset Modal -->
<div id="preset-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Add Preset</h3>
</div>
<div class="modal-body">
<form id="preset-form">
<input type="hidden" id="preset-id">
<div class="form-group">
<label for="preset-name">Preset Name *</label>
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
</div>
<div class="form-group">
<label for="ai-provider">AI Provider</label>
<select id="ai-provider">
<option value="">-- Select Provider --</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="google">Google AI (Gemini)</option>
<option value="azure">Azure OpenAI</option>
<option value="bedrock">AWS Bedrock</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option>
<option value="ollama">Ollama (Local)</option>
</select>
</div>
<div class="form-group">
<label for="ai-model">Model ID</label>
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
<div class="hint">The model identifier to use with the selected provider</div>
</div>
<div class="form-group">
<label for="ai-api-key">API Key</label>
<input type="password" id="ai-api-key" placeholder="Your API key">
<div class="hint">This will be stored locally on your device</div>
</div>
<div class="form-group">
<label for="ai-base-url">Base URL (Optional)</label>
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
<div class="hint">Custom API endpoint URL</div>
</div>
<div class="form-group">
<label for="temperature">Temperature (Optional)</label>
<input type="text" id="temperature" placeholder="0.7">
<div class="hint">Controls randomness (0.0 - 2.0)</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>Delete Preset</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
<p class="delete-warning">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
<!-- Toast notification -->
<div id="toast" class="toast"></div>
<script src="./settings.js"></script>
</body>
</html>

View File

@@ -1,311 +0,0 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #e8e8e8;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--border-color: #e0e0e0;
--accent-color: #0066cc;
--accent-hover: #0052a3;
--danger-color: #dc3545;
--success-color: #28a745;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-hover: #3d3d3d;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #404040;
--accent-color: #4da6ff;
--accent-hover: #66b3ff;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
}
.container {
max-width: 560px;
margin: 0 auto;
padding: 24px;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
.section {
margin-bottom: 32px;
}
.preset-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.preset-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-card:hover {
background: var(--bg-hover);
}
.preset-card.active {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.preset-name {
font-weight: 600;
font-size: 15px;
}
.preset-badge {
background: var(--accent-color);
color: white;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
.preset-info {
font-size: 13px;
color: var(--text-secondary);
}
.preset-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin-bottom: 16px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--bg-primary);
border-radius: 12px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
}
.form-group .hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--text-primary);
color: var(--bg-primary);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 200;
opacity: 0;
transition: opacity 0.3s ease;
}
.toast.show {
opacity: 1;
}
.toast.success {
background: var(--success-color);
color: white;
}
.toast.error {
background: var(--danger-color);
color: white;
}
/* Inline style replacements */
.delete-warning {
color: var(--text-secondary);
margin-top: 8px;
font-size: 14px;
}

View File

@@ -1,311 +0,0 @@
// Settings page JavaScript
// This file handles the UI interactions for the settings window
let presets = []
let currentPresetId = null
let editingPresetId = null
let deletingPresetId = null
// DOM Elements
const presetList = document.getElementById("preset-list")
const addPresetBtn = document.getElementById("add-preset-btn")
const presetModal = document.getElementById("preset-modal")
const deleteModal = document.getElementById("delete-modal")
const presetForm = document.getElementById("preset-form")
const modalTitle = document.getElementById("modal-title")
const toast = document.getElementById("toast")
// Form fields
const presetIdField = document.getElementById("preset-id")
const presetNameField = document.getElementById("preset-name")
const aiProviderField = document.getElementById("ai-provider")
const aiModelField = document.getElementById("ai-model")
const aiApiKeyField = document.getElementById("ai-api-key")
const aiBaseUrlField = document.getElementById("ai-base-url")
const temperatureField = document.getElementById("temperature")
// Buttons
const cancelBtn = document.getElementById("cancel-btn")
const saveBtn = document.getElementById("save-btn")
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
// Initialize
document.addEventListener("DOMContentLoaded", async () => {
await loadPresets()
setupEventListeners()
})
// Load presets from main process
async function loadPresets() {
try {
presets = await window.settingsAPI.getPresets()
currentPresetId = await window.settingsAPI.getCurrentPresetId()
renderPresets()
} catch (error) {
console.error("Failed to load presets:", error)
showToast("Failed to load presets", "error")
}
}
// Render presets list
function renderPresets() {
if (presets.length === 0) {
presetList.innerHTML = `
<div class="empty-state">
<p>No presets configured yet.</p>
<p>Add a preset to quickly switch between different AI configurations.</p>
</div>
`
return
}
presetList.innerHTML = presets
.map((preset) => {
const isActive = preset.id === currentPresetId
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
return `
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
<div class="preset-header">
<span class="preset-name">${escapeHtml(preset.name)}</span>
${isActive ? '<span class="preset-badge">Active</span>' : ""}
</div>
<div class="preset-info">
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
</div>
<div class="preset-actions">
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
</div>
</div>
`
})
.join("")
// Add event listeners to buttons
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
applyPreset(btn.dataset.id)
})
})
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openEditModal(btn.dataset.id)
})
})
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openDeleteModal(btn.dataset.id)
})
})
}
// Setup event listeners
function setupEventListeners() {
addPresetBtn.addEventListener("click", () => openAddModal())
cancelBtn.addEventListener("click", () => closeModal())
saveBtn.addEventListener("click", () => savePreset())
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
// Close modal on overlay click
presetModal.addEventListener("click", (e) => {
if (e.target === presetModal) closeModal()
})
deleteModal.addEventListener("click", (e) => {
if (e.target === deleteModal) closeDeleteModal()
})
// Handle Enter key in form
presetForm.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault()
savePreset()
}
})
}
// Open add modal
function openAddModal() {
editingPresetId = null
modalTitle.textContent = "Add Preset"
presetForm.reset()
presetIdField.value = ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Open edit modal
function openEditModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
editingPresetId = id
modalTitle.textContent = "Edit Preset"
presetIdField.value = preset.id
presetNameField.value = preset.name
aiProviderField.value = preset.config.AI_PROVIDER || ""
aiModelField.value = preset.config.AI_MODEL || ""
aiApiKeyField.value = preset.config.AI_API_KEY || ""
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
temperatureField.value = preset.config.TEMPERATURE || ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Close modal
function closeModal() {
presetModal.classList.remove("show")
editingPresetId = null
}
// Open delete modal
function openDeleteModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
deletingPresetId = id
document.getElementById("delete-preset-name").textContent = preset.name
deleteModal.classList.add("show")
}
// Close delete modal
function closeDeleteModal() {
deleteModal.classList.remove("show")
deletingPresetId = null
}
// Save preset
async function savePreset() {
const name = presetNameField.value.trim()
if (!name) {
showToast("Please enter a preset name", "error")
presetNameField.focus()
return
}
const preset = {
id: editingPresetId || undefined,
name: name,
config: {
AI_PROVIDER: aiProviderField.value || undefined,
AI_MODEL: aiModelField.value.trim() || undefined,
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
TEMPERATURE: temperatureField.value.trim() || undefined,
},
}
// Remove undefined values
Object.keys(preset.config).forEach((key) => {
if (preset.config[key] === undefined) {
delete preset.config[key]
}
})
try {
saveBtn.disabled = true
saveBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.savePreset(preset)
await loadPresets()
closeModal()
showToast(
editingPresetId ? "Preset updated" : "Preset created",
"success",
)
} catch (error) {
console.error("Failed to save preset:", error)
showToast("Failed to save preset", "error")
} finally {
saveBtn.disabled = false
saveBtn.textContent = "Save"
}
}
// Confirm delete
async function confirmDelete() {
if (!deletingPresetId) return
try {
deleteConfirmBtn.disabled = true
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.deletePreset(deletingPresetId)
await loadPresets()
closeDeleteModal()
showToast("Preset deleted", "success")
} catch (error) {
console.error("Failed to delete preset:", error)
showToast("Failed to delete preset", "error")
} finally {
deleteConfirmBtn.disabled = false
deleteConfirmBtn.textContent = "Delete"
}
}
// Apply preset
async function applyPreset(id) {
try {
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
if (btn) {
btn.disabled = true
btn.innerHTML = '<span class="loading"></span>'
}
const result = await window.settingsAPI.applyPreset(id)
if (result.success) {
currentPresetId = id
renderPresets()
showToast("Preset applied, server restarting...", "success")
} else {
showToast(result.error || "Failed to apply preset", "error")
}
} catch (error) {
console.error("Failed to apply preset:", error)
showToast("Failed to apply preset", "error")
}
}
// Get provider display label
function getProviderLabel(provider) {
const labels = {
openai: "OpenAI",
anthropic: "Anthropic",
google: "Google AI",
azure: "Azure OpenAI",
bedrock: "AWS Bedrock",
openrouter: "OpenRouter",
deepseek: "DeepSeek",
siliconflow: "SiliconFlow",
ollama: "Ollama",
}
return labels[provider] || provider
}
// Show toast notification
function showToast(message, type = "") {
toast.textContent = message
toast.className = "toast show" + (type ? ` ${type}` : "")
setTimeout(() => {
toast.classList.remove("show")
}, 3000)
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}

View File

@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "../dist-electron",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["./**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration
# 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
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)
# 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_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_SUMMARY=detailed
@@ -68,17 +64,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# SGLang Configuration (OpenAI-compatible)
# SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# 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=...
# AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai # Optional: Custom Gateway URL (for local dev or self-hosted Gateway)
# # If not set, uses Vercel default: https://ai-gateway.vercel.sh/v1/ai
# Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-...
@@ -97,14 +82,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
# Subdirectory Deployment (Optional)
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)
# Leave empty for root deployment (default)
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
# PDF Input Feature (Optional)
# Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable.
# ENABLE_PDF_INPUT=true
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)

View File

@@ -1,29 +0,0 @@
"use client"
import React, { createContext, useContext } from "react"
import type { Dictionary } from "@/lib/i18n/dictionaries"
const DictionaryContext = createContext<Dictionary | null>(null)
export function DictionaryProvider({
children,
dictionary,
}: React.PropsWithChildren<{ dictionary: Dictionary }>) {
return React.createElement(
DictionaryContext.Provider,
{ value: dictionary },
children,
)
}
export function useDictionary() {
const dict = useContext(DictionaryContext)
if (!dict) {
throw new Error(
"useDictionary must be used within a DictionaryProvider",
)
}
return dict
}
export default useDictionary

View File

@@ -1,373 +0,0 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { STORAGE_KEYS } from "@/lib/storage"
import {
createEmptyConfig,
createModelConfig,
createProviderConfig,
type FlattenedModel,
findModelById,
flattenModels,
type ModelConfig,
type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"
// Old storage keys for migration
const OLD_KEYS = {
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
}
/**
* Migrate from old single-provider format to new multi-model format
*/
function migrateOldConfig(): MultiModelConfig | null {
if (typeof window === "undefined") return null
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
// No old config to migrate
if (!oldProvider || !oldApiKey || !oldModel) return null
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
// Create new config from old format
const provider = createProviderConfig(oldProvider as ProviderName)
provider.apiKey = oldApiKey
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
const model = createModelConfig(oldModel)
provider.models.push(model)
const config: MultiModelConfig = {
version: 1,
providers: [provider],
selectedModelId: model.id,
}
// Clear old keys after migration
localStorage.removeItem(OLD_KEYS.aiProvider)
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
localStorage.removeItem(OLD_KEYS.aiApiKey)
localStorage.removeItem(OLD_KEYS.aiModel)
return config
}
/**
* Load config from localStorage
*/
function loadConfig(): MultiModelConfig {
if (typeof window === "undefined") return createEmptyConfig()
// First, check if new format exists
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (stored) {
try {
return JSON.parse(stored) as MultiModelConfig
} catch {
console.error("Failed to parse model config")
}
}
// Try migration from old format
const migrated = migrateOldConfig()
if (migrated) {
// Save migrated config
localStorage.setItem(
STORAGE_KEYS.modelConfigs,
JSON.stringify(migrated),
)
return migrated
}
return createEmptyConfig()
}
/**
* Save config to localStorage
*/
function saveConfig(config: MultiModelConfig): void {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
}
export interface UseModelConfigReturn {
// State
config: MultiModelConfig
isLoaded: boolean
// Getters
models: FlattenedModel[]
selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined
// Actions
setSelectedModelId: (modelId: string | undefined) => void
addProvider: (provider: ProviderName) => ProviderConfig
updateProvider: (
providerId: string,
updates: Partial<ProviderConfig>,
) => void
deleteProvider: (providerId: string) => void
addModel: (providerId: string, modelId: string) => ModelConfig
updateModel: (
providerId: string,
modelConfigId: string,
updates: Partial<ModelConfig>,
) => void
deleteModel: (providerId: string, modelConfigId: string) => void
resetConfig: () => void
}
export function useModelConfig(): UseModelConfigReturn {
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
const [isLoaded, setIsLoaded] = useState(false)
// Load config on mount
useEffect(() => {
const loaded = loadConfig()
setConfig(loaded)
setIsLoaded(true)
}, [])
// Save config whenever it changes (after initial load)
useEffect(() => {
if (isLoaded) {
saveConfig(config)
}
}, [config, isLoaded])
// Derived state
const models = flattenModels(config)
const selectedModel = config.selectedModelId
? findModelById(config, config.selectedModelId)
: undefined
// Actions
const setSelectedModelId = useCallback((modelId: string | undefined) => {
setConfig((prev) => ({
...prev,
selectedModelId: modelId,
}))
}, [])
const addProvider = useCallback(
(provider: ProviderName): ProviderConfig => {
const newProvider = createProviderConfig(provider)
setConfig((prev) => ({
...prev,
providers: [...prev.providers, newProvider],
}))
return newProvider
},
[],
)
const updateProvider = useCallback(
(providerId: string, updates: Partial<ProviderConfig>) => {
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId ? { ...p, ...updates } : p,
),
}))
},
[],
)
const deleteProvider = useCallback((providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
// Clear selected model if it belongs to deleted provider
const newSelectedId =
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
? undefined
: prev.selectedModelId
return {
...prev,
providers: prev.providers.filter((p) => p.id !== providerId),
selectedModelId: newSelectedId,
}
})
}, [])
const addModel = useCallback(
(providerId: string, modelId: string): ModelConfig => {
const newModel = createModelConfig(modelId)
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId
? { ...p, models: [...p.models, newModel] }
: p,
),
}))
return newModel
},
[],
)
const updateModel = useCallback(
(
providerId: string,
modelConfigId: string,
updates: Partial<ModelConfig>,
) => {
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId
? {
...p,
models: p.models.map((m) =>
m.id === modelConfigId
? { ...m, ...updates }
: m,
),
}
: p,
),
}))
},
[],
)
const deleteModel = useCallback(
(providerId: string, modelConfigId: string) => {
setConfig((prev) => ({
...prev,
providers: prev.providers.map((p) =>
p.id === providerId
? {
...p,
models: p.models.filter(
(m) => m.id !== modelConfigId,
),
}
: p,
),
// Clear selected model if it was deleted
selectedModelId:
prev.selectedModelId === modelConfigId
? undefined
: prev.selectedModelId,
}))
},
[],
)
const resetConfig = useCallback(() => {
setConfig(createEmptyConfig())
}, [])
return {
config,
isLoaded,
models,
selectedModel,
selectedModelId: config.selectedModelId,
setSelectedModelId,
addProvider,
updateProvider,
deleteProvider,
addModel,
updateModel,
deleteModel,
resetConfig,
}
}
/**
* Get the AI config for the currently selected model.
* Returns format compatible with existing getAIConfig() usage.
*/
export function getSelectedAIConfig(): {
accessCode: string
aiProvider: string
aiBaseUrl: string
aiApiKey: string
aiModel: string
// AWS Bedrock credentials
awsAccessKeyId: string
awsSecretAccessKey: string
awsRegion: string
awsSessionToken: string
} {
const empty = {
accessCode: "",
aiProvider: "",
aiBaseUrl: "",
aiApiKey: "",
aiModel: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
}
if (typeof window === "undefined") return empty
// Get access code (separate from model config)
const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || ""
// Load multi-model config
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (!stored) {
// Fallback to old format for backward compatibility
return {
accessCode,
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
// Old format didn't support AWS
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
}
}
let config: MultiModelConfig
try {
config = JSON.parse(stored)
} catch {
return { ...empty, accessCode }
}
// No selected model = use server default
if (!config.selectedModelId) {
return { ...empty, accessCode }
}
// Find selected model
const model = findModelById(config, config.selectedModelId)
if (!model) {
return { ...empty, accessCode }
}
return {
accessCode,
aiProvider: model.provider,
aiBaseUrl: model.baseUrl || "",
aiApiKey: model.apiKey,
aiModel: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: model.awsAccessKeyId || "",
awsSecretAccessKey: model.awsSecretAccessKey || "",
awsRegion: model.awsRegion || "",
awsSessionToken: model.awsSessionToken || "",
}
}

View File

@@ -2,7 +2,6 @@ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
@@ -19,8 +18,6 @@ export type ProviderName =
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
interface ModelConfig {
model: any
@@ -34,11 +31,6 @@ export interface ClientOverrides {
baseUrl?: string | null
apiKey?: string | null
modelId?: string | null
// AWS Bedrock credentials
awsAccessKeyId?: string | null
awsSecretAccessKey?: string | null
awsRegion?: string | null
awsSessionToken?: string | null
}
// Providers that can be used with client-provided API keys
@@ -47,12 +39,9 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"anthropic",
"google",
"azure",
"bedrock",
"openrouter",
"deepseek",
"siliconflow",
"sglang",
"gateway",
]
// Bedrock provider options for Anthropic beta features
@@ -344,11 +333,8 @@ function buildProviderOptions(
case "deepseek":
case "openrouter":
case "siliconflow":
case "sglang":
case "gateway": {
case "siliconflow": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break
}
@@ -370,8 +356,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
}
/**
@@ -387,16 +371,7 @@ function detectProvider(): ProviderName | null {
continue
}
if (process.env[envVar]) {
// Azure requires additional config (baseURL or resourceName)
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)
}
configuredProviders.push(provider as ProviderName)
}
}
@@ -418,25 +393,13 @@ function validateProviderCredentials(provider: ProviderName): void {
`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.`,
)
}
}
}
/**
* Get the AI model based on environment variables
*
* Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
* - AI_MODEL: The model ID/name for the selected provider
*
* Provider-specific env vars:
@@ -452,20 +415,8 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
* - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional)
*/
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
const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
@@ -513,7 +464,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
if (configured.length === 0) {
throw new Error(
`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` +
`- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` +
@@ -522,7 +472,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`,
)
} else {
@@ -550,25 +499,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
switch (provider) {
case "bedrock": {
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
const hasClientCredentials =
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
const bedrockRegion =
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
const bedrockProvider = hasClientCredentials
? createAmazonBedrock({
region: bedrockRegion,
accessKeyId: overrides.awsAccessKeyId!,
secretAccessKey: overrides.awsSecretAccessKey!,
...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken,
}),
})
: createAmazonBedrock({
region: bedrockRegion,
credentialProvider: fromNodeProviderChain(),
})
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
const bedrockProvider = createAmazonBedrock({
region: process.env.AWS_REGION || "us-west-2",
credentialProvider: fromNodeProviderChain(),
})
model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) {
@@ -636,15 +572,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "azure": {
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
const resourceName = process.env.AZURE_RESOURCE_NAME
// 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) {
if (baseURL || overrides?.apiKey) {
const customAzure = createAzure({
apiKey,
// baseURL takes precedence over resourceName per SDK behavior
...(baseURL && { baseURL }),
...(!baseURL && resourceName && { resourceName }),
})
model = customAzure(modelId)
} else {
@@ -705,136 +636,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
const sglangProvider = createOpenAI({
apiKey,
baseURL,
// Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => {
const response = await fetch(url, options)
if (!response.body) {
return response
}
// Create a transform stream to fix the non-compliant sglang stream
let buffer = ""
const decoder = new TextDecoder()
const transformStream = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true })
// Process all complete messages in the buffer
let messageEndPos
while (
(messageEndPos = buffer.indexOf("\n\n")) !== -1
) {
const message = buffer.substring(
0,
messageEndPos,
)
buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n'
if (message.startsWith("data: ")) {
const jsonStr = message.substring(6).trim()
if (jsonStr === "[DONE]") {
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
continue
}
try {
const data = JSON.parse(jsonStr)
const delta = data.choices?.[0]?.delta
if (delta) {
// Fix 1: remove invalid empty role
if (delta.role === "") {
delete delta.role
}
// Fix 2: remove non-standard reasoning_content field
if ("reasoning_content" in delta) {
delete delta.reasoning_content
}
}
// Re-serialize and forward the corrected data with the correct SSE format
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(data)}\n\n`,
),
)
} catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
} else if (message.trim() !== "") {
// Pass through other message types (e.g., 'event: ...')
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
}
},
flush(controller) {
// If there's anything left in the buffer, forward it.
if (buffer.trim()) {
controller.enqueue(
new TextEncoder().encode(buffer),
)
}
},
})
const transformedBody =
response.body.pipeThrough(transformStream)
// Return a new response with the transformed body
return new Response(transformedBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
},
})
model = sglangProvider.chat(modelId)
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
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const baseURL =
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
// Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
if (baseURL || overrides?.apiKey) {
const customGateway = createGateway({
apiKey,
...(baseURL && { baseURL }),
})
model = customGateway(modelId)
} else {
model = gateway(modelId)
}
break
}
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow`,
)
}

View File

@@ -1,37 +0,0 @@
/**
* Get the base path for API calls and static assets
* This is used for subdirectory deployment support
*
* Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio"
* For root deployment, this returns ""
*
* Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
*/
export function getBasePath(): string {
// Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""
if (basePath && !basePath.startsWith("/")) {
console.warn("NEXT_PUBLIC_BASE_PATH should start with /")
}
return basePath
}
/**
* Get full API endpoint URL
* @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config")
* @returns Full API path with base path prefix
*/
export function getApiEndpoint(endpoint: string): string {
const basePath = getBasePath()
return `${basePath}${endpoint}`
}
/**
* Get full static asset URL
* @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt")
* @returns Full asset path with base path prefix
*/
export function getAssetUrl(assetPath: string): string {
const basePath = getBasePath()
return `${basePath}${assetPath}`
}

View File

@@ -9,7 +9,12 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
promptText:
"Give me a **animated connector** diagram of transformer's architecture",
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"/>
</mxCell>
@@ -249,12 +254,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxCell id="output_label" value="Outputs&#xa;(shifted right)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="660" y="530" width="100" height="30" as="geometry"/>
</mxCell>`,
</mxCell>
</root>`,
},
{
promptText: "Replicate this in aws style",
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"/>
</mxCell>
@@ -313,12 +324,18 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxPoint x="700" y="350" as="sourcePoint"/>
<mxPoint x="750" y="300" as="targetPoint"/>
</mxGeometry>
</mxCell>`,
</mxCell>
</root>`,
},
{
promptText: "Replicate this flowchart.",
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"/>
</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">
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
</mxCell>`,
</mxCell>
</root>`,
},
{
promptText: "Summarize this paper as a diagram",
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;"
value="" vertex="1">
<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."
vertex="1">
<mxGeometry height="55" width="230" x="530" y="600" as="geometry" />
</mxCell>`,
</mxCell>
</root>`,
},
{
promptText: "Draw a cat for me",
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"/>
</mxCell>
@@ -875,7 +902,9 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxPoint x="235" y="290"/>
</Array>
</mxGeometry>
</mxCell>`,
</mxCell>
</root>`,
},
]

View File

@@ -1,6 +0,0 @@
export const i18n = {
defaultLocale: "en",
locales: ["en", "zh", "ja"],
} as const
export type Locale = (typeof i18n)["locales"][number]

View File

@@ -1,18 +0,0 @@
import "server-only"
import type { Locale } from "./config"
const dictionaries = {
en: () => import("./dictionaries/en.json").then((m) => m.default),
zh: () => import("./dictionaries/zh.json").then((m) => m.default),
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
}
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>
export const hasLocale = (locale: string): locale is Locale =>
locale in dictionaries
export async function getDictionary(locale: Locale): Promise<Dictionary> {
return dictionaries[locale]()
}

View File

@@ -1,207 +0,0 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"confirm": "Confirm",
"clear": "Clear",
"edit": "Edit",
"delete": "Delete",
"loading": "Loading..",
"new": "NEW"
},
"nav": {
"about": "About",
"editor": "Editor",
"newChat": "Start fresh chat",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
"aiChat": "AI Chat"
},
"providers": {
"useServerDefault": "Use Server Default",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
"send": "Send",
"sending": "Sending...",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"diagramHistory": "Diagram history",
"saveDiagram": "Save diagram",
"uploadFile": "Upload file (image, PDF, text)",
"minimalStyle": "Minimal",
"styledMode": "Styled",
"minimalTooltip": "Use minimal for faster generation (no colors)",
"regenerate": "Regenerate response",
"copyResponse": "Copy response",
"copied": "Copied!",
"failedToCopy": "Failed to copy",
"goodResponse": "Good response",
"badResponse": "Bad response",
"clickToEdit": "Click to edit",
"editMessage": "Edit message",
"saveAndSubmit": "Save & Submit"
},
"examples": {
"title": "Create diagrams with AI",
"subtitle": "Describe what you want to create or upload an image to replicate",
"quickExamples": "Quick Examples",
"paperToDiagram": "Paper to Diagram",
"paperDescription": "Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more",
"animatedDiagram": "Animated Diagram",
"animatedDescription": "Draw a transformer architecture with animated connectors",
"awsArchitecture": "AWS Architecture",
"awsDescription": "Create a cloud architecture diagram with AWS icons",
"replicateFlowchart": "Replicate Flowchart",
"replicateDescription": "Upload and replicate an existing flowchart",
"creativeDrawing": "Creative Drawing",
"creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples are cached for instant response",
"mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
"preview": "PREVIEW"
},
"settings": {
"title": "Settings",
"description": "Configure your application settings.",
"accessCode": "Access Code",
"accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.",
"aiProvider": "AI Provider Settings",
"aiProviderDescription": "Use your own API key to bypass usage limits. Your key is stored locally in your browser and is never stored on the server.",
"provider": "Provider",
"modelId": "Model ID",
"apiKey": "API Key",
"apiKeyPlaceholder": "Your API key",
"baseUrl": "Base URL (optional)",
"customEndpoint": "Custom endpoint URL",
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style:",
"switchTo": "Switch to",
"minimal": "Minimal",
"sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page."
},
"save": {
"title": "Save Diagram",
"description": "Choose a format and filename to save your diagram.",
"format": "Format",
"filename": "Filename",
"filenamePlaceholder": "Enter filename",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG Image",
"svg": "SVG Image"
}
},
"history": {
"title": "Diagram History",
"description": "Here saved each diagram before AI modification.\nClick on a diagram to restore it",
"noHistory": "No history available yet. Send messages to create diagram history.",
"version": "Version",
"restoreTo": "Restore to Version {version}?"
},
"dialogs": {
"clearTitle": "Clear Everything?",
"clearDescription": "This will clear the current conversation and reset the diagram. This action cannot be undone.",
"clearEverything": "Clear Everything",
"clearSuccess": "Started a fresh chat"
},
"errors": {
"maxFiles": "Too many files. Maximum {max} allowed.",
"onlyMoreAllowed": "Only {slots} more file(s) allowed",
"fileExceeds": "\"{name}\" is {size} (exceeds {max}MB)",
"unsupportedType": "\"{name}\" is not a supported file type",
"filesRejected": "{count} files rejected:",
"andMore": "...and {count} more",
"invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.",
"networkError": "Network error. Please check your connection.",
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"validationFailed": "Diagram validation failed. Please try regenerating.",
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
"failedToProcess": "Failed to process diagram. Please try regenerating.",
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage",
"failedToPersist": "Failed to persist state before unload",
"failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image"
},
"quota": {
"dailyLimit": "Daily Quota Reached",
"tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
"reset": "Your limit resets tomorrow. Thanks for understanding!",
"selfHost": "Self-host",
"sponsor": "Sponsor",
"learnMore": "Learn more →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "Generate Diagram",
"editDiagram": "Edit Diagram",
"appendDiagram": "Continue Diagram",
"complete": "Complete",
"error": "Error",
"truncated": "Truncated"
},
"file": {
"reading": "Reading...",
"chars": "chars",
"removeFile": "Remove file"
},
"reasoning": {
"thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds",
"thoughtBrief": "Thought for a few seconds"
},
"about": {
"modelChange": "Model Change & Usage Limits",
"walletCrying": "(Or: Why My Wallet is Crying)",
"seekingSponsorship": "Call for Sponsorship",
"contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
},
"modelConfig": {
"title": "AI Model Configuration",
"description": "Configure multiple AI providers and models",
"configure": "Configure",
"addProvider": "Add Provider",
"addModel": "Add Model",
"modelId": "Model ID",
"modelLabel": "Display Label",
"streaming": "Enable Streaming",
"deleteProvider": "Delete Provider",
"deleteModel": "Delete Model",
"noModels": "No models configured. Add a model to get started.",
"selectProvider": "Select a provider or add a new one",
"configureMultiple": "Configure multiple AI providers and switch between them easily",
"apiKeyStored": "API keys are stored locally in your browser",
"test": "Test",
"validationError": "Validation failed",
"addModelFirst": "Add at least one model to validate"
}
}

View File

@@ -1,207 +0,0 @@
{
"common": {
"save": "保存",
"cancel": "キャンセル",
"close": "閉じる",
"confirm": "確認",
"clear": "クリア",
"edit": "編集",
"delete": "削除",
"loading": "読み込み中..",
"new": "新規"
},
"nav": {
"about": "概要",
"editor": "エディタ",
"newChat": "新しいチャットを開始",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
"aiChat": "AI チャット"
},
"providers": {
"useServerDefault": "サーバーデフォルトを使用",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"send": "送信",
"sending": "送信中...",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"diagramHistory": "ダイアグラム履歴",
"saveDiagram": "ダイアグラムを保存",
"uploadFile": "ファイルをアップロード画像、PDF、テキスト",
"minimalStyle": "ミニマル",
"styledMode": "スタイル付き",
"minimalTooltip": "高速生成のためミニマルを使用(色なし)",
"regenerate": "応答を再生成",
"copyResponse": "応答をコピー",
"copied": "コピーしました!",
"failedToCopy": "コピーに失敗しました",
"goodResponse": "良い応答",
"badResponse": "悪い応答",
"clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信"
},
"examples": {
"title": "AI でダイアグラムを作成",
"subtitle": "作成したいものを説明するか、画像をアップロードして複製",
"quickExamples": "クイック例",
"paperToDiagram": "論文からダイアグラムへ",
"paperDescription": ".pdf, .txt, .md, .json, .csv, .py, .js, .ts などをアップロード",
"animatedDiagram": "アニメーション図",
"animatedDescription": "アニメーションコネクタ付きの Transformer アーキテクチャを描画",
"awsArchitecture": "AWS アーキテクチャ",
"awsDescription": "AWS アイコンでクラウドアーキテクチャ図を作成",
"replicateFlowchart": "フローチャートを複製",
"replicateDescription": "既存のフローチャートをアップロードして複製",
"creativeDrawing": "クリエイティブな描画",
"creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はキャッシュされ、即座に応答します",
"mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
"preview": "プレビュー"
},
"settings": {
"title": "設定",
"description": "アプリケーション設定を構成します。",
"accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
"aiProvider": "AI プロバイダー設定",
"aiProviderDescription": "独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。",
"provider": "プロバイダー",
"modelId": "モデル ID",
"apiKey": "API キー",
"apiKeyPlaceholder": "あなたの API キー",
"baseUrl": "ベース URLオプション",
"customEndpoint": "カスタムエンドポイント URL",
"overrides": "上書き",
"clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用",
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル:",
"switchTo": "切り替え",
"minimal": "ミニマル",
"sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。"
},
"save": {
"title": "ダイアグラムを保存",
"description": "形式とファイル名を選択してダイアグラムを保存します。",
"format": "形式",
"filename": "ファイル名",
"filenamePlaceholder": "ファイル名を入力",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 画像",
"svg": "SVG 画像"
}
},
"history": {
"title": "ダイアグラム履歴",
"description": "AI 修正前に保存された各ダイアグラム。\nダイアグラムをクリックして復元",
"noHistory": "まだ履歴がありません。メッセージを送信してダイアグラム履歴を作成してください。",
"version": "バージョン",
"restoreTo": "バージョン {version} に復元しますか?"
},
"dialogs": {
"clearTitle": "すべてクリアしますか?",
"clearDescription": "現在の会話をクリアし、ダイアグラムをリセットします。この操作は元に戻せません。",
"clearEverything": "すべてクリア",
"clearSuccess": "新しいチャットを開始しました"
},
"errors": {
"maxFiles": "ファイルが多すぎます。最大 {max} 個まで許可されています。",
"onlyMoreAllowed": "あと {slots} 個のファイルのみ許可されています",
"fileExceeds": "「{name}」は {size} です({max}MB を超えています)",
"unsupportedType": "「{name}」はサポートされていないファイルタイプです",
"filesRejected": "{count} 個のファイルが拒否されました:",
"andMore": "...およびさらに {count} 個",
"invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。",
"networkError": "ネットワークエラー。接続を確認してください。",
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました",
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
"failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー"
},
"quota": {
"dailyLimit": "1日の割当量に達しました",
"tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます!",
"selfHost": "セルフホスト",
"sponsor": "スポンサー",
"learnMore": "詳細 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "ダイアグラムを生成",
"editDiagram": "ダイアグラムを編集",
"appendDiagram": "ダイアグラムに追加",
"complete": "完了",
"error": "エラー",
"truncated": "切り捨て"
},
"file": {
"reading": "読み込み中...",
"chars": "文字",
"removeFile": "ファイルを削除"
},
"reasoning": {
"thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました",
"thoughtBrief": "数秒考えました"
},
"about": {
"modelChange": "モデル変更と利用制限について",
"walletCrying": "(別名:お財布が悲鳴を上げています)",
"seekingSponsorship": "スポンサー募集",
"contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"modelConfig": {
"title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定",
"configure": "設定",
"addProvider": "プロバイダーを追加",
"addModel": "モデルを追加",
"modelId": "モデルID",
"modelLabel": "表示名",
"streaming": "ストリーミングを有効",
"deleteProvider": "プロバイダーを削除",
"deleteModel": "モデルを削除",
"noModels": "モデルが設定されていません。モデルを追加してください。",
"selectProvider": "プロバイダーを選択または追加してください",
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
"test": "テスト",
"validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください"
}
}

View File

@@ -1,207 +0,0 @@
{
"common": {
"save": "保存",
"cancel": "取消",
"close": "关闭",
"confirm": "确认",
"clear": "清除",
"edit": "编辑",
"delete": "删除",
"loading": "加载中...",
"new": "新建"
},
"nav": {
"about": "关于",
"editor": "编辑器",
"newChat": "开始新对话",
"github": "GitHub",
"settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
},
"providers": {
"useServerDefault": "使用服务器默认值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
"send": "发送",
"sending": "发送中...",
"sendMessage": "发送消息",
"clearConversation": "清除对话",
"diagramHistory": "图表历史",
"saveDiagram": "保存图表",
"uploadFile": "上传文件图片、PDF、文本",
"minimalStyle": "简约",
"styledMode": "精致",
"minimalTooltip": "使用简约模式以加快生成速度(无颜色)",
"regenerate": "重新生成响应",
"copyResponse": "复制响应",
"copied": "已复制!",
"failedToCopy": "复制失败",
"goodResponse": "有帮助",
"badResponse": "无帮助",
"clickToEdit": "点击编辑",
"editMessage": "编辑消息",
"saveAndSubmit": "保存并提交"
},
"examples": {
"title": "用 AI 创建图表",
"subtitle": "描述您想要创建的内容或上传图片进行复制",
"quickExamples": "快速示例",
"paperToDiagram": "文档转图表",
"paperDescription": "上传 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等文件",
"animatedDiagram": "动画图表",
"animatedDescription": "绘制带有动画连接器的 Transformer 架构",
"awsArchitecture": "AWS 架构",
"awsDescription": "使用 AWS 图标创建云架构图",
"replicateFlowchart": "复制流程图",
"replicateDescription": "上传并复制现有流程图",
"creativeDrawing": "创意绘图",
"creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例已缓存,可即时响应",
"mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
"preview": "预览"
},
"settings": {
"title": "设置",
"description": "配置您的应用程序设置。",
"accessCode": "访问码",
"accessCodePlaceholder": "输入访问码",
"accessCodeDescription": "使用此应用程序需要访问码。",
"aiProvider": "AI 提供商设置",
"aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。",
"provider": "提供商",
"modelId": "模型 ID",
"apiKey": "API 密钥",
"apiKeyPlaceholder": "您的 API 密钥",
"baseUrl": "基础 URL可选",
"customEndpoint": "自定义端点 URL",
"overrides": "覆盖",
"clearSettings": "清除设置",
"useServerDefault": "使用服务器默认值",
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式:",
"switchTo": "切换到",
"minimal": "简约",
"sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。"
},
"save": {
"title": "保存图表",
"description": "选择格式和文件名以保存您的图表。",
"format": "格式",
"filename": "文件名",
"filenamePlaceholder": "输入文件名",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 图片",
"svg": "SVG 图片"
}
},
"history": {
"title": "图表历史",
"description": "在 AI 修改之前保存的每个图表。\n点击图表以恢复它",
"noHistory": "尚无历史记录。发送消息以创建图表历史。",
"version": "版本",
"restoreTo": "恢复到版本 {version}"
},
"dialogs": {
"clearTitle": "清除所有内容?",
"clearDescription": "这将清除当前对话并重置图表。此操作无法撤消。",
"clearEverything": "清除所有内容",
"clearSuccess": "已开始新对话"
},
"errors": {
"maxFiles": "文件太多。最多允许 {max} 个。",
"onlyMoreAllowed": "只能再添加 {slots} 个文件",
"fileExceeds": "\"{name}\" 大小为 {size}(超过 {max}MB",
"unsupportedType": "\"{name}\" 不是支持的文件类型",
"filesRejected": "{count} 个文件被拒绝:",
"andMore": "...还有 {count} 个",
"invalidAccessCode": "无效或缺少访问码。请在设置中配置。",
"networkError": "网络错误。请检查您的连接。",
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
"validationFailed": "图表验证失败。请尝试重新生成。",
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
"failedToProcess": "无法处理图表。请尝试重新生成。",
"sessionCorrupted": "会话数据已损坏。重新开始。",
"failedToSave": "无法保存消息到 localStorage",
"failedToRestore": "无法从 localStorage 恢复",
"failedToPersist": "卸载前无法持久化状态",
"failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错"
},
"quota": {
"dailyLimit": "已达每日配额",
"tokenLimit": "已达每日令牌限制",
"tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
"reset": "您的限制将在明天重置。感谢您的理解!",
"selfHost": "自托管",
"sponsor": "赞助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "生成图表",
"editDiagram": "编辑图表",
"appendDiagram": "继续图表",
"complete": "完成",
"error": "错误",
"truncated": "已截断"
},
"file": {
"reading": "读取中...",
"chars": "字符",
"removeFile": "移除文件"
},
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtBrief": "思考了几秒钟"
},
"about": {
"modelChange": "模型变更与用量限制",
"walletCrying": "(别名:我的钱包顶不住了)",
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
"contactMe": "联系我",
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型",
"configure": "配置",
"addProvider": "添加提供商",
"addModel": "添加模型",
"modelId": "模型 ID",
"modelLabel": "显示名称",
"streaming": "启用流式输出",
"deleteProvider": "删除提供商",
"deleteModel": "删除模型",
"noModels": "尚未配置模型。添加模型以开始使用。",
"selectProvider": "选择一个提供商或添加新的",
"configureMultiple": "配置多个 AI 提供商并轻松切换",
"apiKeyStored": "API 密钥存储在您的浏览器本地",
"test": "测试",
"validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证"
}
}

View File

@@ -1,14 +0,0 @@
export function formatMessage(
template: string | undefined,
vars?: Record<string, string | number | undefined>,
): string {
if (!template) return ""
if (!vars) return template
return template.replace(/\{(\w+)\}/g, (match, name) => {
const val = vars[name]
return val === undefined ? match : String(val)
})
}
export default formatMessage

View File

@@ -1,10 +1,7 @@
import { extractText, getDocumentProxy } from "unpdf"
// Maximum characters allowed for extracted text (configurable via env)
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
export const MAX_EXTRACTED_CHARS =
Number(process.env.NEXT_PUBLIC_MAX_EXTRACTED_CHARS) ||
DEFAULT_MAX_EXTRACTED_CHARS
// Maximum characters allowed for extracted text
export const MAX_EXTRACTED_CHARS = 150000 // 150k chars
// Text file extensions we support
const TEXT_EXTENSIONS = [

View File

@@ -24,8 +24,4 @@ export const STORAGE_KEYS = {
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
// Multi-model configuration
modelConfigs: "next-ai-draw-io-model-configs",
selectedModelId: "next-ai-draw-io-selected-model-id",
} as const

View File

@@ -42,25 +42,11 @@ description: Edit specific parts of the EXISTING diagram. Use this when making s
parameters: {
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>)
}
---Tool4---
tool name: get_shape_library
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, etc.) before creating diagrams with cloud/tech icons.
parameters: {
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc.
}
---End of tools---
IMPORTANT: Choose the right tool:
- 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 append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
- Use get_shape_library for: Discovering available icons/shapes when creating cloud architecture or technical diagrams (call BEFORE display_diagram)
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
@@ -91,37 +77,42 @@ Note that:
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- For cloud/tech diagrams (AWS, Azure, GCP, K8s), call get_shape_library first to discover available icon shapes and their syntax.
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool:
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
- For delete: only cell_id is needed
- Find the cell_id from "Current diagram XML" in system context
- 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>"}]}
- Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]}
- 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>"}]}
- CRITICAL: Copy search patterns EXACTLY from the "Current diagram XML" in system context - attribute order matters!
- Always include the element's id attribute for unique targeting: {"search": "<mxCell id=\\"5\\"", ...}
- Include complete elements (mxCell + mxGeometry) for reliable matching
- Preserve exact whitespace, indentation, and line breaks
- BAD: {"search": "value=\\"Label\\"", ...} - too vague, matches multiple elements
- GOOD: {"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"...\\">", "replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"...\\">"}
- 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
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically.
Example - generate ONLY this:
Basic structure:
\`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
</root>
</mxGraphModel>
\`\`\`
Note: All other mxCell elements go as siblings after id="1".
CRITICAL RULES:
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
4. Use unique sequential IDs starting from "2"
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
3. Use unique sequential IDs for all cells (start from "2" for user content)
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
Shape (vertex) example:
\`\`\`xml
@@ -135,6 +126,122 @@ Connector (edge) example:
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
`
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
2. Every mxCell needs a unique id attribute
3. Every mxCell (except id="0") needs a valid parent attribute referencing an existing cell
4. Edge source/target attributes must reference existing cell IDs
5. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
6. Always start with the two root cells: <mxCell id="0"/><mxCell id="1" parent="0"/>
**Example with swimlanes and edges** (note: all mxCells are siblings under <root>):
\`\`\`xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\`
### edit_diagram Details
**CRITICAL RULES:**
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
- Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element
**Input Format:**
\`\`\`json
{
"edits": [
{
"search": "EXACT lines copied from current XML (preserve attribute order!)",
"replace": "Replacement lines"
}
]
}
\`\`\`
## edit_diagram Best Practices
### Core Principle: Unique & Precise Patterns
Your search pattern MUST uniquely identify exactly ONE location in the XML. Before writing a search pattern:
1. Review the "Current diagram XML" in the system context
2. Identify the exact element(s) to modify by their unique id attribute
3. Include enough context to ensure uniqueness
### Pattern Construction Rules
**Rule 1: Always include the element's id attribute**
\`\`\`json
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
\`\`\`
**Rule 2: Include complete XML elements when possible**
\`\`\`json
{
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"3\\" value=\\"New\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\`
**Rule 3: Preserve exact whitespace and formatting**
Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order.
### Good vs Bad Patterns
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
### ⚠️ JSON Escaping (CRITICAL)
Every double quote inside JSON string values MUST be escaped with backslash:
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error!
### Error Recovery
If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML
2. **Second retry**: Expand context - include more surrounding lines
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
@@ -184,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
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
\`\`\`
`
// Style instructions - only included when minimalStyle is false
const STYLE_INSTRUCTIONS = `
Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
`
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
const MINIMAL_STYLE_INSTRUCTION = `
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
### No Styling - Plain Black/White Only
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
- NO color attributes (no hex colors like #ff69b4)
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
- IGNORE all color/style examples below
### Container/Group Shapes - MUST be Transparent
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
- This prevents containers from covering child elements
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
### Focus on Layout Quality
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
- SPACING: Minimum 50px gap between all elements
- NO OVERLAPS: Elements and edges must never overlap
- Follow ALL 7 Edge Routing Rules for arrow positioning
- Use waypoints to route edges AROUND obstacles
- Use different exitY/entryY values for multiple edges between same nodes
`
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4400 tokens
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated):
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
2. All mxCell elements must be siblings - never nested inside other mxCell elements
3. Every mxCell needs a unique id attribute (start from "2")
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
5. Edge source/target attributes must reference existing cell IDs
6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
\`\`\`xml
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
### append_diagram Details
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
**CRITICAL RULES:**
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
### edit_diagram Details
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
**Operations:**
- **update**: Replace an existing cell. Provide cell_id and new_xml.
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
- **delete**: Remove a cell. Only cell_id is needed.
**Input Format:**
\`\`\`json
{
"operations": [
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
{"type": "delete", "cell_id": "5"}
]
}
\`\`\`
**Examples:**
Change label:
\`\`\`json
{"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\`
Add new shape:
\`\`\`json
{"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
\`\`\`
Delete cell:
\`\`\`json
{"operations": [{"type": "delete", "cell_id": "5"}]}
\`\`\`
**Error Recovery:**
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
## Edge Examples
### Two edges between same nodes (CORRECT - no overlap):
@@ -365,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
* @param modelId - The AI model ID from environment
* @param minimalStyle - If true, removes style instructions to save tokens
* @returns The system prompt string
*/
export function getSystemPrompt(
modelId?: string,
minimalStyle?: boolean,
): string {
export function getSystemPrompt(modelId?: string): string {
const modelName = modelId || "AI"
let prompt: string
@@ -395,15 +369,5 @@ export function getSystemPrompt(
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)
}

View File

@@ -1,277 +0,0 @@
// Types for multi-provider model configuration
export type ProviderName =
| "openai"
| "anthropic"
| "google"
| "azure"
| "bedrock"
| "openrouter"
| "deepseek"
| "siliconflow"
| "gateway"
// Individual model configuration
export interface ModelConfig {
id: string // UUID for this model
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
validated?: boolean // Has this model been validated
validationError?: string // Error message if validation failed
}
// Provider configuration
export interface ProviderConfig {
id: string // UUID for this provider config
provider: ProviderName
name?: string // Custom display name (e.g., "OpenAI Production")
apiKey: string
baseUrl?: string
// AWS Bedrock specific fields
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string // Optional, for temporary credentials
models: ModelConfig[]
validated?: boolean // Has API key been validated
}
// The complete multi-model configuration
export interface MultiModelConfig {
version: 1
providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID
}
// Flattened model for dropdown display
export interface FlattenedModel {
id: string // Model config UUID
modelId: string // Actual model ID
provider: ProviderName
providerLabel: string // Provider display name
apiKey: string
baseUrl?: string
// AWS Bedrock specific fields
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
awsSessionToken?: string
validated?: boolean // Has this model been validated
}
// Provider metadata
export const PROVIDER_INFO: Record<
ProviderName,
{ label: string; defaultBaseUrl?: string }
> = {
openai: { label: "OpenAI" },
anthropic: {
label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1",
},
google: { label: "Google" },
azure: { label: "Azure OpenAI" },
bedrock: { label: "Amazon Bedrock" },
openrouter: { label: "OpenRouter" },
deepseek: { label: "DeepSeek" },
siliconflow: {
label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.com/v1",
},
gateway: { label: "AI Gateway" },
}
// Suggested models per provider for quick add
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
openai: [
// GPT-4o series (latest)
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-2024-11-20",
// GPT-4 Turbo
"gpt-4-turbo",
"gpt-4-turbo-preview",
// o1/o3 reasoning models
"o1",
"o1-mini",
"o1-preview",
"o3-mini",
// GPT-4
"gpt-4",
// GPT-3.5
"gpt-3.5-turbo",
],
anthropic: [
// Claude 4.5 series (latest)
"claude-opus-4-5-20250514",
"claude-sonnet-4-5-20250514",
// Claude 4 series
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
// Claude 3.7 series
"claude-3-7-sonnet-20250219",
// Claude 3.5 series
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
// Claude 3 series
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
google: [
// Gemini 2.5 series
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-preview-05-20",
// Gemini 2.0 series
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-lite",
// Gemini 1.5 series
"gemini-1.5-pro",
"gemini-1.5-flash",
// Legacy
"gemini-pro",
],
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
bedrock: [
// Anthropic Claude
"anthropic.claude-opus-4-5-20250514-v1:0",
"anthropic.claude-sonnet-4-5-20250514-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-haiku-20240307-v1:0",
// Amazon Nova
"amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0",
"amazon.nova-micro-v1:0",
// Meta Llama
"meta.llama3-3-70b-instruct-v1:0",
"meta.llama3-1-405b-instruct-v1:0",
"meta.llama3-1-70b-instruct-v1:0",
// Mistral
"mistral.mistral-large-2411-v1:0",
"mistral.mistral-small-2503-v1:0",
],
openrouter: [
// Anthropic
"anthropic/claude-sonnet-4",
"anthropic/claude-opus-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-haiku",
// OpenAI
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/o1",
"openai/o3-mini",
// Google
"google/gemini-2.5-pro",
"google/gemini-2.5-flash",
"google/gemini-2.0-flash-exp:free",
// Meta Llama
"meta-llama/llama-3.3-70b-instruct",
"meta-llama/llama-3.1-405b-instruct",
"meta-llama/llama-3.1-70b-instruct",
// DeepSeek
"deepseek/deepseek-chat",
"deepseek/deepseek-r1",
// Qwen
"qwen/qwen-2.5-72b-instruct",
],
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
siliconflow: [
// DeepSeek
"deepseek-ai/DeepSeek-V3",
"deepseek-ai/DeepSeek-R1",
"deepseek-ai/DeepSeek-V2.5",
// Qwen
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen2.5-Coder-32B-Instruct",
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2-VL-72B-Instruct",
],
gateway: [
"openai/gpt-4o",
"openai/gpt-4o-mini",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-3-5-sonnet",
"google/gemini-2.0-flash",
],
}
// Helper to generate UUID
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
// Create empty config
export function createEmptyConfig(): MultiModelConfig {
return {
version: 1,
providers: [],
selectedModelId: undefined,
}
}
// Create new provider config
export function createProviderConfig(provider: ProviderName): ProviderConfig {
return {
id: generateId(),
provider,
apiKey: "",
baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,
models: [],
validated: false,
}
}
// Create new model config
export function createModelConfig(modelId: string): ModelConfig {
return {
id: generateId(),
modelId,
}
}
// Get all models as flattened list for dropdown
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
const models: FlattenedModel[] = []
for (const provider of config.providers) {
// Use custom name if provided, otherwise use default provider label
const providerLabel =
provider.name || PROVIDER_INFO[provider.provider].label
for (const model of provider.models) {
models.push({
id: model.id,
modelId: model.modelId,
provider: provider.provider,
providerLabel,
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
// AWS Bedrock fields
awsAccessKeyId: provider.awsAccessKeyId,
awsSecretAccessKey: provider.awsSecretAccessKey,
awsRegion: provider.awsRegion,
awsSessionToken: provider.awsSessionToken,
validated: model.validated,
})
}
}
return models
}
// Find model by ID
export function findModelById(
config: MultiModelConfig,
modelId: string,
): FlattenedModel | undefined {
return flattenModels(config).find((m) => m.id === modelId)
}

Some files were not shown because too many files have changed in this diff Show More