Compare commits

..

1 Commits

Author SHA1 Message Date
Dayuan Jiang
c7e88b0711 Remove Electron settings panel (breaking change)
- Remove electron/settings/ folder
- Remove config-manager.ts and settings-window.ts
- Remove Configuration menu from app menu
- Remove config-presets IPC handlers
- Update README.md
- Clean up electron.d.ts

Note: This is a breaking change - users will lose existing presets
2025-12-25 13:58:21 +09:00
82 changed files with 5455 additions and 9662 deletions

View File

@@ -1,24 +0,0 @@
---
name: Enhancement
about: Suggest an improvement to existing functionality
title: '[Enhancement] '
labels: enhancement
assignees: ''
---
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
## Current Behavior
Describe how the feature currently works.
## Proposed Enhancement
How you'd like this to be improved.
## Motivation
Why this enhancement would be beneficial.
## Screenshots / Mockups
If applicable, add screenshots or mockups to illustrate the proposed changes.
## Additional Context
Any other information about the enhancement request.

40
.github/renovate.json vendored
View File

@@ -1,40 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["after 10am on saturday"],
"timezone": "Asia/Tokyo",
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"matchPackagePatterns": ["*"],
"groupName": "minor and patch dependencies",
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"matchPackagePatterns": ["*"],
"automerge": false
},
{
"matchPackagePatterns": ["@ai-sdk/*"],
"groupName": "AI SDK packages"
},
{
"matchPackagePatterns": ["@radix-ui/*"],
"groupName": "Radix UI packages"
},
{
"matchPackagePatterns": ["electron", "electron-builder"],
"groupName": "Electron packages",
"automerge": false
},
{
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages",
"automerge": false
}
],
"vulnerabilityAlerts": {
"enabled": true
}
}

View File

@@ -12,18 +12,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '20'
- name: Install Biome
run: npm install --save-dev @biomejs/biome
- name: Run Biome format
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
run: npx @biomejs/biome check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
@@ -34,21 +37,11 @@ jobs:
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
# For fork PRs, just fail if formatting is needed (can't push to forks)
- name: Fail if fork PR needs formatting
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes."
git diff --stat
exit 1
# For same-repo PRs, commit and push the changes
- name: Commit changes
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
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 remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
git add .
git commit -m "style: auto-format with Biome"
git push origin HEAD:${{ github.head_ref }}
git push

View File

@@ -1,44 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Type check
run: npx tsc --noEmit
- name: Lint check
run: npm run check
- name: Build
run: npm run build
- name: Security audit
run: npm audit --audit-level=high --omit=dev

View File

@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -54,7 +54,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -69,7 +69,7 @@ jobs:
# 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@v5
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 }}

View File

@@ -29,12 +29,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 20
cache: "npm"
- name: Install dependencies

View File

@@ -1,7 +1,7 @@
# Multi-stage Dockerfile for Next.js
# Stage 1: Install dependencies
FROM node:24-alpine AS deps
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -9,10 +9,10 @@ WORKDIR /app
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
RUN npm ci
# Stage 2: Build application
FROM node:24-alpine AS builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copy node_modules from deps stage
@@ -34,7 +34,7 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
RUN npm run build
# Stage 3: Production runtime
FROM node:24-alpine AS runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

View File

@@ -19,7 +19,6 @@ English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -27,20 +26,17 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
## Table of Contents
- [Next AI Draw.io](#next-ai-drawio)
- [Next AI Draw.io ](#next-ai-drawio-)
- [Table of Contents](#table-of-contents)
- [Examples](#examples)
- [Features](#features)
- [MCP Server (Preview)](#mcp-server-preview)
- [Claude Code CLI](#claude-code-cli)
- [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)
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support)
- [How It Works](#how-it-works)
- [Project Structure](#project-structure)
@@ -136,7 +132,7 @@ No installation needed! Try the app directly on our demo site:
[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> Note: Due to high traffic, the demo site currently uses minimax-m2. For best results, we recommend self-hosting with Claude Sonnet 4.5 or Claude Opus 4.5.
> **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.
@@ -151,14 +147,13 @@ Download the native desktop app for your platform from the [Releases page](https
| 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
- **Built-in settings**: Configure AI providers directly in the app
**Quick Setup:**
1. Download and install for your platform
2. Open the app → **Menu → Configuration → Manage Presets**
2. Click the settings icon in the chat panel
3. Add your AI provider credentials
4. Start creating diagrams!
@@ -217,7 +212,7 @@ cp env.example .env.local
Edit `.env.local` and configure your chosen provider:
- Set `AI_PROVIDER` to your chosen provider (doubao,bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
- Set `AI_MODEL` to the specific model you want to use
- Add the required API keys for your provider
- `TEMPERATURE`: Optional temperature setting (e.g., `0` for deterministic output). Leave unset for models that don't support it (e.g., reasoning models).
@@ -237,23 +232,18 @@ npm run dev
## Deployment
### Deploy on Vercel (Recommended)
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js.
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Or you can deploy by this button.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Deploy on Cloudflare Workers
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
## Multi-Provider Support
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock (default)
- OpenAI
- Anthropic
@@ -264,7 +254,6 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- DeepSeek
- SiliconFlow
All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
@@ -305,8 +294,6 @@ public/ # Static assets including example images
## Support & Contact
**Special thanks to [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:

View File

@@ -96,68 +96,72 @@ export default function AboutCN() {
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
{" "}
<span className="text-sm text-amber-600 font-medium italic font-normal">
()
</span>
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
</a>
{" "}
AI
(TPS/TPM)
</p>
<p>
使 Opus 4.5 {" "}
<span className="font-semibold text-amber-700">
K2-thinking
</span>{" "}
{" "}
<span className="font-semibold text-amber-700">
50Token
Haiku 4.5
</span>
</p>
<p>
<span className="font-semibold text-amber-700">
</span>
API
</p>
</div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
使
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
Token/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{/* Limits Cards */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
Token
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
Token/
</p>
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(dailyTokenLimit)}
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
</div>
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
</div>
<div className="text-2xl font-bold text-gray-900">
{dailyRequestLimit}
</div>
<div className="text-sm text-gray-600">
</div>
</div>
</div>
@@ -167,19 +171,48 @@ export default function AboutCN() {
</div>
{/* Bring Your Own Key */}
<div className="text-center">
<div className="text-center mb-5">
<h4 className="text-base font-bold text-gray-900 mb-2">
使 API Key
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
使 API
Key
使 API Key
Provider API Key
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
Key
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-3 mb-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Sponsorship CTA */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
()
</h4>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
AI API
</p>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
GitHub Live Demo
Logo
</p>
<a
href="mailto:me@jiang.jp"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
</a>
</div>
</div>
</div>
@@ -344,16 +377,6 @@ export default function AboutCN() {
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
</a>
</li>
<li>AWS Bedrock</li>
<li>
OpenAI / OpenAI兼容API{" "}
@@ -365,7 +388,6 @@ export default function AboutCN() {
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>{" "}
@@ -373,21 +395,18 @@ export default function AboutCN() {
</p>
{/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<p className="text-gray-700 mb-4 font-semibold">
{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
</a>{" "}
API Token
</p>
<div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe
src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang"
height="32"
width="114"
style={{ border: 0, borderRadius: 6 }}
/>
</div>
<p className="text-gray-700">
{" "}
<a

View File

@@ -104,68 +104,73 @@ export default function AboutJA() {
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
ByteDance Doubao提供
{" "}
<span className="text-sm text-amber-600 font-medium italic font-normal">
</span>
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
{" "}
AI API (TPS/TPM)
</p>
<p>
Opus 4.5 {" "}
<span className="font-semibold text-amber-700">
K2-thinking
Haiku 4.5
</span>{" "}
使{" "}
</p>
<p>
<span className="font-semibold text-amber-700">
50
</span>
API
</p>
</div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
使
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{/* Limits Cards */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
使
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(dailyTokenLimit)}
<span className="text-sm font-normal text-gray-600">
/
</span>
</div>
</div>
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
1
</div>
<div className="text-2xl font-bold text-gray-900">
{dailyRequestLimit}
</div>
<div className="text-sm text-gray-600">
</div>
</div>
</div>
@@ -175,17 +180,44 @@ export default function AboutJA() {
</div>
{/* Bring Your Own Key */}
<div className="text-center">
<div className="text-center mb-5">
<h4 className="text-base font-bold text-gray-900 mb-2">
APIキーを使用
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
APIキーを使用することもできま
APIキーを使用することでAPIキーを設定してくださ
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-3 mb-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Sponsorship CTA */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
</h4>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
AI
API
</p>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
GitHub
</p>
<a
href="mailto:me@jiang.jp"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
</a>
</div>
</div>
</div>
@@ -359,16 +391,6 @@ export default function AboutJA() {
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
</li>
<li>AWS Bedrock</li>
<li>
OpenAI / OpenAI互換API<code>OPENAI_BASE_URL</code>
@@ -380,7 +402,6 @@ export default function AboutJA() {
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>
@@ -388,21 +409,18 @@ export default function AboutJA() {
</p>
{/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<p className="text-gray-700 mb-4 font-semibold">
APIトークン使用を支援してくださった{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>{" "}
</p>
<div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe
src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang"
height="32"
width="114"
style={{ border: 0, borderRadius: 6 }}
/>
</div>
<p className="text-gray-700">
{" "}
<a

View File

@@ -104,70 +104,79 @@ export default function About() {
</div>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
Sponsored by ByteDance Doubao
Model Change & Usage Limits{" "}
<span className="text-sm text-amber-600 font-medium italic font-normal">
(Or: Why My Wallet is Crying)
</span>
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
Great news! Thanks to the generous
sponsorship from{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
, the demo site now uses the powerful{" "}
The response to this project has been
incredibleyou all love making diagrams!
However, this enthusiasm means we are
frequently hitting the AI API rate limits
(TPS/TPM). When this happens, the system
pauses, leading to failed requests.
</p>
<p>
Due to the high usage, I have changed the
model from Opus 4.5 to{" "}
<span className="font-semibold text-amber-700">
K2-thinking
</span>{" "}
model for better diagram generation! Sign up
via the link to get{" "}
Haiku 4.5
</span>
, which is more cost-effective.
</p>
<p>
As an{" "}
<span className="font-semibold text-amber-700">
500K free tokens
</span>{" "}
for all models!
indie developer
</span>
, I am currently footing the entire API
bill. To keep the lights on and ensure the
service remains available to everyone
without sending me into debt, I have also
implemented the following temporary caps:
</p>
</div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
Please note the current usage limits:
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
requests/day
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
tokens/day
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{/* Limits Cards */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
Token Usage
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
tokens/min
</p>
<span className="text-sm font-normal text-gray-600">
/min
</span>
</div>
<div className="text-lg font-bold text-gray-900">
{formatNumber(dailyTokenLimit)}
<span className="text-sm font-normal text-gray-600">
/day
</span>
</div>
</div>
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
Daily Requests
</div>
<div className="text-2xl font-bold text-gray-900">
{dailyRequestLimit}
</div>
<div className="text-sm text-gray-600">
requests
</div>
</div>
</div>
@@ -177,21 +186,51 @@ export default function About() {
</div>
{/* Bring Your Own Key */}
<div className="text-center">
<div className="text-center mb-5">
<h4 className="text-base font-bold text-gray-900 mb-2">
Bring Your Own API Key
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
You can also use your own API key with any
supported provider. Click the Settings icon
in the chat panel to configure your provider
and API key.
You can use your own API key to bypass these
limits. Click the Settings icon in the chat
panel to configure your provider and API
key.
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
Your key is stored locally in your browser
and is never stored on the server.
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-3 mb-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Sponsorship CTA */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
Call for Sponsorship
</h4>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
Scaling the backend is the only way to
remove these limits. I am actively seeking
sponsorship from AI API providers or Cloud
Platforms.
</p>
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
In return for support (credits or funding),
I will prominently feature your company as a
platform sponsor on both the GitHub
repository and the live demo site.
</p>
<a
href="mailto:me@jiang.jp"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
Contact Me
</a>
</div>
</div>
</div>
@@ -378,16 +417,6 @@ export default function About() {
Multi-Provider Support
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
</li>
<li>AWS Bedrock (default)</li>
<li>
OpenAI / OpenAI-compatible APIs (via{" "}
@@ -399,7 +428,6 @@ export default function About() {
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
</ul>
<p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on
@@ -409,21 +437,18 @@ export default function About() {
</p>
{/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Support &amp; Contact
</h2>
<p className="text-gray-700 mb-4 font-semibold">
Special thanks to{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>{" "}
for sponsoring the API token usage of the demo site!
</p>
<div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">
Support &amp; Contact
</h2>
<iframe
src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang"
height="32"
width="114"
style={{ border: 0, borderRadius: 6 }}
/>
</div>
<p className="text-gray-700">
If you find this project useful, please consider{" "}
<a

View File

@@ -1,5 +1,4 @@
"use client"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
@@ -11,7 +10,6 @@ import {
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
@@ -26,8 +24,6 @@ export default function Home() {
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const router = useRouter()
const pathname = usePathname()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -62,18 +58,6 @@ export default function Home() {
// Load preferences from localStorage after mount
useEffect(() => {
// Restore saved locale and redirect if needed
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
const pathParts = pathname.split("/").filter(Boolean)
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
router.replace(`/${pathParts.join("/")}`)
return // Wait for redirect
}
}
const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi)
@@ -100,7 +84,7 @@ export default function Home() {
}
setIsLoaded(true)
}, [pathname, router])
}, [])
const handleDarkModeChange = async () => {
await saveDiagramToStorage()

View File

@@ -605,7 +605,7 @@ Notes:
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. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
- 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.
@@ -614,8 +614,8 @@ For update/add, new_xml must be a complete mxCell element including mxGeometry.
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
Example - Delete a cell:
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
inputSchema: z.object({
operations: z
.array(

View File

@@ -225,27 +225,6 @@ export async function POST(req: Request) {
break
}
case "sglang": {
// SGLang is OpenAI-compatible
const sglang = createOpenAI({
apiKey: apiKey || "not-needed",
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
})
model = sglang.chat(modelId)
break
}
case "doubao": {
// ByteDance Doubao uses DeepSeek-compatible API
const doubao = createDeepSeek({
apiKey,
baseURL:
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
})
model = doubao(modelId)
break
}
default:
return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` },

View File

@@ -17,9 +17,14 @@ 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"
@@ -147,14 +152,16 @@ interface ChatInputProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
showHistory?: boolean
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
showUnvalidatedModels?: boolean
onConfigureModels?: () => void
}
@@ -167,23 +174,28 @@ export function ChatInput({
files = [],
onFileChange = () => {},
pdfData = new Map(),
showHistory = false,
onToggleHistory = () => {},
sessionId,
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
models = [],
selectedModelId,
onModelSelect = () => {},
showUnvalidatedModels = false,
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const { diagramHistory, saveDiagramToFile } = useDiagram()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showHistory, setShowHistory] = 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
@@ -371,67 +383,109 @@ export function ChatInput({
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
<HistoryDialog
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">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
@@ -453,20 +507,6 @@ export function ChatInput({
</div>
</div>
</div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(filename, format, sessionId)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
</form>
)
}

View File

@@ -14,12 +14,18 @@ import Link from "next/link"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa"
import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary"
@@ -148,6 +154,7 @@ export default function ChatPanel({
// File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -185,7 +192,6 @@ export default function ChatPanel({
dailyRequestLimit,
dailyTokenLimit,
tpmLimit,
onConfigModel: () => setShowModelConfigDialog(true),
})
// Generate a unique session ID for Langfuse tracing (restore from localStorage if available)
@@ -241,178 +247,182 @@ export default function ChatPanel({
onExport,
})
const { messages, sendMessage, addToolOutput, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
try {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
const {
messages,
sendMessage,
addToolOutput,
stop,
status,
error,
setMessages,
} = useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
try {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
return
}
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
return
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
return
}
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
return
}
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
}
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
// Debug: Log messages structure when error occurs
console.log("[onError] messages count:", messages.length)
messages.forEach((msg, idx) => {
console.log(`[onError] Message ${idx}:`, {
role: msg.role,
partsCount: msg.parts?.length,
})
}
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage =
"Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (friendlyMessage.includes("image content block")) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [
{ type: "text" as const, text: friendlyMessage },
],
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
console.log(
`[onError] Part ${partIdx}:`,
JSON.stringify({
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input &&
typeof part.input === "object"
? Object.keys(part.input)
: null,
}),
)
})
}
return [...currentMessages, errorMessage]
})
}
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (friendlyMessage.includes("image content block")) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
}
},
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
return [...currentMessages, errorMessage]
})
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: ({ message }) => {
// Track actual token usage from server metadata
const metadata = message?.metadata as
| Record<string, unknown>
| undefined
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
// DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason)
},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
toast.error(
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
)
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
toast.error(
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
)
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
}
return true
},
})
return true
},
})
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
@@ -933,11 +943,7 @@ export default function ChatPanel({
<div className="flex items-center gap-2 overflow-x-hidden">
<div className="flex items-center gap-2">
<Image
src={
darkMode
? "/favicon-white.svg"
: "/favicon.ico"
}
src="/favicon.ico"
alt="Next AI Drawio"
width={isMobile ? 24 : 28}
height={isMobile ? 24 : 28}
@@ -949,32 +955,18 @@ export default function ChatPanel({
Next AI Drawio
</h1>
</div>
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
)}
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent="Sponsored by ByteDance Doubao K2-thinking. See About page for details."
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-500 hover:text-amber-600"
{!isMobile &&
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
<AlertTriangle className="h-4 w-4" />
</ButtonWithTooltip>
</Link>
)}
About
</Link>
)}
</div>
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
@@ -988,6 +980,23 @@ export default function ChatPanel({
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
</TooltipTrigger>
<TooltipContent>{dict.nav.github}</TooltipContent>
</Tooltip>
<ButtonWithTooltip
tooltipContent={dict.nav.settings}
@@ -1037,9 +1046,6 @@ export default function ChatPanel({
<DevXmlSimulator
setMessages={setMessages}
onDisplayChart={onDisplayChart}
onShowQuotaToast={() =>
quotaManager.showQuotaLimitToast(50, 50)
}
/>
)}
@@ -1056,12 +1062,15 @@ export default function ChatPanel({
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)}
/>
</footer>
@@ -1074,8 +1083,6 @@ export default function ChatPanel({
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
/>
<ModelConfigDialog

View File

@@ -134,13 +134,11 @@ const DEV_XML_PRESETS: Record<string, string> = {
interface DevXmlSimulatorProps {
setMessages: React.Dispatch<React.SetStateAction<any[]>>
onDisplayChart: (xml: string) => void
onShowQuotaToast?: () => void
}
export function DevXmlSimulator({
setMessages,
onDisplayChart,
onShowQuotaToast,
}: DevXmlSimulatorProps) {
const [devXml, setDevXml] = useState("")
const [isSimulating, setIsSimulating] = useState(false)
@@ -344,15 +342,6 @@ export function DevXmlSimulator({
Stop
</button>
)}
{onShowQuotaToast && (
<button
type="button"
onClick={onShowQuotaToast}
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
>
Test Quota Toast
</button>
)}
</div>
</div>
</details>

View File

@@ -50,7 +50,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils"
@@ -76,9 +75,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
gateway: "vercel",
doubao: "bytedance",
}
// Provider logo component
@@ -89,16 +86,10 @@ function ProviderLogo({
provider: ProviderName
className?: string
}) {
// Use Lucide icons for providers without models.dev logos
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
@@ -421,7 +412,11 @@ export function ModelConfigDialog({
setSelectedProviderId(
provider.id,
)
setValidationStatus("idle")
setValidationStatus(
provider.validated
? "success"
: "idle",
)
setShowApiKey(false)
}}
className={cn(
@@ -560,20 +555,6 @@ export function ModelConfigDialog({
</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div>
{/* Configuration Section */}
@@ -1435,6 +1416,24 @@ export function ModelConfigDialog({
)}
</div>
</ConfigSection>
{/* Danger Zone */}
<div className="pt-4">
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl"
>
<Trash2 className="h-4 w-4 mr-2" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div>
</div>
</ScrollArea>
</>
@@ -1456,23 +1455,10 @@ export function ModelConfigDialog({
{/* Footer */}
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
checked={modelConfig.showUnvalidatedModels}
onCheckedChange={
modelConfig.setShowUnvalidatedModels
}
/>
<Label className="text-xs text-muted-foreground cursor-pointer">
{dict.modelConfig.showUnvalidatedModels}
</Label>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Key className="h-3 w-3" />
{dict.modelConfig.apiKeyStored}
</p>
</div>
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
<Key className="h-3 w-3" />
{dict.modelConfig.apiKeyStored}
</p>
</div>
</DialogContent>

View File

@@ -1,14 +1,7 @@
"use client"
import {
AlertTriangle,
Bot,
Check,
ChevronDown,
Server,
Settings2,
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
import { useMemo, useState } from "react"
import {
ModelSelectorContent,
ModelSelectorEmpty,
@@ -33,7 +26,6 @@ interface ModelSelectorProps {
onSelect: (modelId: string | undefined) => void
onConfigure: () => void
disabled?: boolean
showUnvalidatedModels?: boolean
}
// Map our provider names to models.dev logo names
@@ -46,9 +38,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel",
doubao: "bytedance",
}
// Group models by providerLabel (handles duplicate providers)
@@ -77,20 +67,17 @@ export function ModelSelector({
onSelect,
onConfigure,
disabled = false,
showUnvalidatedModels = false,
}: ModelSelectorProps) {
const dict = useDictionary()
const [open, setOpen] = useState(false)
// Filter models based on showUnvalidatedModels setting
const displayModels = useMemo(() => {
if (showUnvalidatedModels) {
return models
}
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
// Only show validated models in the selector
const validatedModels = useMemo(
() => models.filter((m) => m.validated === true),
[models],
)
const groupedModels = useMemo(
() => groupModelsByProvider(displayModels),
[displayModels],
() => groupModelsByProvider(validatedModels),
[validatedModels],
)
// Find selected model for display
@@ -114,189 +101,122 @@ export function ModelSelector({
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
const wrapperRef = useRef<HTMLDivElement | null>(null)
const [showLabel, setShowLabel] = useState(true)
// Threshold (px) under which we hide the label (tweak as needed)
const HIDE_THRESHOLD = 240
const SHOW_THRESHOLD = 260
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const target = el.parentElement ?? el
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width
setShowLabel((prev) => {
// if currently showing and width dropped below hide threshold -> hide
if (prev && width <= HIDE_THRESHOLD) return false
// if currently hidden and width rose above show threshold -> show
if (!prev && width >= SHOW_THRESHOLD) return true
// otherwise keep previous state (hysteresis)
return prev
})
}
})
ro.observe(target)
const initialWidth = target.getBoundingClientRect().width
setShowLabel(initialWidth >= SHOW_THRESHOLD)
return () => ro.disconnect()
}, [])
return (
<div ref={wrapperRef} className="inline-block">
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
tooltipContent={tooltipContent}
variant="ghost"
size="sm"
disabled={disabled}
className={cn(
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
aria-label={tooltipContent}
>
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="text-xs truncate">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}
</span>
) : (
// Keep an sr-only label for screen readers when hidden
<span className="sr-only">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}
</span>
)}
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip>
</ModelSelectorTrigger>
<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
: dict.modelConfig.default}
</span>
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip>
</ModelSelectorTrigger>
<ModelSelectorContent title={dict.modelConfig.selectModel}>
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty>
{validatedModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
<ModelSelectorContent title={dict.modelConfig.selectModel}>
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty>
{displayModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option */}
<ModelSelectorGroup heading={dict.modelConfig.default}>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
{/* Server Default Option */}
<ModelSelectorGroup heading={dict.modelConfig.default}>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
<Check
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
>
<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>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</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>
{model.validated !== true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
{/* 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",
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
/>
<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>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
</div>
{/* Configure Option */}
<ModelSelectorSeparator />
<ModelSelectorGroup>
<ModelSelectorItem
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer"
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{dict.modelConfig.onlyVerifiedShown}
</div>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { Coffee, Settings, X } from "lucide-react"
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"
@@ -11,7 +12,6 @@ interface QuotaLimitToastProps {
used: number
limit: number
onDismiss: () => void
onConfigModel?: () => void
}
export function QuotaLimitToast({
@@ -19,7 +19,6 @@ export function QuotaLimitToast({
used,
limit,
onDismiss,
onConfigModel,
}: QuotaLimitToastProps) {
const dict = useDictionary()
const isTokenLimit = type === "token"
@@ -76,36 +75,16 @@ export function QuotaLimitToast({
? dict.quota.messageToken
: dict.quota.messageApi}
</p>
<p
dangerouslySetInnerHTML={{
__html: formatMessage(dict.quota.doubaoSponsorship, {
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
}),
}}
/>
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p>
</div>{" "}
{/* Action buttons */}
<div className="flex items-center gap-2">
{onConfigModel && (
<button
type="button"
onClick={() => {
onConfigModel()
onDismiss()
}}
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"
>
<Settings className="w-3.5 h-3.5" />
{dict.quota.configModel}
</button>
)}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
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"
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}

View File

@@ -1,6 +1,6 @@
"use client"
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { Moon, Sun } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
@@ -24,6 +24,7 @@ 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"
import { cn } from "@/lib/utils"
// Reusable setting item component for consistent layout
function SettingItem({
@@ -64,8 +65,6 @@ interface SettingsDialogProps {
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -87,8 +86,6 @@ function SettingsContent({
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
@@ -154,9 +151,6 @@ function SettingsContent({
}, [open])
const changeLanguage = (lang: string) => {
// Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang)
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
@@ -351,61 +345,14 @@ function SettingsContent({
}}
/>
</SettingItem>
{/* Diagram Style */}
<SettingItem
label={dict.settings.diagramStyle}
description={dict.settings.diagramStyleDescription}
>
<div className="flex items-center gap-2">
<Switch
id="minimal-style"
checked={minimalStyle}
onCheckedChange={onMinimalStyleChange}
/>
<span className="text-sm text-muted-foreground">
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</span>
</div>
</SettingItem>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
<div className="flex items-center justify-center gap-3">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Tag className="h-3 w-3" />
{process.env.APP_VERSION}
</span>
<span className="text-muted-foreground">·</span>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Github className="h-3 w-3" />
GitHub
</a>
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
"true" && (
<>
<span className="text-muted-foreground">·</span>
<a
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Info className="h-3 w-3" />
About
</a>
</>
)}
</div>
<p className="text-xs text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
</div>
</DialogContent>
)

View File

@@ -1,267 +0,0 @@
# Deploy on Cloudflare Workers
This project can be deployed as a **Cloudflare Worker** using the **OpenNext adapter**, giving you:
- Global edge deployment
- Very low latency
- Free `workers.dev` hosting
- Full Next.js ISR support via R2 (optional)
> **Important Windows Note:** OpenNext and Wrangler are **not fully reliable on native Windows**. Recommended options:
>
> - Use **GitHub Codespaces** (works perfectly)
> - OR use **WSL (Linux)**
>
> Pure Windows builds may fail due to WASM file path issues.
---
## Prerequisites
1. A **Cloudflare account** (free tier works for basic deployment)
2. **Node.js 18+**
3. **Wrangler CLI** installed (dev dependency is fine):
```bash
npm install -D wrangler
```
4. Cloudflare login:
```bash
npx wrangler login
```
> **Note:** A payment method is only required if you want to enable R2 for ISR caching. Basic Workers deployment is free.
---
## Step 1 — Install dependencies
```bash
npm install
```
---
## Step 2 — Configure environment variables
Cloudflare uses a different file for local testing.
### 1) Create `.dev.vars` (for Cloudflare local + deploy)
```bash
cp env.example .dev.vars
```
Fill in your API keys and configuration.
### 2) Make sure `.env.local` also exists (for regular Next.js dev)
```bash
cp env.example .env.local
```
Fill in the same values there.
---
## Step 3 — Choose your deployment type
### Option A: Deploy WITHOUT R2 (Simple, Free)
If you don't need ISR caching, you can deploy without R2:
**1. Use simple `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
Skip to **Step 4**.
---
### Option B: Deploy WITH R2 (Full ISR Support)
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
**1. Create an R2 bucket** in the Cloudflare Dashboard:
- Go to **Storage & Databases → R2**
- Click **Create bucket**
- Name it: `next-inc-cache`
**2. Configure `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
})
```
**3. Configure `wrangler.jsonc` (with R2):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "next-inc-cache"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
---
## Step 4 — Register a workers.dev subdomain (first-time only)
Before your first deployment, you need a workers.dev subdomain.
**Option 1: Via Cloudflare Dashboard (Recommended)**
Visit: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
**Option 2: During deploy**
When you run `npm run deploy`, Wrangler may prompt:
```
Would you like to register a workers.dev subdomain? (Y/n)
```
Type `Y` and choose a subdomain name.
> **Note:** In CI/CD or non-interactive environments, the prompt won't appear. Register via the dashboard first.
---
## Step 5 — Deploy to Cloudflare
```bash
npm run deploy
```
What the script does:
- Builds the Next.js app
- Converts it to a Cloudflare Worker via OpenNext
- Uploads static assets
- Publishes the Worker
Your app will be available at:
```
https://<worker-name>.<your-subdomain>.workers.dev
```
---
## Common issues & fixes
### `You need to register a workers.dev subdomain`
**Cause:** No workers.dev subdomain registered for your account.
**Fix:** Go to https://dash.cloudflare.com → Workers & Pages → Set up a subdomain.
---
### `Please enable R2 through the Cloudflare Dashboard`
**Cause:** R2 is configured in wrangler.jsonc but not enabled on your account.
**Fix:** Either enable R2 (requires payment method) or use Option A (deploy without R2).
---
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
**Cause:** `r2_buckets` is missing from `wrangler.jsonc`.
**Fix:** Add the `r2_buckets` section or switch to Option A (without R2).
---
### `Can't set compatibility date in the future`
**Cause:** `compatibility_date` in wrangler config is set to a future date.
**Fix:** Change `compatibility_date` to today or an earlier date.
---
### Windows error: `resvg.wasm?module` (ENOENT)
**Cause:** Windows filenames cannot include `?`, but a wasm asset uses `?module` in its filename.
**Fix:** Build/deploy on Linux (WSL, Codespaces, or CI).
---
## Optional: Preview locally
Preview the Worker locally before deploying:
```bash
npm run preview
```
---
## Summary
| Feature | Without R2 | With R2 |
|---------|------------|---------|
| Cost | Free | Requires payment method |
| ISR Caching | No | Yes |
| Static Pages | Yes | Yes |
| API Routes | Yes | Yes |
| Setup Complexity | Simple | Moderate |
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.

View File

@@ -19,8 +19,6 @@
一个集成了AI功能的Next.js网页应用与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 目录
@@ -129,6 +127,8 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
[![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> 注意:由于访问量较大,演示站点目前使用 minimax-m2 模型。如需获得最佳效果,建议使用 Claude Sonnet 4.5 或 Claude Opus 4.5 自行部署。
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
### 使用Docker运行推荐
@@ -186,7 +186,7 @@ cp env.example .env.local
编辑 `.env.local` 并配置您选择的提供商:
-`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, doubao
-`AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
-`AI_MODEL` 设置为您要使用的特定模型
- 添加您的提供商所需的API密钥
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
@@ -218,7 +218,6 @@ npm run dev
## 多提供商支持
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock默认
- OpenAI
- Anthropic
@@ -269,8 +268,6 @@ public/ # 静态资源包括示例图片
## 支持与联系
**特别感谢[字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
如需支持或咨询请在GitHub仓库上提交issue或联系维护者

View File

@@ -19,8 +19,6 @@
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## 目次
@@ -129,6 +127,8 @@ Claudeにダイアグラムの作成を依頼
[![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> 注意:アクセス数が多いため、デモサイトでは現在 minimax-m2 モデルを使用しています。最高の結果を得るには、Claude Sonnet 4.5 または Claude Opus 4.5 でのセルフホスティングをお勧めします。
> **自分のAPIキーを使用**自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
### Dockerで実行推奨
@@ -186,7 +186,7 @@ cp env.example .env.local
`.env.local`を編集して選択したプロバイダーを設定:
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, doubao
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
- `AI_MODEL`を使用する特定のモデルに設定
- プロバイダーに必要なAPIキーを追加
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
@@ -218,7 +218,6 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
## マルチプロバイダーサポート
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrockデフォルト
- OpenAI
- Anthropic
@@ -269,8 +268,6 @@ public/ # サンプル画像を含む静的アセット
## サポート&お問い合わせ
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください

View File

@@ -11,15 +11,6 @@ This guide explains how to configure different AI model providers for next-ai-dr
## Supported Providers
### Doubao (ByteDance Volcengine)
> **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
```bash
DOUBAO_API_KEY=your_api_key
AI_MODEL=doubao-seed-1-8-251215 # or other Doubao model
```
### Google Gemini
```bash
@@ -85,8 +76,6 @@ Optional custom endpoint (defaults to the recommended domain):
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1
```
### Azure OpenAI
```bash
@@ -190,7 +179,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, doubao, azure, bedrock, openrouter, ollama, gateway
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama, gateway
```
## Model Capability Requirements

View File

@@ -33,7 +33,7 @@ services:
| Scenario | URL Value |
|----------|-----------|
| Localhost | `http://localhost:8080` |
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
| Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` |
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

View File

@@ -6,7 +6,7 @@
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<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>
```

29
electron.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/**
* Type declarations for Electron API exposed via preload script
*/
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>
}
}
}
export {}

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,19 +1,4 @@
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"
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
/**
* Build and set the application menu
@@ -24,13 +9,6 @@ export function buildAppMenu(): void {
Menu.setApplicationMenu(menu)
}
/**
* Rebuild the menu (call this when presets change)
*/
export function rebuildAppMenu(): void {
buildAppMenu()
}
/**
* Get the menu template
*/
@@ -46,15 +24,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
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" },
@@ -69,22 +38,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
// 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" },
],
submenu: [isMac ? { role: "close" } : { role: "quit" }],
})
// Edit menu
@@ -129,9 +83,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
],
})
// Configuration menu with presets
template.push(buildConfigMenu())
// Window menu
template.push({
label: "Window",
@@ -172,70 +123,3 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
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,10 +1,8 @@
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
@@ -24,19 +22,12 @@ if (!gotTheLock) {
// 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()

View File

@@ -1,43 +1,4 @@
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
@@ -123,90 +84,4 @@ export function registerIpcHandlers(): void {
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

@@ -3,16 +3,16 @@ import { app } from "electron"
/**
* Port configuration
* Using fixed ports to preserve localStorage across restarts
* (localStorage is origin-specific, so changing ports loses all saved data)
*/
const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility
development: 6002,
// Production mode uses fixed port (61337) to preserve localStorage
// Falls back to sequential ports if unavailable
production: 61337,
// Maximum attempts to find an available port (fallback)
// Production mode port range (will find first available)
production: {
min: 10000,
max: 65535,
},
// Maximum attempts to find an available port
maxAttempts: 100,
}
@@ -36,11 +36,19 @@ export function isPortAvailable(port: number): Promise<boolean> {
})
}
/**
* 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: uses fixed port (61337) to preserve localStorage
* - Falls back to sequential ports if preferred port is unavailable
* - 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
@@ -48,9 +56,6 @@ export function isPortAvailable(port: number): Promise<boolean> {
*/
export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged
const preferredPort = isDev
? PORT_CONFIG.development
: PORT_CONFIG.production
// Try to reuse cached port if requested and available
if (reuseExisting && allocatedPort !== null) {
@@ -64,22 +69,29 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null
}
// Try preferred port first
if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort
return preferredPort
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...`,
)
}
console.warn(
`Preferred port ${preferredPort} 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()
// Fallback: try sequential ports starting from preferred + 1
for (let attempt = 1; attempt <= PORT_CONFIG.maxAttempts; attempt++) {
const port = preferredPort + attempt
if (await isPortAvailable(port)) {
const available = await isPortAvailable(port)
if (available) {
allocatedPort = port
console.log(`Allocated fallback port: ${port}`)
console.log(`Allocated port: ${port}`)
return port
}
}

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,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,116 +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">
<div class="deprecation-notice">
<strong>⚠️ Deprecation Notice</strong>
<p>This settings panel will be removed in a future update.</p>
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
</div>
<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,344 +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;
}
}
.deprecation-notice {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.deprecation-notice strong {
color: #856404;
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.deprecation-notice p {
color: #856404;
font-size: 13px;
margin: 4px 0;
}
@media (prefers-color-scheme: dark) {
.deprecation-notice {
background-color: #332701;
border-color: #665200;
}
.deprecation-notice strong,
.deprecation-notice p {
color: #ffc107;
}
}
* {
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

@@ -72,10 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# ByteDance Doubao Configuration (via Volcengine)
# DOUBAO_API_KEY=your-doubao-api-key
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine 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"

View File

@@ -109,11 +109,9 @@ export interface UseModelConfigReturn {
models: FlattenedModel[]
selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined
showUnvalidatedModels: boolean
// Actions
setSelectedModelId: (modelId: string | undefined) => void
setShowUnvalidatedModels: (show: boolean) => void
addProvider: (provider: ProviderName) => ProviderConfig
updateProvider: (
providerId: string,
@@ -162,13 +160,6 @@ export function useModelConfig(): UseModelConfigReturn {
}))
}, [])
const setShowUnvalidatedModels = useCallback((show: boolean) => {
setConfig((prev) => ({
...prev,
showUnvalidatedModels: show,
}))
}, [])
const addProvider = useCallback(
(provider: ProviderName): ProviderConfig => {
const newProvider = createProviderConfig(provider)
@@ -287,9 +278,7 @@ export function useModelConfig(): UseModelConfigReturn {
models,
selectedModel,
selectedModelId: config.selectedModelId,
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
setSelectedModelId,
setShowUnvalidatedModels,
addProvider,
updateProvider,
deleteProvider,

View File

@@ -21,7 +21,6 @@ export type ProviderName =
| "siliconflow"
| "sglang"
| "gateway"
| "doubao"
interface ModelConfig {
model: any
@@ -54,7 +53,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"siliconflow",
"sglang",
"gateway",
"doubao",
]
// Bedrock provider options for Anthropic beta features
@@ -348,8 +346,7 @@ function buildProviderOptions(
case "openrouter":
case "siliconflow":
case "sglang":
case "gateway":
case "doubao": {
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break
@@ -375,7 +372,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
doubao: "DOUBAO_API_KEY",
}
/**
@@ -840,23 +836,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "doubao": {
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
const baseURL =
overrides?.baseUrl ||
process.env.DOUBAO_BASE_URL ||
"https://ark.cn-beijing.volces.com/api/v3"
const doubaoProvider = createDeepSeek({
apiKey,
baseURL,
})
model = doubaoProvider(modelId)
break
}
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, doubao`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`,
)
}

View File

@@ -27,7 +27,6 @@ try {
/**
* Get today's date string in the configured timezone (YYYY-MM-DD format)
* This is used as the Sort Key (SK) for per-day tracking
*/
function getTodayInTimezone(): string {
return new Intl.DateTimeFormat("en-CA", {
@@ -62,8 +61,8 @@ interface QuotaCheckResult {
/**
* Check all quotas and increment request count atomically.
* Uses composite key (PK=user, SK=date) for per-day tracking.
* Each day automatically gets a new item - no explicit reset needed.
* Uses ConditionExpression to prevent race conditions.
* Returns which limit was exceeded if any.
*/
export async function checkAndIncrementRequest(
ip: string,
@@ -74,33 +73,77 @@ export async function checkAndIncrementRequest(
return { allowed: true }
}
const pk = ip // User identifier (base64 IP)
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const today = getTodayInTimezone()
const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try {
// Single atomic update - handles creation AND increment
// New day automatically creates new item (different SK)
// Note: lastMinute/tpmCount are managed by recordTokenUsage only
// First, try to reset counts if it's a new day (atomic day reset)
// This will succeed only if lastResetDate < today or doesn't exist
try {
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } },
// Reset all counts to 1/0 for the new day
UpdateExpression: `
SET lastResetDate = :today,
dailyReqCount = :one,
dailyTokenCount = :zero,
lastMinute = :minute,
tpmCount = :zero,
#ttl = :ttl
`,
// Only succeed if it's a new day (or new item)
ConditionExpression: `
attribute_not_exists(lastResetDate) OR lastResetDate < :today
`,
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":today": { S: today },
":zero": { N: "0" },
":one": { N: "1" },
":minute": { S: currentMinute },
":ttl": { N: String(ttl) },
},
}),
)
// New day reset successful
return { allowed: true }
} catch (resetError: any) {
// If condition failed, it's the same day - continue to increment logic
if (!(resetError instanceof ConditionalCheckFailedException)) {
throw resetError // Re-throw unexpected errors
}
}
// Same day - increment request count with limit checks
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
UpdateExpression: "ADD reqCount :one",
Key: { PK: { S: `IP#${ip}` } },
// Increment request count, handle minute boundary for TPM
UpdateExpression: `
SET lastMinute = :minute,
tpmCount = if_not_exists(tpmCount, :zero),
#ttl = :ttl
ADD dailyReqCount :one
`,
// Check all limits before allowing increment
// TPM check: allow if new minute OR under limit
ConditionExpression: `
(attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND
(attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND
lastResetDate = :today AND
(attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit) AND
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
`,
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":today": { S: today },
":zero": { N: "0" },
":one": { N: "1" },
":minute": { S: currentMinute },
":ttl": { N: String(ttl) },
":reqLimit": { N: String(limits.requests || 999999) },
":tokenLimit": { N: String(limits.tokens || 999999) },
":tpmLimit": { N: String(limits.tpm || 999999) },
@@ -117,39 +160,42 @@ export async function checkAndIncrementRequest(
const getResult = await client.send(
new GetItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
Key: { PK: { S: `IP#${ip}` } },
}),
)
const item = getResult.Item
const storedDate = item?.lastResetDate?.S
const storedMinute = item?.lastMinute?.S
const isNewDay = !storedDate || storedDate < today
const reqCount = Number(item?.reqCount?.N || 0)
const tokenCount = Number(item?.tokenCount?.N || 0)
const dailyReqCount = isNewDay
? 0
: Number(item?.dailyReqCount?.N || 0)
const dailyTokenCount = isNewDay
? 0
: Number(item?.dailyTokenCount?.N || 0)
const tpmCount =
storedMinute !== currentMinute
? 0
: Number(item?.tpmCount?.N || 0)
// Determine which limit was exceeded
if (limits.requests > 0 && reqCount >= limits.requests) {
if (limits.requests > 0 && dailyReqCount >= limits.requests) {
return {
allowed: false,
type: "request",
error: "Daily request limit exceeded",
used: reqCount,
used: dailyReqCount,
limit: limits.requests,
}
}
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
if (limits.tokens > 0 && dailyTokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
used: dailyTokenCount,
limit: limits.tokens,
}
}
@@ -164,7 +210,7 @@ export async function checkAndIncrementRequest(
}
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
// Fail safe by allowing (could be a reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
@@ -187,7 +233,7 @@ export async function checkAndIncrementRequest(
/**
* Record token usage after response completes.
* Uses composite key (PK=user, SK=date) for per-day tracking.
* Uses atomic operations to update both daily token count and TPM count.
* Handles minute boundaries atomically to prevent race conditions.
*/
export async function recordTokenUsage(
@@ -198,27 +244,24 @@ export async function recordTokenUsage(
if (!client || !TABLE) return
if (!Number.isFinite(tokens) || tokens <= 0) return
const pk = ip // User identifier (base64 IP)
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try {
// Try to update for same minute OR new item (most common cases)
// Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches)
// Try to update assuming same minute (most common case)
// Uses condition to ensure we're in the same minute
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
Key: { PK: { S: `IP#${ip}` } },
UpdateExpression:
"SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens",
ConditionExpression:
"attribute_not_exists(lastMinute) OR lastMinute = :minute",
"SET #ttl = :ttl ADD dailyTokenCount :tokens, tpmCount :tokens",
ConditionExpression: "lastMinute = :minute",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":minute": { S: currentMinute },
":tokens": { N: String(tokens) },
":ttl": { N: String(ttl) },
},
}),
)
@@ -229,15 +272,14 @@ export async function recordTokenUsage(
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
Key: { PK: { S: `IP#${ip}` } },
UpdateExpression:
"SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens",
"SET lastMinute = :minute, tpmCount = :tokens, #ttl = :ttl ADD dailyTokenCount :tokens",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":minute": { S: currentMinute },
":tokens": { N: String(tokens) },
":ttl": { N: String(ttl) },
},
}),
)

View File

@@ -98,13 +98,7 @@
"minimal": "Minimal",
"sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download"
"closeProtectionDescription": "Show confirmation when leaving the page."
},
"save": {
"title": "Save Diagram",
@@ -157,12 +151,10 @@
"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": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
"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.",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
"configModel": "Use Your API Key",
"reset": "Your limit resets tomorrow. Thanks for understanding!",
"selfHost": "Self-host",
"sponsor": "Sponsor",
"learnMore": "Learn more →",
@@ -251,9 +243,6 @@
"default": "Default",
"serverDefault": "Server Default",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated"
"onlyVerifiedShown": "Only verified models are shown"
}
}

View File

@@ -98,13 +98,7 @@
"minimal": "ミニマル",
"sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード"
"closeProtectionDescription": "ページを離れる際に確認を表示します。"
},
"save": {
"title": "ダイアグラムを保存",
@@ -157,12 +151,10 @@
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデルDoubao、DeepSeek、Kimi含むで50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
"configModel": "APIキーを使用",
"reset": "制限は明日リセットされます。ご理解ありがとうございます",
"selfHost": "セルフホスト",
"sponsor": "スポンサー",
"learnMore": "詳細 →",
@@ -251,9 +243,6 @@
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません"
"onlyVerifiedShown": "検証済みのモデルのみ表示"
}
}

View File

@@ -98,13 +98,7 @@
"minimal": "简约",
"sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录",
"download": "下载"
"closeProtectionDescription": "离开页面时显示确认。"
},
"save": {
"title": "保存图表",
@@ -157,12 +151,10 @@
"tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
"reset": "您的限制将在明天重置。感谢您的理解",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token包括豆包、DeepSeek 和 Kimi然后在模型设置中配置您的 API Key。",
"configModel": "使用您的密钥",
"reset": "您的限制将在明天重置。感谢您的理解",
"selfHost": "自托管",
"sponsor": "赞助",
"learnMore": "了解更多 →",
@@ -251,9 +243,6 @@
"default": "默认",
"serverDefault": "服务器默认",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证"
"onlyVerifiedShown": "仅显示已验证的模型"
}
}

View File

@@ -276,7 +276,7 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
**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. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
- **delete**: Remove a cell. Only cell_id is needed.
**Input Format:**
\`\`\`json
@@ -301,9 +301,9 @@ Add new shape:
{"operations": [{"operation": "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 container (children & edges auto-deleted):
Delete cell:
\`\`\`json
{"operations": [{"operation": "delete", "cell_id": "2"}]}
{"operations": [{"operation": "delete", "cell_id": "5"}]}
\`\`\`
**Error Recovery:**

View File

@@ -9,9 +9,7 @@ export type ProviderName =
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
| "doubao"
// Individual model configuration
export interface ModelConfig {
@@ -42,7 +40,6 @@ export interface MultiModelConfig {
version: 1
providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID
showUnvalidatedModels?: boolean // Show models that haven't been validated
}
// Flattened model for dropdown display
@@ -80,38 +77,28 @@ export const PROVIDER_INFO: Record<
label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.com/v1",
},
sglang: {
label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1",
},
gateway: { label: "AI Gateway" },
doubao: {
label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
},
}
// Suggested models per provider for quick add
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
openai: [
"gpt-5.2-pro",
"gpt-5.2-chat-latest",
"gpt-5.2",
"gpt-5.1-codex-mini",
"gpt-5.1-codex",
"gpt-5.1-chat-latest",
"gpt-5.1",
"gpt-5-pro",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-codex",
"gpt-5-chat-latest",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
// 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)
@@ -208,10 +195,6 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2-VL-72B-Instruct",
],
sglang: [
// SGLang is OpenAI-compatible, models depend on deployment
"default",
],
gateway: [
"openai/gpt-4o",
"openai/gpt-4o-mini",
@@ -219,15 +202,6 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
"anthropic/claude-3-5-sonnet",
"google/gemini-2.0-flash",
],
doubao: [
// ByteDance Doubao models
"doubao-1.5-thinking-pro-250415",
"doubao-1.5-thinking-pro-m-250428",
"doubao-1.5-pro-32k-250115",
"doubao-1.5-pro-256k-250115",
"doubao-pro-32k-241215",
"doubao-pro-256k-241215",
],
}
// Helper to generate UUID

View File

@@ -10,7 +10,6 @@ export interface QuotaConfig {
dailyRequestLimit: number
dailyTokenLimit: number
tpmLimit: number
onConfigModel?: () => void
}
/**
@@ -23,8 +22,7 @@ export function useQuotaManager(config: QuotaConfig): {
showTokenLimitToast: (used?: number, limit?: number) => void
showTPMLimitToast: (limit?: number) => void
} {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit, onConfigModel } =
config
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
const dict = useDictionary()
// Show quota limit toast (request-based)
@@ -36,13 +34,12 @@ export function useQuotaManager(config: QuotaConfig): {
used={used ?? dailyRequestLimit}
limit={limit ?? dailyRequestLimit}
onDismiss={() => toast.dismiss(t)}
onConfigModel={onConfigModel}
/>
),
{ duration: 15000 },
)
},
[dailyRequestLimit, onConfigModel],
[dailyRequestLimit],
)
// Show token limit toast
@@ -55,13 +52,12 @@ export function useQuotaManager(config: QuotaConfig): {
used={used ?? dailyTokenLimit}
limit={limit ?? dailyTokenLimit}
onDismiss={() => toast.dismiss(t)}
onConfigModel={onConfigModel}
/>
),
{ duration: 15000 },
)
},
[dailyTokenLimit, onConfigModel],
[dailyTokenLimit],
)
// Show TPM limit toast

View File

@@ -633,77 +633,32 @@ export function applyDiagramOperations(
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") {
// Protect root cells from deletion
if (op.cell_id === "0" || op.cell_id === "1") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cannot delete root cell "${op.cell_id}"`,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
// Cell not found - might have been cascade-deleted by a previous operation
// Skip silently instead of erroring (AI may redundantly list children/edges)
continue
}
// Cascade delete: collect all cells to delete (children + edges + self)
const cellsToDelete = new Set<string>()
// Recursive function to find all descendants
const collectDescendants = (cellId: string) => {
if (cellsToDelete.has(cellId)) return
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
const children = root.querySelectorAll(
`mxCell[parent="${cellId}"]`,
)
children.forEach((child) => {
const childId = child.getAttribute("id")
if (childId && childId !== "0" && childId !== "1") {
collectDescendants(childId)
}
})
}
// Collect the target cell and all its descendants
collectDescendants(op.cell_id)
// Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) {
const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
// Recurse to collect edge's children (like labels)
collectDescendants(edgeId)
}
})
}
// Log what will be deleted
if (cellsToDelete.size > 1) {
console.log(
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
// Check for edges referencing this cell (warning only, still delete)
const referencingEdges = root.querySelectorAll(
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
)
if (referencingEdges.length > 0) {
const edgeIds = Array.from(referencingEdges)
.map((e) => e.getAttribute("id"))
.join(", ")
console.warn(
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
)
}
// Delete all collected cells
for (const cellId of cellsToDelete) {
const cell = cellMap.get(cellId)
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
}
// Remove the node
existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
}
}

View File

@@ -17,13 +17,3 @@ const nextConfig: NextConfig = {
}
export default nextConfig
// Initialize OpenNext Cloudflare for local development only
// This must be a dynamic import to avoid loading workerd binary during builds
if (process.env.NODE_ENV === "development") {
import("@opennextjs/cloudflare").then(
({ initOpenNextCloudflareForDev }) => {
initOpenNextCloudflareForDev()
},
)
}

View File

@@ -1,7 +0,0 @@
// default open-next.config.ts file created by @opennextjs/cloudflare
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
})

10278
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "next-ai-draw-io",
"version": "0.4.7",
"version": "0.4.6",
"license": "Apache-2.0",
"private": true,
"main": "dist-electron/main/index.js",
@@ -12,20 +12,16 @@
"format": "biome check --write .",
"check": "biome ci",
"prepare": "husky",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"electron:dev": "node scripts/electron-dev.mjs",
"electron:build": "npm run build && npm run electron:compile",
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external",
"electron:start": "npx cross-env NODE_ENV=development npx electron .",
"electron:prepare": "node scripts/prepare-electron-build.mjs",
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml",
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder",
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac",
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --win",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --linux",
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1",
@@ -43,7 +39,6 @@
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.14.7",
"@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
@@ -65,9 +60,9 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"js-tiktoken": "^1.0.21",
"jsdom": "^27.0.0",
"jsdom": "^26.0.0",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.562.0",
"lucide-react": "^0.483.0",
"motion": "^12.23.25",
"negotiator": "^1.0.0",
"next": "^16.0.7",
@@ -100,7 +95,7 @@
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/negotiator": "^0.6.4",
"@types/node": "^24.0.0",
"@types/node": "^20",
"@types/pako": "^2.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -109,15 +104,14 @@
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.27.2",
"eslint": "9.39.2",
"eslint-config-next": "16.1.1",
"eslint": "9.39.1",
"eslint-config-next": "16.0.5",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"shx": "^0.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"wait-on": "^9.0.3",
"wrangler": "4.54.0"
"wait-on": "^9.0.3"
},
"overrides": {
"@openrouter/ai-sdk-provider": {

View File

@@ -97,7 +97,7 @@ Use the standard MCP configuration with:
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
| `display_diagram` | Create a new diagram from XML |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |

View File

@@ -1,24 +1,24 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.6",
"version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.6",
"version": "0.1.5",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"open": "^10.1.0",
"zod": "^3.24.0"
},
"bin": {
"next-ai-drawio-mcp": "dist/index.js"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/node": "^20",
"tsx": "^4.19.0",
"typescript": "^5"
},
@@ -481,9 +481,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.0.tgz",
"integrity": "sha512-z0Zhn/LmQ3yz91dEfd5QgS7DpSjA4pk+3z2++zKgn5L6iDFM9QapsVoAQSbKLvlrFsZk9+ru6yHHWNq2lCYJKQ==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.7",
@@ -520,13 +520,13 @@
}
},
"node_modules/@types/node": {
"version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~6.21.0"
}
},
"node_modules/accepts": {
@@ -1034,7 +1034,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -1372,18 +1371,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-in-ssh": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
"integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
@@ -1599,20 +1586,18 @@
}
},
"node_modules/open": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
"integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
"license": "MIT",
"dependencies": {
"default-browser": "^5.4.0",
"default-browser": "^5.2.1",
"define-lazy-prop": "^3.0.0",
"is-in-ssh": "^1.0.0",
"is-inside-container": "^1.0.0",
"powershell-utils": "^0.1.0",
"wsl-utils": "^0.3.0"
"wsl-utils": "^0.1.0"
},
"engines": {
"node": ">=20"
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -1655,18 +1640,6 @@
"node": ">=16.20.0"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
"integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1989,9 +1962,9 @@
"license": "ISC"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@@ -2035,16 +2008,15 @@
"license": "ISC"
},
"node_modules/wsl-utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz",
"integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
"license": "MIT",
"dependencies": {
"is-wsl": "^3.1.0",
"powershell-utils": "^0.1.0"
"is-wsl": "^3.1.0"
},
"engines": {
"node": ">=20"
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -2055,7 +2027,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.10",
"version": "0.1.6",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
@@ -38,11 +38,11 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"open": "^10.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/node": "^20",
"tsx": "^4.19.0",
"typescript": "^5"
},

View File

@@ -182,77 +182,32 @@ export function applyDiagramOperations(
// Add to map
cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") {
// Protect root cells from deletion
if (op.cell_id === "0" || op.cell_id === "1") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cannot delete root cell "${op.cell_id}"`,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
// Cell not found - might have been cascade-deleted by a previous operation
// Skip silently instead of erroring (AI may redundantly list children/edges)
continue
}
// Cascade delete: collect all cells to delete (children + edges + self)
const cellsToDelete = new Set<string>()
// Recursive function to find all descendants
const collectDescendants = (cellId: string) => {
if (cellsToDelete.has(cellId)) return
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
const children = root.querySelectorAll(
`mxCell[parent="${cellId}"]`,
)
children.forEach((child) => {
const childId = child.getAttribute("id")
if (childId && childId !== "0" && childId !== "1") {
collectDescendants(childId)
}
})
}
// Collect the target cell and all its descendants
collectDescendants(op.cell_id)
// Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) {
const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
// Recurse to collect edge's children (like labels)
collectDescendants(edgeId)
}
})
}
// Log what will be deleted
if (cellsToDelete.size > 1) {
console.log(
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
// Check for edges referencing this cell (warning only, still delete)
const referencingEdges = root.querySelectorAll(
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
)
if (referencingEdges.length > 0) {
const edgeIds = Array.from(referencingEdges)
.map((e) => e.getAttribute("id"))
.join(", ")
console.warn(
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
)
}
// Delete all collected cells
for (const cellId of cellsToDelete) {
const cell = cellMap.get(cellId)
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
}
// Remove the node
existingCell.parentNode?.removeChild(existingCell)
cellMap.delete(op.cell_id)
}
}

View File

@@ -78,7 +78,7 @@ server.prompt(
## Creating a New Diagram
1. Call start_session to open the browser preview
2. Use create_new_diagram with complete mxGraphModel XML to create a new diagram
2. Use display_diagram with complete mxGraphModel XML to create a new diagram
## Adding Elements to Existing Diagram
1. Use edit_diagram with "add" operation
@@ -91,7 +91,7 @@ server.prompt(
3. For update, provide the cell_id and complete new mxCell XML
## Important Notes
- create_new_diagram REPLACES the entire diagram - only use for new diagrams
- display_diagram REPLACES the entire diagram - only use for new diagrams
- edit_diagram PRESERVES user's manual changes (fetches browser state first)
- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`,
},
@@ -150,59 +150,19 @@ server.registerTool(
},
)
// Tool: create_new_diagram
// Tool: display_diagram
server.registerTool(
"create_new_diagram",
"display_diagram",
{
description: `Create a NEW diagram from mxGraphModel XML. Use this when creating a diagram from scratch or replacing the current diagram entirely.
CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.
When to use this tool:
- Creating a new diagram from scratch
- Replacing the current diagram with a completely different one
- Major structural changes that require regenerating the diagram
When to use edit_diagram instead:
- Small modifications to existing diagram
- Adding/removing individual elements
- Changing labels, colors, or positions
XML FORMAT - Full mxGraphModel structure:
<mxGraphModel>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Shape" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
LAYOUT CONSTRAINTS:
- Keep all elements within x=0-800, y=0-600 (single page viewport)
- Start from margins (x=40, y=40), keep elements grouped closely
- Use unique IDs starting from "2" (0 and 1 are reserved)
- Set parent="1" for top-level shapes
- Space shapes 150-200px apart for clear edge routing
EDGE ROUTING RULES:
- Never let multiple edges share the same path - use different exitY/entryY values
- For bidirectional connections (A↔B), use OPPOSITE sides
- Always specify exitX, exitY, entryX, entryY explicitly in edge style
- Route edges AROUND obstacles using waypoints (add 20-30px clearance)
- Use natural connection points based on flow (not corners)
COMMON STYLES:
- Shapes: rounded=1; fillColor=#hex; strokeColor=#hex
- Edges: endArrow=classic; edgeStyle=orthogonalEdgeStyle; curved=1
- Text: fontSize=14; fontStyle=1 (bold); align=center`,
description:
"Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " +
"Use this for creating new diagrams from scratch. " +
"To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " +
"You should generate valid draw.io/mxGraph XML format.",
inputSchema: {
xml: z
.string()
.describe(
"REQUIRED: The complete mxGraphModel XML. Must always be provided.",
),
.describe("The draw.io XML to display (mxGraphModel format)"),
},
},
async ({ xml: inputXml }) => {
@@ -239,7 +199,7 @@ COMMON STYLES:
}
}
log.info(`Setting diagram content, ${xml.length} chars`)
log.info(`Displaying diagram, ${xml.length} chars`)
// Sync from browser state first
const browserState = getState(currentSession.id)
@@ -266,20 +226,20 @@ COMMON STYLES:
// Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, xml, "")
log.info(`Diagram content set successfully`)
log.info(`Diagram displayed successfully`)
return {
content: [
{
type: "text",
text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`,
text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("create_new_diagram failed:", message)
log.error("display_diagram failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
@@ -380,7 +340,7 @@ server.registerTool(
content: [
{
type: "text",
text: "Error: No diagram to edit. Please create a diagram first with create_new_diagram.",
text: "Error: No diagram to edit. Please create a diagram first with display_diagram.",
},
],
isError: true,
@@ -514,7 +474,7 @@ server.registerTool(
content: [
{
type: "text",
text: "No diagram exists yet. Use create_new_diagram to create one.",
text: "No diagram exists yet. Use display_diagram to create one.",
},
],
}

63
proxy.ts Normal file
View File

@@ -0,0 +1,63 @@
import { match as matchLocale } from "@formatjs/intl-localematcher"
import Negotiator from "negotiator"
import type { NextRequest } from "next/server"
import { NextResponse } from "next/server"
import { i18n } from "./lib/i18n/config"
function getLocale(request: NextRequest): string | undefined {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value
})
// @ts-expect-error locales are readonly
const locales: string[] = i18n.locales
// Use negotiator and intl-localematcher to get best locale
const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
locales,
)
const locale = matchLocale(languages, locales, i18n.defaultLocale)
return locale
}
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Skip API routes, static files, and Next.js internals
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.includes("/favicon") ||
/\.(.*)$/.test(pathname)
) {
return
}
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) =>
!pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
)
// Redirect if there is no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request)
// Redirect to localized path
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url,
),
)
}
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

View File

@@ -1,2 +0,0 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1536.000000pt" height="1536.000000pt" viewBox="0 0 1536.000000 1536.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,1536.000000) scale(0.100000,-0.100000)"
fill="#ffffff" stroke="none">
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431
-560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356
319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85
-17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529
-2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128
-82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303
-177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276
286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242
241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337
-25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22
-5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204
-383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72
-167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300
6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884
-15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534
c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154
11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448
-20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10
-47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293
-369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6
39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52
-149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415
20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50
118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0
535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539
376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70
290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -2,17 +2,13 @@
/**
* Development script for running Electron with Next.js
* 1. Reads preset configuration (if exists)
* 2. Starts Next.js dev server with preset env vars
* 3. Waits for it to be ready
* 4. Compiles Electron TypeScript
* 5. Launches Electron
* 6. Watches for preset changes and restarts Next.js
* 1. Starts Next.js dev server
* 2. Waits for it to be ready
* 3. Compiles Electron TypeScript
* 4. Launches Electron
*/
import { spawn } from "node:child_process"
import { existsSync, readFileSync, watch } from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
@@ -22,64 +18,6 @@ const rootDir = path.join(__dirname, "..")
const NEXT_PORT = 6002
const NEXT_URL = `http://localhost:${NEXT_PORT}`
/**
* Get the user data path (same as Electron's app.getPath("userData"))
*/
function getUserDataPath() {
const appName = "next-ai-draw-io"
switch (process.platform) {
case "darwin":
return path.join(
os.homedir(),
"Library",
"Application Support",
appName,
)
case "win32":
return path.join(
process.env.APPDATA ||
path.join(os.homedir(), "AppData", "Roaming"),
appName,
)
default:
return path.join(os.homedir(), ".config", appName)
}
}
/**
* Load preset configuration from config file
*/
function loadPresetConfig() {
const configPath = path.join(getUserDataPath(), "config-presets.json")
if (!existsSync(configPath)) {
console.log("📋 No preset configuration found, using .env.local")
return null
}
try {
const content = readFileSync(configPath, "utf-8")
const data = JSON.parse(content)
if (!data.currentPresetId) {
console.log("📋 No active preset, using .env.local")
return null
}
const preset = data.presets.find((p) => p.id === data.currentPresetId)
if (!preset) {
console.log("📋 Active preset not found, using .env.local")
return null
}
console.log(`📋 Using preset: "${preset.name}"`)
return preset.config
} catch (error) {
console.error("Failed to load preset config:", error.message)
return null
}
}
/**
* Wait for the Next.js server to be ready
*/
@@ -129,25 +67,14 @@ function runCommand(command, args, options = {}) {
}
/**
* Start Next.js dev server with preset environment
* Start Next.js dev server
*/
function startNextServer(presetEnv) {
const env = { ...process.env }
// Apply preset environment variables
if (presetEnv) {
for (const [key, value] of Object.entries(presetEnv)) {
if (value !== undefined && value !== "") {
env[key] = value
}
}
}
function startNextServer() {
const nextProcess = spawn("npm", ["run", "dev"], {
cwd: rootDir,
stdio: "inherit",
shell: true,
env,
env: process.env,
})
nextProcess.on("error", (err) => {
@@ -163,12 +90,9 @@ function startNextServer(presetEnv) {
async function main() {
console.log("🚀 Starting Electron development environment...\n")
// Load preset configuration
const presetEnv = loadPresetConfig()
// Start Next.js dev server with preset env
// Start Next.js dev server
console.log("1. Starting Next.js development server...")
let nextProcess = startNextServer(presetEnv)
const nextProcess = startNextServer()
// Wait for Next.js to be ready
try {
@@ -203,75 +127,14 @@ async function main() {
},
})
// Watch for preset config changes
const configPath = path.join(getUserDataPath(), "config-presets.json")
let configWatcher = null
let restartPending = false
function setupConfigWatcher() {
if (!existsSync(path.dirname(configPath))) {
// Directory doesn't exist yet, check again later
setTimeout(setupConfigWatcher, 5000)
return
}
try {
configWatcher = watch(
configPath,
{ persistent: false },
async (eventType) => {
if (eventType === "change" && !restartPending) {
restartPending = true
console.log(
"\n🔄 Preset configuration changed, restarting Next.js server...",
)
// Kill current Next.js process
nextProcess.kill()
// Wait a bit for process to die
await new Promise((r) => setTimeout(r, 1000))
// Reload preset and restart
const newPresetEnv = loadPresetConfig()
nextProcess = startNextServer(newPresetEnv)
try {
await waitForServer(NEXT_URL)
console.log(
"✅ Next.js server restarted with new configuration\n",
)
} catch (err) {
console.error(
"❌ Failed to restart Next.js:",
err.message,
)
}
restartPending = false
}
},
)
console.log("👀 Watching for preset configuration changes...")
} catch (err) {
// File might not exist yet, that's ok
setTimeout(setupConfigWatcher, 5000)
}
}
// Start watching after a delay (config file might not exist yet)
setTimeout(setupConfigWatcher, 2000)
electronProcess.on("close", (code) => {
console.log(`\nElectron exited with code ${code}`)
if (configWatcher) configWatcher.close()
nextProcess.kill()
process.exit(code || 0)
})
electronProcess.on("error", (err) => {
console.error("Electron error:", err)
if (configWatcher) configWatcher.close()
nextProcess.kill()
process.exit(1)
})
@@ -279,7 +142,6 @@ async function main() {
// Handle termination signals
const cleanup = () => {
console.log("\n🛑 Shutting down...")
if (configWatcher) configWatcher.close()
electronProcess.kill()
nextProcess.kill()
process.exit(0)

View File

@@ -22,7 +22,6 @@
"@/*": ["./*"]
}
},
"files": ["electron/electron.d.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",

View File

@@ -1,23 +0,0 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08", // must be a today or past compatibility_date
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "next-inc-cache"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}