Compare commits

..

3 Commits

Author SHA1 Message Date
dayuan.jiang
950a8754fa fix: regenerate package-lock.json with cross-platform deps 2025-12-29 14:26:50 +09:00
dayuan.jiang
76c0308ceb chore: regenerate package-lock.json to fix CI 2025-12-29 14:17:11 +09:00
dayuan.jiang
d40ab4114c chore: clean up root folder by moving config files
- Move renovate.json to .github/renovate.json
- Move electron-builder.yml to electron/electron-builder.yml
- Move electron.d.ts to electron/electron.d.ts
- Delete proxy.ts (unused dead code)
- Update package.json dist scripts with --config flag
- Use tsconfig.json files array for electron.d.ts (bypasses exclude)

Reduces git-tracked root files from 23 to 19.
2025-12-29 14:14:15 +09:00
136 changed files with 4105 additions and 19914 deletions

View File

@@ -20,42 +20,15 @@ npm run lint # Check lint errors
npm run check # Run all checks (CI) npm run check # Run all checks (CI)
``` ```
Git hooks via Husky run automatically: Pre-commit hooks via Husky will run Biome automatically on staged files.
- **Pre-commit**: Biome (format/lint) + TypeScript type check
- **Pre-push**: Unit tests
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save. For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
## Testing
Run tests before submitting PRs:
```bash
npm run test # Unit tests (Vitest)
npm run test:e2e # E2E tests (Playwright)
```
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
To run a specific test file:
```bash
npx playwright test tests/e2e/diagram-generation.spec.ts
```
To run tests with UI mode:
```bash
npx playwright test --ui
```
## Pull Requests ## Pull Requests
1. Create a feature branch 1. Create a feature branch
2. Make changes (pre-commit runs lint + type check automatically) 2. Make changes and ensure `npm run check` passes
3. Run E2E tests with `npm run test:e2e` 3. Submit PR against `main` with a clear description
4. Push (pre-push runs unit tests automatically)
5. Submit PR against `main` with a clear description
CI will run the full test suite on your PR.
## Issues ## Issues

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"], "extends": ["config:recommended"],
"schedule": ["after 10am on the first day of the month"], "schedule": ["after 10am on saturday"],
"timezone": "Asia/Tokyo", "timezone": "Asia/Tokyo",
"packageRules": [ "packageRules": [
{ {
@@ -13,7 +13,6 @@
{ {
"matchUpdateTypes": ["major"], "matchUpdateTypes": ["major"],
"matchPackagePatterns": ["*"], "matchPackagePatterns": ["*"],
"groupName": "major dependencies",
"automerge": false "automerge": false
}, },
{ {

View File

@@ -11,8 +11,7 @@ on:
required: false required: false
jobs: jobs:
# Mac and Linux: Build and publish directly (no signing needed) build:
build-mac-linux:
permissions: permissions:
contents: write contents: write
strategy: strategy:
@@ -21,9 +20,13 @@ jobs:
include: include:
- os: macos-latest - os: macos-latest
platform: mac platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest - os: ubuntu-latest
platform: linux platform: linux
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -35,60 +38,9 @@ jobs:
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm install run: npm ci
- name: Build and publish - name: Build and publish Electron app
run: npm run dist:${{ matrix.platform }} run: npm run dist:${{ matrix.platform }}
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Windows: Build, sign with SignPath, then publish
build-windows:
permissions:
contents: write
runs-on: windows-latest
steps:
- name: Checkout code
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
# Build WITHOUT publishing
- name: Build Windows app
run: npm run dist:win:build
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload unsigned artifacts for signing
uses: actions/upload-artifact@v6
id: upload-unsigned
with:
name: windows-unsigned
path: release/*.exe
retention-days: 1
- name: Sign with SignPath
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
project-slug: 'next-ai-draw-io'
signing-policy-slug: 'test-signing'
artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: release-signed
- name: Upload signed artifacts to release
uses: softprops/action-gh-release@v2
with:
files: release-signed/*.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,75 +0,0 @@
name: Test
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lint-and-unit:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run check
- name: Run unit tests
run: npm run test -- --run
e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install chromium --with-deps
- name: Install Playwright deps (cached)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Build app
run: npm run build
- name: Run E2E tests
run: npm run test:e2e
env:
CI: true
- name: Upload test results
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7

7
.gitignore vendored
View File

@@ -14,8 +14,6 @@ packages/*/dist
# testing # testing
/coverage /coverage
/playwright-report/
/test-results/
# next.js # next.js
/.next/ /.next/
@@ -65,8 +63,3 @@ push-via-ec2.sh
CLAUDE.md CLAUDE.md
.spec-workflow .spec-workflow
# edgeone
.edgeone
opencode.json
ai-models.json

View File

@@ -1,2 +1 @@
npx lint-staged npx lint-staged
npx tsc --noEmit

View File

@@ -1,4 +0,0 @@
# Skip if node_modules not installed (e.g., on EC2 push server)
if [ -d "node_modules" ]; then
npm run test -- --run
fi

View File

@@ -30,10 +30,6 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE} ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
# Build Next.js application (standalone mode) # Build Next.js application (standalone mode)
RUN npm run build RUN npm run build

137
README.md
View File

@@ -4,7 +4,7 @@
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize** **AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md) English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
@@ -36,16 +36,15 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Try it Online](#try-it-online) - [Try it Online](#try-it-online)
- [Desktop Application](#desktop-application) - [Desktop Application](#desktop-application)
- [Run with Docker](#run-with-docker) - [Run with Docker (Recommended)](#run-with-docker-recommended)
- [Installation](#installation) - [Installation](#installation)
- [Deployment](#deployment) - [Deployment](#deployment)
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages) - [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
- [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support) - [Multi-Provider Support](#multi-provider-support)
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- [Project Structure](#project-structure)
- [Support \& Contact](#support--contact) - [Support \& Contact](#support--contact)
- [FAQ](#faq)
- [Star History](#star-history) - [Star History](#star-history)
## Examples ## Examples
@@ -101,7 +100,7 @@ Here are some example prompts and their generated diagrams:
## MCP Server (Preview) ## MCP Server (Preview)
> **Preview Feature**: This feature is experimental and may not be stable. > **Preview Feature**: This feature is experimental and may not stable.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol). Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
@@ -145,11 +144,53 @@ No installation needed! Try the app directly on our demo site:
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases): Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
Supported platforms: Windows, macOS, Linux. | Platform | Download |
|----------|----------|
| macOS | `.dmg` (Intel & Apple Silicon) |
| Windows | `.exe` installer (x64 & ARM64) |
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
### Run with Docker **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
[Go to Docker Guide](./docs/en/docker.md) **Quick Setup:**
1. Download and install for your platform
2. Open the app → **Menu → Configuration → Manage Presets**
3. Add your AI provider credentials
4. Start creating diagrams!
### Run with Docker (Recommended)
If you just want to run it locally, the best way is to use Docker.
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
Then run:
```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Or use an env file:
```bash
cp env.example .env
# Edit .env with your configuration
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options.
> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./docs/offline-deployment.md) for configuration options.
### Installation ### Installation
@@ -158,35 +199,45 @@ Supported platforms: Windows, macOS, Linux.
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
```
2. Install dependencies:
```bash
npm install npm install
```
3. Configure your AI provider:
Create a `.env.local` file in the root directory:
```bash
cp env.example .env.local cp env.example .env.local
``` ```
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider. Edit `.env.local` and configure your chosen provider:
2. Run the development server: - Set `AI_PROVIDER` to your chosen provider (doubao,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).
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
4. Run the development server:
```bash ```bash
npm run dev npm run dev
``` ```
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application. 5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
## Deployment ## Deployment
### Deploy to EdgeOne Pages ### Deploy on Vercel (Recommended)
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
Deploy by this button:
[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
### Deploy on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
@@ -196,7 +247,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
### Deploy on Cloudflare Workers ### Deploy on Cloudflare Workers
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md) [Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
@@ -207,28 +258,20 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- OpenAI - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Google Vertex AI
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
All providers except AWS Bedrock and OpenRouter support custom endpoints. All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider. 📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1. **Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
## How It Works ## How It Works
@@ -241,6 +284,24 @@ The application uses the following technologies:
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly. Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
## Project Structure
```
app/ # Next.js App Router
api/chat/ # Chat API endpoint with AI tools
page.tsx # Main page with DrawIO embed
components/ # React components
chat-panel.tsx # Chat interface with diagram control
chat-input.tsx # User input component with file upload
history-dialog.tsx # Diagram version history viewer
ui/ # UI components (buttons, cards, etc.)
contexts/ # React context providers
diagram-context.tsx # Global diagram state management
lib/ # Utility functions and helpers
ai-providers.ts # Multi-provider AI configuration
utils.ts # XML processing and conversion utilities
public/ # Static assets including example images
```
## Support & Contact ## Support & Contact
@@ -252,10 +313,6 @@ For support or inquiries, please open an issue on the GitHub repository or conta
- Email: me[at]jiang.jp - Email: me[at]jiang.jp
## FAQ
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)

View File

@@ -10,7 +10,18 @@ export const metadata: Metadata = {
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"], keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
} }
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function AboutCN() { export default function AboutCN() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -61,6 +72,28 @@ export default function AboutCN() {
<p className="text-xl text-gray-600 font-medium"> <p className="text-xl text-gray-600 font-medium">
AI驱动的图表创建工具 - AI驱动的图表创建工具 -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</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="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
@@ -97,6 +130,42 @@ export default function AboutCN() {
</p> </p>
</div> </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">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
Token/
</p>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -292,13 +361,11 @@ export default function AboutCN() {
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
<li>Ollama</li> <li>Ollama</li>
<li>OpenRouter</li> <li>OpenRouter</li>
<li>DeepSeek</li> <li>DeepSeek</li>
<li>SiliconFlow</li> <li>SiliconFlow</li>
<li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>{" "} <code>claude-sonnet-4-5</code>{" "}

View File

@@ -17,7 +17,18 @@ export const metadata: Metadata = {
], ],
} }
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function AboutJA() { export default function AboutJA() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -69,6 +80,28 @@ export default function AboutJA() {
AI搭載のダイアグラム作成ツール - AI搭載のダイアグラム作成ツール -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link>
</div>
</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="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
@@ -105,6 +138,42 @@ export default function AboutJA() {
</p> </p>
</div> </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">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -307,13 +376,11 @@ export default function AboutJA() {
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
<li>Ollama</li> <li>Ollama</li>
<li>OpenRouter</li> <li>OpenRouter</li>
<li>DeepSeek</li> <li>DeepSeek</li>
<li>SiliconFlow</li> <li>SiliconFlow</li>
<li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code> <code>claude-sonnet-4-5</code>

View File

@@ -17,7 +17,18 @@ export const metadata: Metadata = {
], ],
} }
function formatNumber(num: number): string {
if (num >= 1000) {
return `${num / 1000}k`
}
return num.toString()
}
export default function About() { export default function About() {
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -69,6 +80,28 @@ export default function About() {
AI-Powered Diagram Creation Tool - Chat, Draw, AI-Powered Diagram Creation Tool - Chat, Draw,
Visualize Visualize
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-blue-600 font-semibold"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</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="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
@@ -107,6 +140,42 @@ export default function About() {
</p> </p>
</div> </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">
{formatNumber(tpmLimit)}
</p>
<p className="text-xs text-gray-500">
tokens/min
</p>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -326,13 +395,11 @@ export default function About() {
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
<li>Ollama</li> <li>Ollama</li>
<li>OpenRouter</li> <li>OpenRouter</li>
<li>DeepSeek</li> <li>DeepSeek</li>
<li>SiliconFlow</li> <li>SiliconFlow</li>
<li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on Note that <code>claude-sonnet-4-5</code> has trained on

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { Suspense, useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -16,22 +17,49 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } = const {
useDiagram() drawioRef,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [darkMode, setDarkMode] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isSavingRef = useRef(false)
const mouseOverDrawioRef = useRef(false)
const isMobileRef = useRef(false) const isMobileRef = useRef(false)
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
useEffect(() => {
if (!showSaveDialog) {
const timeout = setTimeout(() => {
isSavingRef.current = false
}, 1000)
return () => clearTimeout(timeout)
}
}, [showSaveDialog])
// Handle save from draw.io's built-in save button
// Note: draw.io sends save events for various reasons (focus changes, etc.)
// We use mouse position to determine if the user is interacting with draw.io
const handleDrawioSave = useCallback(() => {
if (!mouseOverDrawioRef.current) return
if (isSavingRef.current) return
isSavingRef.current = true
setShowSaveDialog(true)
}, [setShowSaveDialog])
// Load preferences from localStorage after mount // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
// Restore saved locale and redirect if needed // Restore saved locale and redirect if needed
@@ -64,32 +92,34 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark) document.documentElement.classList.toggle("dark", prefersDark)
} }
const savedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
if (savedCloseProtection === "true") {
setCloseProtection(true)
}
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
const handleDrawioLoad = useCallback(() => { const handleDarkModeChange = async () => {
setIsDrawioReady(true) await saveDiagramToStorage()
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = () => {
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue)) localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue) document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
const handleDrawioUiChange = () => { const handleDrawioUiChange = async () => {
await saveDiagramToStorage()
const newUi = drawioUi === "min" ? "sketch" : "min" const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi) localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi) setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
// Check mobile - reset draw.io before crossing breakpoint // Check mobile - save diagram and reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true) const isInitialRenderRef = useRef(true)
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
@@ -98,7 +128,7 @@ export default function Home() {
!isInitialRenderRef.current && !isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current newIsMobile !== isMobileRef.current
) { ) {
setIsDrawioReady(false) saveDiagramToStorage().catch(() => {})
resetDrawioReady() resetDrawioReady()
} }
isMobileRef.current = newIsMobile isMobileRef.current = newIsMobile
@@ -109,7 +139,7 @@ export default function Home() {
checkMobile() checkMobile()
window.addEventListener("resize", checkMobile) window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile) return () => window.removeEventListener("resize", checkMobile)
}, [resetDrawioReady]) }, [saveDiagramToStorage, resetDrawioReady])
const toggleChatPanel = () => { const toggleChatPanel = () => {
const panel = chatPanelRef.current const panel = chatPanelRef.current
@@ -137,6 +167,20 @@ export default function Home() {
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown)
}, []) }, [])
// Show confirmation dialog when user tries to leave the page
useEffect(() => {
if (!closeProtection) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
return ""
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () =>
window.removeEventListener("beforeunload", handleBeforeUnload)
}, [closeProtection])
return ( return (
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup <ResizablePanelGroup
@@ -153,36 +197,34 @@ export default function Home() {
className={`h-full relative ${ className={`h-full relative ${
isMobile ? "p-1" : "p-2" isMobile ? "p-1" : "p-2"
}`} }`}
onMouseEnter={() => {
mouseOverDrawioRef.current = true
}}
onMouseLeave={() => {
mouseOverDrawioRef.current = false
}}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded && ( {isLoaded ? (
<div <DrawIoEmbed
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} key={`${drawioUi}-${darkMode}`}
> ref={drawioRef}
<DrawIoEmbed onExport={handleDiagramExport}
key={`${drawioUi}-${darkMode}-${currentLang}`} onLoad={onDrawioLoad}
ref={drawioRef} onSave={handleDrawioSave}
onExport={handleDiagramExport} baseUrl={drawioBaseUrl}
onLoad={handleDrawioLoad} urlParameters={{
baseUrl={drawioBaseUrl} ui: drawioUi,
urlParameters={{ spin: true,
ui: drawioUi, libraries: false,
spin: false, saveAndExit: false,
libraries: false, noExitBtn: true,
saveAndExit: false, dark: darkMode,
noSaveBtn: true, }}
noExitBtn: true, />
dark: darkMode, ) : (
lang: currentLang, <div className="h-full w-full flex items-center justify-center bg-background">
}} <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
/>
</div>
)}
{(!isLoaded || !isDrawioReady) && (
<div className="h-full w-full bg-background flex items-center justify-center">
<span className="text-muted-foreground">
Draw.io panel is loading...
</span>
</div> </div>
)} )}
</div> </div>
@@ -205,23 +247,16 @@ export default function Home() {
onExpand={() => setIsChatVisible(true)} onExpand={() => setIsChatVisible(true)}
> >
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}> <div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<Suspense <ChatPanel
fallback={ isVisible={isChatVisible}
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground"> onToggleVisibility={toggleChatPanel}
Loading chat... drawioUi={drawioUi}
</div> onToggleDrawioUi={handleDrawioUiChange}
} darkMode={darkMode}
> onToggleDarkMode={handleDarkModeChange}
<ChatPanel isMobile={isMobile}
isVisible={isChatVisible} onCloseProtectionChange={setCloseProtection}
onToggleVisibility={toggleChatPanel} />
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
/>
</Suspense>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>

View File

@@ -12,17 +12,8 @@ import fs from "fs/promises"
import { jsonrepair } from "jsonrepair" import { jsonrepair } from "jsonrepair"
import path from "path" import path from "path"
import { z } from "zod" import { z } from "zod"
import { import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
getAIModel,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import {
isMinimalDiagram,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import { import {
checkAndIncrementRequest, checkAndIncrementRequest,
isQuotaEnabled, isQuotaEnabled,
@@ -34,12 +25,98 @@ import {
setTraceOutput, setTraceOutput,
wrapWithObserve, wrapWithObserve,
} from "@/lib/langfuse" } from "@/lib/langfuse"
import { findServerModelById } from "@/lib/server-model-config"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120 export const maxDuration = 120
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Helper function to validate file parts in messages
function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) {
return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
}
}
}
}
return { valid: true }
}
// Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
})
}
// Helper function to create cached stream response // Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response { function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}` const toolCallId = `cached-${Date.now()}`
@@ -167,45 +244,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === CACHE CHECK END === // === CACHE CHECK END ===
// Read client AI provider overrides from headers // Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
const selectedModelId = req.headers.get("x-selected-model-id")
// For EdgeOne provider, construct full URL from request origin
// because createOpenAI needs absolute URL, not relative path
if (provider === "edgeone" && !baseUrl) {
const origin = req.headers.get("origin") || new URL(req.url).origin
baseUrl = `${origin}/api/edgeai`
}
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req.headers.get("cookie")
// Check if this is a server model with custom env var names
let serverModelConfig: {
apiKeyEnv?: string
baseUrlEnv?: string
provider?: string
} = {}
if (selectedModelId?.startsWith("server:")) {
const serverModel = await findServerModelById(selectedModelId)
console.log(
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
)
if (serverModel) {
serverModelConfig = {
apiKeyEnv: serverModel.apiKeyEnv,
baseUrlEnv: serverModel.baseUrlEnv,
// Use actual provider from config (client header may have incorrect value due to ID format change)
provider: serverModel.provider,
}
}
}
const clientOverrides = { const clientOverrides = {
// Server model provider takes precedence over client header provider: req.headers.get("x-ai-provider"),
provider: serverModelConfig.provider || provider, baseUrl: req.headers.get("x-ai-base-url"),
baseUrl,
apiKey: req.headers.get("x-ai-api-key"), apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"), modelId: req.headers.get("x-ai-model"),
// AWS Bedrock credentials // AWS Bedrock credentials
@@ -213,24 +254,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"), awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"), awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"), awsSessionToken: req.headers.get("x-aws-session-token"),
// Server model custom env var names
...serverModelConfig,
// Vertex AI credentials (Express Mode)
vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication
...(provider === "edgeone" &&
cookieHeader && {
headers: { cookie: cookieHeader },
}),
} }
// Read minimal style preference from header // Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true" const minimalStyle = req.headers.get("x-minimal-style") === "true"
console.log(
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
)
// Get AI model with optional client overrides // Get AI model with optional client overrides
const { model, providerOptions, headers, modelId } = const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides) getAIModel(clientOverrides)
@@ -249,17 +277,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
lastUserMessage?.parts?.filter((part: any) => part.type === "file") || lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[] []
// Check if user is sending images to a model that doesn't support them
// AI SDK silently drops unsupported parts, so we need to catch this early
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
return Response.json(
{
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
},
{ status: 400 },
)
}
// User input only - XML is now in a separate cached system message // User input only - XML is now in a separate cached system message
const formattedUserInput = `User input: const formattedUserInput = `User input:
"""md """md
@@ -473,13 +490,6 @@ ${userInputText}
inputToRepair = inputToRepair.replace(/:=/g, ": ") inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "` // Fix `= "` instead of `: "`
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "') inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
// Fix inconsistent quote escaping in XML attributes within JSON strings
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
// Example: y="-20\" should be y=\"-20\"
inputToRepair = inputToRepair.replace(
/(\w+)="([^"]*?)\\"/g,
'$1=\\"$2\\"',
)
} }
// Use jsonrepair to fix truncated JSON // Use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(inputToRepair) const repairedInput = jsonrepair(inputToRepair)
@@ -595,7 +605,7 @@ Notes:
Operations: Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml. - 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. - 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. For update/add, new_xml must be a complete mxCell element including mxGeometry.
@@ -604,8 +614,8 @@ For update/add, new_xml must be a complete mxCell element including mxGeometry.
Example - Add a rectangle: 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>"}]} {"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): Example - Delete a cell:
{"operations": [{"operation": "delete", "cell_id": "2"}]}`, {"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
inputSchema: z.object({ inputSchema: z.object({
operations: z operations: z
.array( .array(

View File

@@ -1,104 +0,0 @@
import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000
export async function POST(req: Request) {
try {
const { url } = await req.json()
if (!url || typeof url !== "string") {
return NextResponse.json(
{ error: "URL is required" },
{ status: 400 },
)
}
// Validate URL format
try {
new URL(url)
} catch {
return NextResponse.json(
{ error: "Invalid URL format" },
{ status: 400 },
)
}
// SSRF protection
if (!allowPrivateUrls && isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
// Extract article content with timeout to avoid tying up server resources
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let article
try {
article = await extract(url, undefined, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
},
signal: controller.signal,
})
} catch (err: any) {
if (err?.name === "AbortError") {
return NextResponse.json(
{ error: "Timed out while fetching URL content" },
{ status: 504 },
)
}
throw err
} finally {
clearTimeout(timeoutId)
}
if (!article || !article.content) {
return NextResponse.json(
{ error: "Could not extract content from URL" },
{ status: 400 },
)
}
// Convert HTML to Markdown
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
})
// Remove unwanted elements before conversion
turndownService.remove(["script", "style", "iframe", "noscript"])
const markdown = turndownService.turndown(article.content)
// Check content length
if (markdown.length > MAX_CONTENT_LENGTH) {
return NextResponse.json(
{
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
},
{ status: 400 },
)
}
return NextResponse.json({
title: article.title || "Untitled",
content: markdown,
charCount: markdown.length,
})
} catch (error) {
console.error("URL extraction error:", error)
return NextResponse.json(
{ error: "Failed to fetch or parse URL content" },
{ status: 500 },
)
}
}

View File

@@ -1,14 +0,0 @@
import { NextResponse } from "next/server"
import { loadFlattenedServerModels } from "@/lib/server-model-config"
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
// This ensures Docker users can set these values when starting containers
export const dynamic = "force-dynamic"
export async function GET() {
const models = await loadFlattenedServerModels()
return NextResponse.json({
models,
hasConfig: models.length > 0,
})
}

View File

@@ -1,136 +0,0 @@
/**
* API endpoint for VLM-based diagram validation.
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { streamObject } from "ai"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export const maxDuration = 30
interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL
sessionId?: string
}
// Default valid result for disabled/error cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
function createStreamingResponse(result: ValidationResult): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
export async function POST(req: Request): Promise<Response> {
try {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
if (!enableValidation) {
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
const { imageData, sessionId } = body
if (!imageData) {
return Response.json(
{ error: "Missing imageData" },
{ status: 400 },
)
}
// Validate image data format
if (
!imageData.startsWith("data:image/png;base64,") &&
!imageData.startsWith("data:image/")
) {
return Response.json(
{ error: "Invalid image data format" },
{ status: 400 },
)
}
// Get the validation model
let model
try {
model = getValidationModel()
} catch (error) {
console.warn(
"[validate-diagram] Validation model not available:",
error,
)
// Return valid if no vision model is configured
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
const timeout =
Math.max(
1000,
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000
// Stream the VLM response for useObject consumption
const result = streamObject({
model,
schema: ValidationResultSchema,
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
role: "user",
content: [
{
type: "image",
image: imageData,
},
{
type: "text",
text: "Please analyze this diagram for visual quality issues.",
},
],
},
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ object }) => {
if (sessionId && object) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
)
}
},
})
return result.toTextStreamResponse()
} catch (error) {
// Log with session context if available
const errorMessage =
error instanceof Error ? error.message : String(error)
console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
}

View File

@@ -3,16 +3,74 @@ import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway" import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google" import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai" import { createOpenAI } from "@ai-sdk/openai"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai" import { generateText } from "ai"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2" import { createOllama } from "ollama-ai-provider-v2"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
export const runtime = "nodejs" export const runtime = "nodejs"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
interface ValidateRequest { interface ValidateRequest {
provider: string provider: string
apiKey: string apiKey: string
@@ -22,8 +80,6 @@ interface ValidateRequest {
awsAccessKeyId?: string awsAccessKeyId?: string
awsSecretAccessKey?: string awsSecretAccessKey?: string
awsRegion?: string awsRegion?: string
// Vertex AI specific
vertexApiKey?: string // Express Mode API key
} }
export async function POST(req: Request) { export async function POST(req: Request) {
@@ -37,8 +93,6 @@ export async function POST(req: Request) {
awsAccessKeyId, awsAccessKeyId,
awsSecretAccessKey, awsSecretAccessKey,
awsRegion, awsRegion,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body } = body
if (!provider || !modelId) { if (!provider || !modelId) {
@@ -49,7 +103,7 @@ export async function POST(req: Request) {
} }
// SECURITY: Block SSRF attacks via custom baseUrl // SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) { if (baseUrl && isPrivateUrl(baseUrl)) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Invalid base URL" }, { valid: false, error: "Invalid base URL" },
{ status: 400 }, { status: 400 },
@@ -67,17 +121,7 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
) )
} }
} else if (provider === "vertexai") { } else if (provider !== "ollama" && !apiKey) {
if (!vertexApiKey) {
return NextResponse.json(
{
valid: false,
error: "Vertex AI API key is required for Express Mode",
},
{ status: 400 },
)
}
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "API key is required" }, { valid: false, error: "API key is required" },
{ status: 400 }, { status: 400 },
@@ -114,15 +158,6 @@ export async function POST(req: Request) {
break break
} }
case "vertexai": {
const vertex = createVertex({
apiKey: vertexApiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = vertex(modelId)
break
}
case "azure": { case "azure": {
const azure = createOpenAI({ const azure = createOpenAI({
apiKey, apiKey,
@@ -167,7 +202,7 @@ export async function POST(req: Request) {
case "siliconflow": { case "siliconflow": {
const sf = createOpenAI({ const sf = createOpenAI({
apiKey, apiKey,
baseURL: baseUrl || "https://api.siliconflow.cn/v1", baseURL: baseUrl || "https://api.siliconflow.com/v1",
}) })
model = sf.chat(modelId) model = sf.chat(modelId)
break break
@@ -190,21 +225,6 @@ export async function POST(req: Request) {
break break
} }
case "edgeone": {
// EdgeOne uses OpenAI-compatible API via Edge Functions
// Need to pass cookies for EdgeOne Pages authentication
const cookieHeader = req.headers.get("cookie") || ""
const edgeone = createOpenAI({
apiKey: "edgeone", // EdgeOne doesn't require API key
baseURL: baseUrl || "/api/edgeai",
headers: {
cookie: cookieHeader,
},
})
model = edgeone.chat(modelId)
break
}
case "sglang": { case "sglang": {
// SGLang is OpenAI-compatible // SGLang is OpenAI-compatible
const sglang = createOpenAI({ const sglang = createOpenAI({
@@ -216,98 +236,16 @@ export async function POST(req: Request) {
} }
case "doubao": { case "doubao": {
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others // ByteDance Doubao uses DeepSeek-compatible API
const doubaoBaseUrl = const doubao = createDeepSeek({
baseUrl || "https://ark.cn-beijing.volces.com/api/v3" apiKey,
const lowerModelId = modelId.toLowerCase() baseURL:
if ( baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
lowerModelId.includes("deepseek") || })
lowerModelId.includes("kimi") model = doubao(modelId)
) {
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
break break
} }
case "modelscope": {
const baseURL =
baseUrl || "https://api-inference.modelscope.cn/v1"
const startTime = Date.now()
try {
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
const response = await fetch(
`${baseURL}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: [
{ role: "user", content: "Say 'OK'" },
],
max_tokens: 20,
stream: true,
enable_thinking: false,
}),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`ModelScope API error (${response.status}): ${errorText}`,
)
}
const contentType =
response.headers.get("content-type") || ""
const isValidStreamingResponse =
response.status === 200 &&
(contentType.includes("text/event-stream") ||
contentType.includes("application/json"))
if (!isValidStreamingResponse) {
throw new Error(
`Unexpected response format: ${contentType}`,
)
}
const responseTime = Date.now() - startTime
if (response.body) {
response.body.cancel().catch(() => {
/* Ignore cancellation errors */
})
}
return NextResponse.json({
valid: true,
responseTime,
note: "ModelScope model validated (using streaming API)",
})
} catch (error) {
console.error(
"[validate-model] ModelScope validation failed:",
error,
)
throw error
}
}
default: default:
return NextResponse.json( return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` }, { valid: false, error: `Unknown provider: ${provider}` },

View File

@@ -74,8 +74,8 @@
--accent: oklch(0.94 0.03 280); --accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270); --accent-foreground: oklch(0.35 0.08 270);
/* Muted rose destructive */ /* Coral destructive */
--destructive: oklch(0.45 0.12 10); --destructive: oklch(0.6 0.2 25);
/* Subtle borders */ /* Subtle borders */
--border: oklch(0.92 0.01 260); --border: oklch(0.92 0.01 260);
@@ -122,7 +122,7 @@
--accent: oklch(0.3 0.04 280); --accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.9 0.03 270); --accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.55 0.12 10); --destructive: oklch(0.65 0.22 25);
--border: oklch(0.28 0.015 260); --border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260); --input: oklch(0.25 0.015 260);
@@ -244,19 +244,6 @@
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.75 0.01 260); background-color: oklch(0.75 0.01 260);
} }
/* Dark mode scrollbar */
.dark .scrollbar-thin {
scrollbar-color: oklch(0.35 0.015 260) transparent;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.35 0.015 260);
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.45 0.015 260);
}
} }
/* Smooth page transitions */ /* Smooth page transitions */

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -134,7 +134,6 @@ export const ModelSelectorLogo = ({
} }
return ( return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img <img
{...props} {...props}
alt={`${provider} logo`} alt={`${provider} logo`}
@@ -169,27 +168,3 @@ export const ModelSelectorName = ({
}: ModelSelectorNameProps) => ( }: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} /> <span className={cn("flex-1 truncate text-left", className)} {...props} />
) )
export type ModelSelectorSectionHeaderProps = {
icon: ReactNode
label: string
className?: string
}
export const ModelSelectorSectionHeader = ({
icon,
label,
className,
}: ModelSelectorSectionHeaderProps) => (
<div
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
className,
)}
>
<span className="[&>svg]:size-3.5" aria-hidden="true">
{icon}
</span>
<span>{label}</span>
</div>
)

View File

@@ -70,11 +70,9 @@ function ExampleCard({
export default function ExamplePanel({ export default function ExamplePanel({
setInput, setInput,
setFiles, setFiles,
minimal = false,
}: { }: {
setInput: (input: string) => void setInput: (input: string) => void
setFiles: (files: File[]) => void setFiles: (files: File[]) => void
minimal?: boolean
}) { }) {
const dict = useDictionary() const dict = useDictionary()
@@ -122,55 +120,49 @@ export default function ExamplePanel({
} }
return ( return (
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}> <div className="py-6 px-2 animate-fade-in">
{!minimal && ( {/* MCP Server Notice */}
<> <a
{/* MCP Server Notice */} href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
<a target="_blank"
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server" rel="noopener noreferrer"
target="_blank" className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
rel="noopener noreferrer" >
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group" <div className="flex items-center gap-3">
> <div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<div className="flex items-center gap-3"> <Terminal className="w-4 h-4 text-purple-500" />
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0"> </div>
<Terminal className="w-4 h-4 text-purple-500" /> <div className="min-w-0">
</div> <div className="flex items-center gap-2">
<div className="min-w-0"> <span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
<div className="flex items-center gap-2"> {dict.examples.mcpServer}
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors"> </span>
{dict.examples.mcpServer} <span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
</span> {dict.examples.preview}
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded"> </span>
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div> </div>
</a> <p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
</p> </p>
</div> </div>
</> </div>
)} </a>
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
</p>
</div>
{/* Examples grid */} {/* Examples grid */}
<div className="space-y-3"> <div className="space-y-3">
{!minimal && ( <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1"> {dict.examples.quickExamples}
{dict.examples.quickExamples} </p>
</p>
)}
<div className="grid gap-2"> <div className="grid gap-2">
<ExampleCard <ExampleCard

View File

@@ -4,37 +4,27 @@ import {
Download, Download,
History, History,
Image as ImageIcon, Image as ImageIcon,
Link,
Loader2, Loader2,
Send, Send,
Trash2,
} from "lucide-react" } from "lucide-react"
import type React from "react" import type React from "react"
import { import { useCallback, useEffect, useRef, useState } from "react"
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast" import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog" import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector" import { ModelSelector } from "@/components/model-selector"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog" import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config" import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { isRealDiagram } from "@/lib/utils"
import { FilePreviewList } from "./file-preview-list" import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -145,23 +135,18 @@ function showValidationErrors(errors: string[], dict: any) {
} }
} }
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps { interface ChatInputProps {
input: string input: string
status: "submitted" | "streaming" | "ready" | "error" status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onClearChat: () => void
files?: File[] files?: File[]
onFileChange?: (files: File[]) => void onFileChange?: (files: File[]) => void
pdfData?: Map< pdfData?: Map<
File, File,
{ text: string; charCount: number; isExtracting: boolean } { text: string; charCount: number; isExtracting: boolean }
> >
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string sessionId?: string
error?: Error | null error?: Error | null
@@ -171,217 +156,90 @@ interface ChatInputProps {
onModelSelect?: (modelId: string | undefined) => void onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean showUnvalidatedModels?: boolean
onConfigureModels?: () => void onConfigureModels?: () => void
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
} }
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>( export function ChatInput({
function ChatInput( input,
{ status,
input, onSubmit,
status, onChange,
onSubmit, onClearChat,
onChange, files = [],
files = [], onFileChange = () => {},
onFileChange = () => {}, pdfData = new Map(),
pdfData = new Map(), sessionId,
urlData, error = null,
onUrlChange, models = [],
sessionId, selectedModelId,
error = null, onModelSelect = () => {},
models = [], showUnvalidatedModels = false,
selectedModelId, onConfigureModels = () => {},
onModelSelect = () => {}, }: ChatInputProps) {
showUnvalidatedModels = false, const dict = useDictionary()
onConfigureModels = () => {}, const { diagramHistory, saveDiagramToFile } = useDiagram()
shouldFocus = false,
onFocused,
},
ref,
) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [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
// Expose focus method via ref const adjustTextareaHeight = useCallback(() => {
useImperativeHandle(ref, () => ({ const textarea = textareaRef.current
focus: () => { if (textarea) {
textareaRef.current?.focus() textarea.style.height = "auto"
}, textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
})) }
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Focus the textarea when shouldFocus becomes true const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// Use setTimeout to ensure focus happens after drawio iframe settles onChange(e)
useEffect(() => { adjustTextareaHeight()
if (shouldFocus) { }
const timer = setTimeout(() => {
textareaRef.current?.focus() const handleKeyDown = (e: React.KeyboardEvent) => {
onFocused?.() if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
}, 150) e.preventDefault()
return () => clearTimeout(timer) const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
form.requestSubmit()
} }
}, [shouldFocus, onFocused]) }
}
const [showHistory, setShowHistory] = useState(false) const handlePaste = async (e: React.ClipboardEvent) => {
const [showUrlDialog, setShowUrlDialog] = useState(false) if (isDisabled) return
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
const adjustTextareaHeight = useCallback(() => { const items = e.clipboardData.items
const textarea = textareaRef.current const imageItems = Array.from(items).filter((item) =>
if (textarea) { item.type.startsWith("image/"),
textarea.style.height = "auto" )
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes if (imageItems.length > 0) {
useEffect(() => { const imageFiles = (
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut) await Promise.all(
if (stored) setSendShortcut(stored) imageItems.map(async (item, index) => {
const file = item.getAsFile()
const handleChange = (e: CustomEvent<string>) => if (!file) return null
setSendShortcut(e.detail) return new File(
window.addEventListener( [file],
"sendShortcutChange", `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
handleChange as EventListener, { type: file.type },
) )
return () => }),
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
) )
}, []) ).filter((f): f is File => f !== null)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
form.requestSubmit()
}
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles( const { validFiles, errors } = validateFiles(
supportedFiles, imageFiles,
files.length, files.length,
dict, dict,
) )
@@ -390,97 +248,132 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
onFileChange([...files, ...validFiles]) onFileChange([...files, ...validFiles])
} }
} }
}
const handleUrlExtract = async (url: string) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!onUrlChange) return const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
setIsExtractingUrl(true) newFiles,
files.length,
try { dict,
const existing = urlData )
? new Map(urlData) showValidationErrors(errors, dict)
: new Map<string, UrlData>() if (validFiles.length > 0) {
existing.set(url, { onFileChange([...files, ...validFiles])
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
} }
return ( if (fileInputRef.current) {
<form fileInputRef.current.value = ""
onSubmit={onSubmit} }
className={`w-full transition-all duration-200 ${ }
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl" const handleRemoveFile = (fileToRemove: File) => {
: "" onFileChange(files.filter((file) => file !== fileToRemove))
}`} if (fileInputRef.current) {
onDragOver={handleDragOver} fileInputRef.current.value = ""
onDragLeave={handleDragLeave} }
onDrop={handleDrop} }
>
{/* File & URL previews */} const triggerFileInput = () => {
{(files.length > 0 || (urlData && urlData.size > 0)) && ( fileInputRef.current?.click()
<div className="mb-3"> }
<FilePreviewList
files={files} const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
onRemoveFile={handleRemoveFile} e.preventDefault()
pdfData={pdfData} e.stopPropagation()
urlData={urlData} setIsDragging(true)
onRemoveUrl={ }
onUrlChange
? (url) => { const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
const next = new Map(urlData) e.preventDefault()
next.delete(url) e.stopPropagation()
onUrlChange(next) setIsDragging(false)
} }
: undefined
} const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles(
supportedFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleClear = () => {
onClearChat()
setShowClearDialog(false)
}
return (
<form
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File previews */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
/>
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
/>
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
tooltipContent={dict.chat.clearConversation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</ButtonWithTooltip>
<ResetWarningModal
open={showClearDialog}
onOpenChange={setShowClearDialog}
onClear={handleClear}
/> />
</div> </div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50"> <div className="flex items-center gap-1 overflow-hidden justify-end">
<div className="flex items-center gap-1 overflow-x-hidden"> <div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip <ButtonWithTooltip
type="button" type="button"
@@ -501,9 +394,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowSaveDialog(true)} onClick={() => setShowSaveDialog(true)}
disabled={ disabled={isDisabled}
isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram} tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
> >
@@ -522,20 +413,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
</ButtonWithTooltip> </ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -575,34 +452,21 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
</Button> </Button>
</div> </div>
</div> </div>
<HistoryDialog </div>
showHistory={showHistory} <HistoryDialog
onToggleHistory={setShowHistory} showHistory={showHistory}
/> onToggleHistory={setShowHistory}
<SaveDialog />
open={showSaveDialog} <SaveDialog
onOpenChange={setShowSaveDialog} open={showSaveDialog}
onSave={(filename, format) => onOpenChange={setShowSaveDialog}
saveDiagramToFile( onSave={(filename, format) =>
filename, saveDiagramToFile(filename, format, sessionId)
format, }
sessionId, defaultFilename={`diagram-${new Date()
dict.save.savedSuccessfully, .toISOString()
) .slice(0, 10)}`}
} />
defaultFilename={`diagram-${new Date() </form>
.toISOString() )
.slice(0, 10)}`} }
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form>
)
},
)

View File

@@ -7,6 +7,7 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Copy, Copy,
Cpu,
FileCode, FileCode,
FileText, FileText,
Pencil, Pencil,
@@ -25,21 +26,24 @@ import {
ReasoningContent, ReasoningContent,
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning" } from "@/components/ai-elements/reasoning"
import { ChatLobby } from "@/components/chat/ChatLobby"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import type { ValidationState } from "@/components/chat/ValidationCard"
import { ValidationCard } from "@/components/chat/ValidationCard"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { import {
applyDiagramOperations, applyDiagramOperations,
convertToLegalXml, convertToLegalXml,
extractCompleteMxCells, extractCompleteMxCells,
isMxCellXmlComplete,
replaceNodes, replaceNodes,
validateAndFixXml, validateAndFixXml,
} from "@/lib/utils" } from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
// Helper to extract complete operations from streaming input // Helper to extract complete operations from streaming input
function getCompleteOperations( function getCompleteOperations(
@@ -53,10 +57,60 @@ function getCompleteOperations(
["update", "add", "delete"].includes(op.operation) && ["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" && typeof op.cell_id === "string" &&
op.cell_id.length > 0 && op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do
(op.operation === "delete" || typeof op.new_xml === "string"), (op.operation === "delete" || typeof op.new_xml === "string"),
) )
} }
// Tool part interface for type safety
interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
? "text-red-600"
: op.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span>
</div>
{op.new_xml && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
</pre>
</div>
)}
</div>
))}
</div>
)
}
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file sections (PDF or text files) // Helper to split text content into regular text and file sections (PDF or text files)
@@ -128,13 +182,6 @@ const getUserOriginalText = (message: UIMessage): string => {
return fullText.replace(filePattern, "").trim() return fullText.replace(filePattern, "").trim()
} }
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatMessageDisplayProps { interface ChatMessageDisplayProps {
messages: UIMessage[] messages: UIMessage[]
setInput: (input: string) => void setInput: (input: string) => void
@@ -145,13 +192,6 @@ interface ChatMessageDisplayProps {
onRegenerate?: (messageIndex: number) => void onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void onEditMessage?: (messageIndex: number, newText: string) => void
status?: "streaming" | "submitted" | "idle" | "error" | "ready" status?: "streaming" | "submitted" | "idle" | "error" | "ready"
isRestored?: boolean
sessions?: SessionMetadata[]
onSelectSession?: (id: string) => void
onDeleteSession?: (id: string) => void
loadedMessageIdsRef?: MutableRefObject<Set<string>>
validationStates?: Record<string, ValidationState>
onImproveWithSuggestions?: (feedback: string) => void
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
@@ -164,35 +204,13 @@ export function ChatMessageDisplay({
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
status = "idle", status = "idle",
isRestored = false,
sessions = [],
onSelectSession,
onDeleteSession,
loadedMessageIdsRef,
validationStates = {},
onImproveWithSuggestions,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("") const previousXML = useRef<string>("")
const processedToolCalls = processedToolCallsRef const processedToolCalls = processedToolCallsRef
// Track the last processed XML per toolCallId to skip redundant processing during streaming // Track the last processed XML per toolCallId to skip redundant processing during streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map()) const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Reset refs when messages become empty (new chat or session switch)
// This ensures cached examples work correctly after starting a new session
useEffect(() => {
if (messages.length === 0) {
previousXML.current = ""
lastProcessedXmlRef.current.clear()
// Note: processedToolCalls is passed from parent, so we clear it too
processedToolCalls.current.clear()
// Scroll to top to show newest history items
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
}
}, [messages.length, processedToolCalls])
// Debounce streaming diagram updates - store pending XML and timeout // Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null) const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
@@ -210,12 +228,6 @@ export function ChatMessageDisplay({
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>( const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{}, {},
) )
const [copiedToolCallId, setCopiedToolCallId] = useState<string | null>(
null,
)
const [copyFailedToolCallId, setCopyFailedToolCallId] = useState<
string | null
>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null) const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [copyFailedMessageId, setCopyFailedMessageId] = useState< const [copyFailedMessageId, setCopyFailedMessageId] = useState<
string | null string | null
@@ -231,39 +243,13 @@ export function ChatMessageDisplay({
Record<string, boolean> Record<string, boolean>
>({}) >({})
const setCopyState = ( const copyMessageToClipboard = async (messageId: string, text: string) => {
messageId: string,
isToolCall: boolean,
isSuccess: boolean,
) => {
if (isSuccess) {
if (isToolCall) {
setCopiedToolCallId(messageId)
setTimeout(() => setCopiedToolCallId(null), 2000)
} else {
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
}
} else {
if (isToolCall) {
setCopyFailedToolCallId(messageId)
setTimeout(() => setCopyFailedToolCallId(null), 2000)
} else {
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
}
}
}
const copyMessageToClipboard = async (
messageId: string,
text: string,
isToolCall = false,
) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
setCopyState(messageId, isToolCall, true)
} catch (_err) { setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
// Fallback for non-secure contexts (HTTP) or permission denied // Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea") const textarea = document.createElement("textarea")
textarea.value = text textarea.value = text
@@ -278,11 +264,15 @@ export function ChatMessageDisplay({
if (!success) { if (!success) {
throw new Error("Copy command failed") throw new Error("Copy command failed")
} }
setCopyState(messageId, isToolCall, true) setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (fallbackErr) { } catch (fallbackErr) {
console.error("Failed to copy message:", fallbackErr) console.error("Failed to copy message:", fallbackErr)
toast.error(dict.chat.failedToCopyDetail) toast.error(
setCopyState(messageId, isToolCall, false) "Failed to copy message. Please copy manually or check clipboard permissions.",
)
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
} finally { } finally {
document.body.removeChild(textarea) document.body.removeChild(textarea)
} }
@@ -314,7 +304,7 @@ export function ChatMessageDisplay({
}) })
} catch (error) { } catch (error) {
console.error("Failed to log feedback:", error) console.error("Failed to log feedback:", error)
toast.error(dict.errors.failedToRecordFeedback) toast.error("Failed to record your feedback. Please try again.")
// Revert optimistic UI update // Revert optimistic UI update
setFeedback((prev) => { setFeedback((prev) => {
const next = { ...prev } const next = { ...prev }
@@ -327,6 +317,7 @@ export function ChatMessageDisplay({
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string, showToast = false) => { (xml: string, showToast = false) => {
let currentXml = xml || "" let currentXml = xml || ""
const startTime = performance.now()
// During streaming (showToast=false), extract only complete mxCell elements // During streaming (showToast=false), extract only complete mxCell elements
// This allows progressive rendering even with partial/incomplete trailing XML // This allows progressive rendering even with partial/incomplete trailing XML
@@ -350,9 +341,17 @@ export function ChatMessageDisplay({
const parseError = testDoc.querySelector("parsererror") const parseError = testDoc.querySelector("parsererror")
if (parseError) { if (parseError) {
// Only show toast if this is the final XML (not during streaming) // Use console.warn instead of console.error to avoid triggering
// Next.js dev mode error overlay for expected streaming states
// (partial XML during streaming is normal and will be fixed by subsequent updates)
if (showToast) { if (showToast) {
toast.error(dict.errors.malformedXml) // Only log as error and show toast if this is the final XML
console.error(
"[ChatMessageDisplay] Malformed XML detected in final output",
)
toast.error(
"AI generated invalid diagram XML. Please try regenerating.",
)
} }
return // Skip this update return // Skip this update
} }
@@ -365,12 +364,18 @@ export function ChatMessageDisplay({
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const replacedXML = replaceNodes(baseXML, convertedXml) const replacedXML = replaceNodes(baseXML, convertedXml)
const xmlProcessTime = performance.now() - startTime
// During streaming (showToast=false), skip heavy validation for lower latency // During streaming (showToast=false), skip heavy validation for lower latency
// The quick DOM parse check above catches malformed XML // The quick DOM parse check above catches malformed XML
// Full validation runs on final output (showToast=true) // Full validation runs on final output (showToast=true)
if (!showToast) { if (!showToast) {
previousXML.current = convertedXml previousXML.current = convertedXml
const loadStartTime = performance.now()
onDisplayChart(replacedXML, true) onDisplayChart(replacedXML, true)
console.log(
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
return return
} }
@@ -380,15 +385,37 @@ export function ChatMessageDisplay({
previousXML.current = convertedXml previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original // Use fixed XML if available, otherwise use original
const xmlToLoad = validation.fixed || replacedXML const xmlToLoad = validation.fixed || replacedXML
if (validation.fixes.length > 0) {
console.log(
"[ChatMessageDisplay] Auto-fixed XML issues:",
validation.fixes,
)
}
// Skip validation in loadDiagram since we already validated above
const loadStartTime = performance.now()
onDisplayChart(xmlToLoad, true) onDisplayChart(xmlToLoad, true)
console.log(
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
} else { } else {
toast.error(dict.errors.validationFailed) console.error(
"[ChatMessageDisplay] XML validation failed:",
validation.error,
)
toast.error(
"Diagram validation failed. Please try regenerating.",
)
} }
} catch (error) { } catch (error) {
console.error("Error processing XML:", error) console.error(
"[ChatMessageDisplay] Error processing XML:",
error,
)
// Only show toast if this is the final XML (not during streaming) // Only show toast if this is the final XML (not during streaming)
if (showToast) { if (showToast) {
toast.error(dict.errors.failedToProcess) toast.error(
"Failed to process diagram. Please try regenerating.",
)
} }
} }
} }
@@ -396,22 +423,8 @@ export function ChatMessageDisplay({
[chartXML, onDisplayChart], [chartXML, onDisplayChart],
) )
// Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0)
useEffect(() => { useEffect(() => {
if (messagesEndRef.current && messages.length > 0) { if (messagesEndRef.current) {
const prevCount = prevMessageCountRef.current
const currentCount = messages.length
prevMessageCountRef.current = currentCount
// Bulk load (session restore) - instant scroll, no animation
if (prevCount === 0 || currentCount - prevCount > 1) {
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
return
}
// Single message added - smooth scroll
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
} }
}, [messages]) }, [messages])
@@ -435,15 +448,11 @@ export function ChatMessageDisplay({
const toolPart = part as ToolPartLike const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart const { toolCallId, state, input } = toolPart
// Auto-collapse on completion, but only if user hasn't manually toggled
if (state === "output-available") { if (state === "output-available") {
setExpandedTools((prev) => { setExpandedTools((prev) => ({
// Only auto-collapse if not already set (user hasn't interacted) ...prev,
if (prev[toolCallId] === undefined) { [toolCallId]: false,
return { ...prev, [toolCallId]: false } }))
}
return prev
})
} }
if ( if (
@@ -633,19 +642,149 @@ export function ChatMessageDisplay({
// Let the timeouts complete naturally - they're harmless if component unmounts. // Let the timeouts complete naturally - they're harmless if component unmounts.
}, [messages, handleDisplayChart, chartXML]) }, [messages, handleDisplayChart, chartXML])
const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId
const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
}
return (
<div
key={callId}
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
Complete
</span>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}
return ( return (
<ScrollArea className="h-full w-full scrollbar-thin"> <ScrollArea className="h-full w-full scrollbar-thin">
<div ref={scrollTopRef} /> {messages.length === 0 ? (
{messages.length === 0 && isRestored ? ( <ExamplePanel setInput={setInput} setFiles={setFiles} />
<ChatLobby ) : (
sessions={sessions}
onSelectSession={onSelectSession || (() => {})}
onDeleteSession={onDeleteSession}
setInput={setInput}
setFiles={setFiles}
dict={dict}
/>
) : messages.length === 0 ? null : (
<div className="py-4 px-4 space-y-4"> <div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const userMessageText = const userMessageText =
@@ -665,21 +804,13 @@ export function ChatMessageDisplay({
.slice(messageIndex + 1) .slice(messageIndex + 1)
.every((m) => m.role !== "user")) .every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id const isEditing = editingMessageId === message.id
// Skip animation for loaded messages (from session restore)
const isRestoredMessage =
loadedMessageIdsRef?.current.has(message.id) ??
false
return ( return (
<div <div
key={message.id} key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`} className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={ style={{
isRestoredMessage animationDelay: `${messageIndex * 50}ms`,
? undefined }}
: {
animationDelay: `${messageIndex * 50}ms`,
}
}
> >
{message.role === "user" && {message.role === "user" &&
userMessageText && userMessageText &&
@@ -701,10 +832,7 @@ export function ChatMessageDisplay({
) )
}} }}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={ title="Edit message"
dict.chat
.editMessage
}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
@@ -721,13 +849,11 @@ export function ChatMessageDisplay({
title={ title={
copiedMessageId === copiedMessageId ===
message.id message.id
? dict.chat.copied ? "Copied!"
: copyFailedMessageId === : copyFailedMessageId ===
message.id message.id
? dict.chat ? "Failed to copy"
.failedToCopy : "Copy message"
: dict.chat
.copyResponse
} }
> >
{copiedMessageId === {copiedMessageId ===
@@ -776,9 +902,6 @@ export function ChatMessageDisplay({
isStreaming={ isStreaming={
isStreamingReasoning isStreamingReasoning
} }
defaultOpen={
!isRestoredMessage
}
> >
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent> <ReasoningContent>
@@ -845,7 +968,7 @@ export function ChatMessageDisplay({
}} }}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors" className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
> >
{dict.common.cancel} Cancel
</button> </button>
<button <button
type="button" type="button"
@@ -867,7 +990,7 @@ export function ChatMessageDisplay({
disabled={!editText.trim()} disabled={!editText.trim()}
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors" className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
{dict.chat.saveAndSubmit} Save & Submit
</button> </button>
</div> </div>
</div> </div>
@@ -921,56 +1044,9 @@ export function ChatMessageDisplay({
return groups.map( return groups.map(
(group, groupIndex) => { (group, groupIndex) => {
if (group.type === "tool") { if (group.type === "tool") {
const toolPart = group return renderToolPart(
.parts[0] as ToolPartLike group
const toolCallId = .parts[0] as ToolPartLike,
toolPart.toolCallId
const isDisplayDiagram =
toolPart.type ===
"tool-display_diagram"
const validationState =
validationStates[
toolCallId
]
return (
<div
key={`${message.id}-tool-${group.startIndex}`}
>
<ToolCallCard
part={
toolPart
}
expandedTools={
expandedTools
}
setExpandedTools={
setExpandedTools
}
onCopy={
copyMessageToClipboard
}
copiedToolCallId={
copiedToolCallId
}
copyFailedToolCallId={
copyFailedToolCallId
}
dict={dict}
/>
{/* Show validation card for display_diagram tools */}
{isDisplayDiagram &&
validationState && (
<ValidationCard
state={
validationState
}
onImproveWithSuggestions={
onImproveWithSuggestions
}
/>
)}
</div>
) )
} }
@@ -1047,8 +1123,7 @@ export function ChatMessageDisplay({
"user" && "user" &&
isLastUserMessage && isLastUserMessage &&
onEditMessage onEditMessage
? dict.chat ? "Click to edit"
.clickToEdit
: undefined : undefined
} }
> >
@@ -1150,7 +1225,7 @@ export function ChatMessageDisplay({
)} )}
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin"> <div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
<pre className="text-xs whitespace-pre-wrap text-foreground/80"> <pre className="text-xs whitespace-pre-wrap text-foreground/80">
{ {
section.content section.content
@@ -1250,8 +1325,8 @@ export function ChatMessageDisplay({
title={ title={
copiedMessageId === copiedMessageId ===
message.id message.id
? dict.chat.copied ? "Copied!"
: dict.chat.copyResponse : "Copy response"
} }
> >
{copiedMessageId === {copiedMessageId ===
@@ -1277,9 +1352,7 @@ export function ChatMessageDisplay({
) )
} }
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
title={ title="Regenerate response"
dict.chat.regenerate
}
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
</button> </button>
@@ -1301,7 +1374,7 @@ export function ChatMessageDisplay({
? "text-green-600 bg-green-100" ? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50" : "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
}`} }`}
title={dict.chat.goodResponse} title="Good response"
> >
<ThumbsUp className="h-3.5 w-3.5" /> <ThumbsUp className="h-3.5 w-3.5" />
</button> </button>
@@ -1320,7 +1393,7 @@ export function ChatMessageDisplay({
? "text-red-600 bg-red-100" ? "text-red-600 bg-red-100"
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50" : "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
}`} }`}
title={dict.chat.badResponse} title="Bad response"
> >
<ThumbsDown className="h-3.5 w-3.5" /> <ThumbsDown className="h-3.5 w-3.5" />
</button> </button>

File diff suppressed because it is too large Load Diff

View File

@@ -1,274 +0,0 @@
"use client"
import {
ChevronDown,
ChevronUp,
MessageSquare,
Search,
Trash2,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import ExamplePanel from "@/components/chat-example-panel"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatLobbyProps {
sessions: SessionMetadata[]
onSelectSession: (id: string) => void
onDeleteSession?: (id: string) => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
dict: {
sessionHistory?: {
recentChats?: string
searchPlaceholder?: string
noResults?: string
justNow?: string
deleteTitle?: string
deleteDescription?: string
}
examples?: {
quickExamples?: string
}
common: {
delete: string
cancel: string
}
}
}
// Helper to format session date
function formatSessionDate(
timestamp: number,
dict?: { justNow?: string },
): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffMins < 1) return dict?.justNow || "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
dict,
}: ChatLobbyProps) {
// Track whether examples section is expanded (collapsed by default when there's history)
const [examplesExpanded, setExamplesExpanded] = useState(false)
// Delete confirmation dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
// Search filter for history
const [searchQuery, setSearchQuery] = useState("")
const hasHistory = sessions.length > 0
if (!hasHistory) {
// Show full examples when no history
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
}
// Show history + collapsible examples when there are sessions
return (
<div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */}
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"}
</p>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={
dict.sessionHistory?.searchPlaceholder ||
"Search chats..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-2">
{sessions
.filter((session) =>
session.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
.map((session) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
<div
key={session.id}
role="button"
tabIndex={0}
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onSelectSession(session.id)
}
}}
>
{session.thumbnailDataUrl ? (
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
<Image
src={session.thumbnailDataUrl}
alt=""
width={48}
height={48}
className="object-contain w-full h-full"
/>
</div>
) : (
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{session.title}
</div>
<div className="text-xs text-muted-foreground">
{formatSessionDate(
session.updatedAt,
dict.sessionHistory,
)}
</div>
</div>
{onDeleteSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSessionToDelete(session.id)
setDeleteDialogOpen(true)
}}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
{sessions.filter((s) =>
s.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
).length === 0 &&
searchQuery && (
<p className="text-sm text-muted-foreground text-center py-4">
{dict.sessionHistory?.noResults ||
"No chats found"}
</p>
)}
</div>
</div>
{/* Collapsible Examples Section */}
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setExamplesExpanded(!examplesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.examples?.quickExamples || "Quick Examples"}
</span>
{examplesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{examplesExpanded && (
<div className="mt-2">
<ExamplePanel
setInput={setInput}
setFiles={setFiles}
minimal
/>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.sessionHistory?.deleteTitle ||
"Delete this chat?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.sessionHistory?.deleteDescription ||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (sessionToDelete && onDeleteSession) {
onDeleteSession(sessionToDelete)
}
setDeleteDialogOpen(false)
setSessionToDelete(null)
}}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,250 +0,0 @@
"use client"
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
import type { Dispatch, SetStateAction } from "react"
import { CodeBlock } from "@/components/code-block"
import { isMxCellXmlComplete } from "@/lib/utils"
import type { DiagramOperation, ToolPartLike } from "./types"
interface ToolCallCardProps {
part: ToolPartLike
expandedTools: Record<string, boolean>
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
onCopy: (callId: string, text: string, isToolCall: boolean) => void
copiedToolCallId: string | null
copyFailedToolCallId: string | null
dict: {
tools: { complete: string }
chat: { copied: string; failedToCopy: string; copyResponse: string }
}
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
? "text-red-600"
: op.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span>
</div>
{op.new_xml && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
</pre>
</div>
)}
</div>
))}
</div>
)
}
export function ToolCallCard({
part,
expandedTools,
setExpandedTools,
onCopy,
copiedToolCallId,
copyFailedToolCallId,
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output } = part
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
}
const handleCopy = () => {
let textToCopy = ""
if (input && typeof input === "object") {
if (input.xml) {
textToCopy = input.xml
} else if (input.operations && Array.isArray(input.operations)) {
textToCopy = JSON.stringify(input.operations, null, 2)
} else if (Object.keys(input).length > 0) {
textToCopy = JSON.stringify(input, null, 2)
}
}
if (
output &&
toolName === "get_shape_library" &&
typeof output === "string"
) {
textToCopy = output
}
if (textToCopy) {
onCopy(callId, textToCopy, true)
}
}
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<>
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{dict.tools.complete}
</span>
{isExpanded && (
<button
type="button"
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
title={
copiedToolCallId === callId
? dict.chat.copied
: copyFailedToolCallId === callId
? dict.chat.failedToCopy
: dict.chat.copyResponse
}
>
{isCopied ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}

View File

@@ -1,328 +0,0 @@
"use client"
import {
AlertTriangle,
Check,
ChevronDown,
ChevronUp,
Eye,
ImageIcon,
RefreshCw,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator"
export type ValidationStatus =
| "idle"
| "capturing"
| "validating"
| "success"
| "success_with_warnings"
| "failed"
| "error"
| "skipped"
export interface ValidationState {
status: ValidationStatus
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string // Base64 PNG data URL
}
interface ValidationCardProps {
state: ValidationState
onImproveWithSuggestions?: (feedback: string) => void
}
export function ValidationCard({
state,
onImproveWithSuggestions,
}: ValidationCardProps) {
const dict = useDictionary()
const [isExpanded, setIsExpanded] = useState(
state.status === "validating" || state.status === "failed",
)
const [hasRequestedImprovement, setHasRequestedImprovement] =
useState(false)
// Generate improvement feedback from validation result
const generateImprovementFeedback = (): string => {
if (!state.result) return ""
const lines: string[] = []
lines.push(
"Please improve the diagram based on the following visual analysis feedback:",
)
lines.push("")
if (state.result.issues.length > 0) {
lines.push("Issues to address:")
for (const issue of state.result.issues) {
lines.push(
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
)
}
lines.push("")
}
if (state.result.suggestions.length > 0) {
lines.push("Suggestions for improvement:")
for (const suggestion of state.result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push("Regenerate the diagram with these improvements applied.")
return lines.join("\n")
}
const handleImproveClick = () => {
if (
!onImproveWithSuggestions ||
!state.result ||
hasRequestedImprovement
)
return
setHasRequestedImprovement(true)
const feedback = generateImprovementFeedback()
onImproveWithSuggestions(feedback)
}
// Check if we should show the improve button
const showImproveButton =
onImproveWithSuggestions &&
state.result &&
(state.status === "success" ||
state.status === "success_with_warnings" ||
state.status === "skipped") &&
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
const getStatusDisplay = () => {
switch (state.status) {
case "capturing":
return {
label: dict.validation.capturing,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "validating":
return {
label: state.attempt
? dict.validation.validatingWithAttempt
.replace("{attempt}", String(state.attempt))
.replace("{max}", String(state.maxAttempts || 3))
: dict.validation.validating,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "success":
return {
label: dict.validation.valid,
color: "text-green-600 bg-green-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
case "success_with_warnings":
return {
label: dict.validation.validWithWarnings,
color: "text-amber-600 bg-amber-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "failed":
return {
label: dict.validation.issuesFound,
color: "text-yellow-600 bg-yellow-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "error":
return {
label: dict.validation.error,
color: "text-red-600 bg-red-50",
icon: <X className="h-4 w-4" aria-hidden="true" />,
}
case "skipped":
return {
label: dict.validation.skipped,
color: "text-gray-600 bg-gray-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
default:
return null
}
}
const statusDisplay = getStatusDisplay()
if (!statusDisplay || state.status === "idle") return null
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Eye
className="w-3.5 h-3.5 text-primary"
aria-hidden="true"
/>
</div>
<span className="text-sm font-medium text-foreground/80">
{dict.validation.title}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
>
{statusDisplay.icon}
<span className="ml-1">{statusDisplay.label}</span>
</span>
{(state.result || state.error) && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
) : (
<ChevronDown
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
)}
</button>
)}
</div>
</div>
{/* Validation details when expanded */}
{isExpanded && (state.result || state.imageData) && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
{/* Captured image */}
{state.imageData && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
<ImageIcon
className="h-3 w-3"
aria-hidden="true"
/>
{dict.validation.capturedScreenshot}
</div>
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
<Image
src={state.imageData}
alt="Captured diagram for validation"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain"
unoptimized
/>
</div>
</div>
)}
{/* Issues */}
{state.result && state.result.issues.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.issuesFoundLabel}
</div>
<div className="space-y-2">
{state.result.issues.map((issue, index) => (
<div
key={index}
className={`text-xs px-3 py-2 rounded-lg border ${
issue.severity === "critical"
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
}`}
>
<span className="font-medium uppercase text-[10px] mr-2">
[{issue.type}]
</span>
{issue.description}
</div>
))}
</div>
</div>
)}
{/* Suggestions */}
{state.result && state.result.suggestions.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.suggestions}
</div>
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
{state.result.suggestions.map(
(suggestion, index) => (
<li key={index}>{suggestion}</li>
),
)}
</ul>
</div>
)}
{/* Valid result message */}
{state.result?.valid &&
state.result.issues.length === 0 && (
<div className="text-xs text-green-600 dark:text-green-400">
{dict.validation.passedValidation}
</div>
)}
</div>
)}
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
{showImproveButton && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
{hasRequestedImprovement ? (
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
<Check className="h-4 w-4" aria-hidden="true" />
{dict.validation.improvementRequested}
</div>
) : (
<>
<button
type="button"
onClick={handleImproveClick}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
>
<RefreshCw
className="h-4 w-4"
aria-hidden="true"
/>
{dict.validation.improveWithSuggestions}
</button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{dict.validation.regenerateWithFeedback}
</p>
</>
)}
</div>
)}
{/* Error details when expanded */}
{isExpanded && state.error && (
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
<div className="text-xs text-red-600">{state.error}</div>
</div>
)}
</div>
)
}

View File

@@ -1,16 +0,0 @@
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
}

View File

@@ -1,7 +1,6 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { wrapWithMxFile } from "@/lib/utils" import { wrapWithMxFile } from "@/lib/utils"
// Dev XML presets for streaming simulator // Dev XML presets for streaming simulator
@@ -143,7 +142,6 @@ export function DevXmlSimulator({
onDisplayChart, onDisplayChart,
onShowQuotaToast, onShowQuotaToast,
}: DevXmlSimulatorProps) { }: DevXmlSimulatorProps) {
const dict = useDictionary()
const [devXml, setDevXml] = useState("") const [devXml, setDevXml] = useState("")
const [isSimulating, setIsSimulating] = useState(false) const [isSimulating, setIsSimulating] = useState(false)
const [devIntervalMs, setDevIntervalMs] = useState(1) const [devIntervalMs, setDevIntervalMs] = useState(1)
@@ -180,7 +178,7 @@ export function DevXmlSimulator({
parts: [ parts: [
{ {
type: "text" as const, type: "text" as const,
text: dict.dev.simulatingMessage, text: "[Dev] Simulating XML streaming",
}, },
], ],
} }
@@ -230,7 +228,7 @@ export function DevXmlSimulator({
const lastMsg = updated[updated.length - 1] as any const lastMsg = updated[updated.length - 1] as any
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) { if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
lastMsg.parts[0].state = "output-available" lastMsg.parts[0].state = "output-available"
lastMsg.parts[0].output = dict.dev.successMessage lastMsg.parts[0].output = "Successfully displayed the diagram."
lastMsg.parts[0].input = { xml } lastMsg.parts[0].input = { xml }
} }
return updated return updated
@@ -247,12 +245,12 @@ export function DevXmlSimulator({
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30"> <div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
<details> <details>
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium"> <summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
{dict.dev.title} Dev: XML Streaming Simulator
</summary> </summary>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.preset} Preset:
</label> </label>
<select <select
onChange={(e) => { onChange={(e) => {
@@ -264,7 +262,7 @@ export function DevXmlSimulator({
defaultValue="" defaultValue=""
> >
<option value="" disabled> <option value="" disabled>
{dict.dev.selectPreset} Select a preset...
</option> </option>
{Object.keys(DEV_XML_PRESETS).map((name) => ( {Object.keys(DEV_XML_PRESETS).map((name) => (
<option key={name} value={name}> <option key={name} value={name}>
@@ -277,19 +275,19 @@ export function DevXmlSimulator({
onClick={() => setDevXml("")} onClick={() => setDevXml("")}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded" className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
> >
{dict.dev.clear} Clear
</button> </button>
</div> </div>
<textarea <textarea
value={devXml} value={devXml}
onChange={(e) => setDevXml(e.target.value)} onChange={(e) => setDevXml(e.target.value)}
placeholder={dict.dev.placeholder} placeholder="Paste mxCell XML here or select a preset..."
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background" className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
/> />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.interval} Interval:
</label> </label>
<input <input
type="range" type="range"
@@ -308,7 +306,7 @@ export function DevXmlSimulator({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.chars} Chars:
</label> </label>
<input <input
type="number" type="number"
@@ -332,8 +330,8 @@ export function DevXmlSimulator({
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isSimulating {isSimulating
? dict.dev.streaming ? "Streaming..."
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`} : `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
</button> </button>
{isSimulating && ( {isSimulating && (
<button <button
@@ -343,7 +341,7 @@ export function DevXmlSimulator({
}} }}
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600" className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
> >
{dict.dev.stop} Stop
</button> </button>
)} )}
{onShowQuotaToast && ( {onShowQuotaToast && (
@@ -352,7 +350,7 @@ export function DevXmlSimulator({
onClick={onShowQuotaToast} onClick={onShowQuotaToast}
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600" className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
> >
{dict.dev.testQuotaToast} Test Quota Toast
</button> </button>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { FileCode, FileText, Link, Loader2, X } from "lucide-react" import { FileCode, FileText, Loader2, X } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
@@ -20,19 +20,12 @@ interface FilePreviewListProps {
File, File,
{ text: string; charCount: number; isExtracting: boolean } { text: string; charCount: number; isExtracting: boolean }
> >
urlData?: Map<
string,
{ url: string; title: string; charCount: number; isExtracting: boolean }
>
onRemoveUrl?: (url: string) => void
} }
export function FilePreviewList({ export function FilePreviewList({
files, files,
onRemoveFile, onRemoveFile,
pdfData = new Map(), pdfData = new Map(),
urlData,
onRemoveUrl,
}: FilePreviewListProps) { }: FilePreviewListProps) {
const dict = useDictionary() const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null) const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -84,7 +77,7 @@ export function FilePreviewList({
} }
}, [imageUrls, selectedImage]) }, [imageUrls, selectedImage])
if (files.length === 0 && (!urlData || urlData.size === 0)) return null if (files.length === 0) return null
return ( return (
<> <>
@@ -159,59 +152,6 @@ export function FilePreviewList({
</div> </div>
) )
})} })}
{/* URL previews */}
{urlData && urlData.size > 0 && (
<div className="flex flex-wrap gap-2">
{Array.from(urlData.entries()).map(
([url, data], index) => (
<div
key={url + index}
className="relative group"
>
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
<div className="flex flex-col items-center justify-center h-full p-1">
{data.isExtracting ? (
<>
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
<span className="text-[10px] text-muted-foreground">
{dict.file.reading}
</span>
</>
) : (
<>
<Link className="h-6 w-6 text-blue-500 mb-1" />
<span className="text-xs text-center truncate w-full px-1">
{data.title.length > 10
? `${data.title.slice(0, 7)}...`
: data.title}
</span>
{data.charCount && (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
data.charCount,
)}{" "}
{dict.file.chars}
</span>
)}
</>
)}
</div>
</div>
{onRemoveUrl && (
<button
type="button"
onClick={() => onRemoveUrl(url)}
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={dict.file.removeFile}
>
<X className="h-3 w-3" />
</button>
)}
</div>
),
)}
</div>
)}
</div> </div>
{/* Image Modal/Lightbox */} {/* Image Modal/Lightbox */}
{selectedImage && ( {selectedImage && (

View File

@@ -43,7 +43,7 @@ export function HistoryDialog({
return ( return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}> <Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin"> <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle> <DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription> <DialogDescription>

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,8 @@ import {
Bot, Bot,
Check, Check,
ChevronDown, ChevronDown,
Monitor,
Server, Server,
Settings2, Settings2,
User,
} from "lucide-react" } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { import {
@@ -21,7 +19,6 @@ import {
ModelSelectorLogo, ModelSelectorLogo,
ModelSelectorName, ModelSelectorName,
ModelSelector as ModelSelectorRoot, ModelSelector as ModelSelectorRoot,
ModelSelectorSectionHeader,
ModelSelectorSeparator, ModelSelectorSeparator,
ModelSelectorTrigger, ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector" } from "@/components/ai-elements/model-selector"
@@ -51,10 +48,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
siliconflow: "siliconflow", siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel", gateway: "vercel",
edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance", doubao: "bytedance",
modelscope: "modelscope",
} }
// Group models by providerLabel (handles duplicate providers) // Group models by providerLabel (handles duplicate providers)
@@ -66,11 +60,7 @@ function groupModelsByProvider(
{ provider: string; models: FlattenedModel[] } { provider: string; models: FlattenedModel[] }
>() >()
for (const model of models) { for (const model of models) {
// For server models, strip "Server · " prefix for cleaner grouping const key = model.providerLabel
const key =
model.source === "server"
? model.providerLabel.replace(/^Server · /, "")
: model.providerLabel
const existing = groups.get(key) const existing = groups.get(key)
if (existing) { if (existing) {
existing.models.push(model) existing.models.push(model)
@@ -98,26 +88,10 @@ export function ModelSelector({
} }
return models.filter((m) => m.validated === true) return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels]) }, [models, showUnvalidatedModels])
const groupedModels = useMemo(
// Separate server and user models () => groupModelsByProvider(displayModels),
const serverModels = useMemo(
() => displayModels.filter((m) => m.source === "server"),
[displayModels], [displayModels],
) )
const userModels = useMemo(
() => displayModels.filter((m) => m.source !== "server"),
[displayModels],
)
// Group each category separately
const groupedServerModels = useMemo(
() => groupModelsByProvider(serverModels),
[serverModels],
)
const groupedUserModels = useMemo(
() => groupModelsByProvider(userModels),
[userModels],
)
// Find selected model for display // Find selected model for display
const selectedModel = useMemo( const selectedModel = useMemo(
@@ -184,7 +158,7 @@ export function ModelSelector({
size="sm" size="sm"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out", "hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
!showLabel && "px-1.5 justify-center", !showLabel && "px-1.5 justify-center",
)} )}
// accessibility: expose label to screen readers // accessibility: expose label to screen readers
@@ -221,169 +195,83 @@ export function ModelSelector({
: dict.modelConfig.noModelsFound} : dict.modelConfig.noModelsFound}
</ModelSelectorEmpty> </ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */} {/* Server Default Option */}
{serverModels.length === 0 && ( <ModelSelectorGroup heading={dict.modelConfig.default}>
<ModelSelectorGroup <ModelSelectorItem
heading={dict.modelConfig.default} value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
> >
<ModelSelectorItem <Check
value="__server_default__"
onSelect={handleSelect}
className={cn( className={cn(
"cursor-pointer", "mr-2 h-4 w-4",
!selectedModelId && "bg-accent", !selectedModelId
? "opacity-100"
: "opacity-0",
)} )}
/>
<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}
> >
<Check {providerModels.map((model) => (
className={cn( <ModelSelectorItem
"mr-2 h-4 w-4", key={model.id}
!selectedModelId value={model.modelId}
? "opacity-100" onSelect={() =>
: "opacity-0", handleSelect(model.id)
)} }
/> className="cursor-pointer"
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={dict.modelConfig.serverModels}
/>
{Array.from(groupedServerModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
> >
{providerModels.map((model) => ( <Check
<ModelSelectorItem className={cn(
key={model.id} "mr-2 h-4 w-4",
value={model.modelId} selectedModelId === model.id
onSelect={() => ? "opacity-100"
handleSelect(model.id) : "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !== true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
} }
className="cursor-pointer"
> >
<Check <AlertTriangle className="ml-auto h-3 w-3 text-warning" />
className={cn( </span>
"mr-2 h-4 w-4", )}
selectedModelId === </ModelSelectorItem>
model.id ))}
? "opacity-100" </ModelSelectorGroup>
: "opacity-0", ),
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(groupedUserModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{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>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)} )}
{/* Configure Option */} {/* Configure Option */}
@@ -392,7 +280,7 @@ export function ModelSelector({
<ModelSelectorItem <ModelSelectorItem
value="__configure__" value="__configure__"
onSelect={handleSelect} onSelect={handleSelect}
className="cursor-pointer text-muted-foreground hover:text-foreground" className="cursor-pointer"
> >
<Settings2 className="mr-2 h-4 w-4" /> <Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName> <ModelSelectorName>

View File

@@ -3,7 +3,6 @@
import { Github, Info, Moon, Sun, Tag } from "lucide-react" import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -25,7 +24,6 @@ import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
// Reusable setting item component for consistent layout // Reusable setting item component for consistent layout
function SettingItem({ function SettingItem({
@@ -61,17 +59,17 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onCloseProtectionChange?: (enabled: boolean) => void
drawioUi: "min" | "sketch" drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void onToggleDrawioUi: () => void
darkMode: boolean darkMode: boolean
onToggleDarkMode: () => void onToggleDarkMode: () => void
minimalStyle?: boolean minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required" const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
function getStoredAccessCodeRequired(): boolean | null { function getStoredAccessCodeRequired(): boolean | null {
@@ -84,32 +82,26 @@ function getStoredAccessCodeRequired(): boolean | null {
function SettingsContent({ function SettingsContent({
open, open,
onOpenChange, onOpenChange,
onCloseProtectionChange,
drawioUi, drawioUi,
onToggleDrawioUi, onToggleDrawioUi,
darkMode, darkMode,
onToggleDarkMode, onToggleDarkMode,
minimalStyle = false, minimalStyle = false,
onMinimalStyleChange = () => {}, onMinimalStyleChange = () => {},
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter() const router = useRouter()
const pathname = usePathname() || "/" const pathname = usePathname() || "/"
const search = useSearchParams() const search = useSearchParams()
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false) const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState( const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false, () => getStoredAccessCodeRequired() ?? false,
) )
const [currentLang, setCurrentLang] = useState("en") const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => { useEffect(() => {
// Only fetch if not cached in localStorage // Only fetch if not cached in localStorage
@@ -151,20 +143,13 @@ function SettingsContent({
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "" localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode) setAccessCode(storedCode)
const storedSendShortcut = localStorage.getItem( const storedCloseProtection = localStorage.getItem(
STORAGE_KEYS.sendShortcut, STORAGE_CLOSE_PROTECTION_KEY,
) )
setSendShortcut(storedSendShortcut || "ctrl-enter") // Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
setError("") setError("")
// Load proxy settings (Electron only)
if (window.electronAPI?.getProxy) {
window.electronAPI.getProxy().then((config) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}
} }
}, [open]) }, [open])
@@ -172,13 +157,6 @@ function SettingsContent({
// Save locale to localStorage for persistence across restarts // Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang) localStorage.setItem("next-ai-draw-io-locale", lang)
// Notify Electron main process to update its menu language
if (window.electronAPI?.setUserLocale) {
window.electronAPI.setUserLocale(lang).catch((error) => {
console.error("Failed to sync locale with Electron:", error)
})
}
const parts = pathname.split("/") const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) { if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang parts[1] = lang
@@ -230,46 +208,6 @@ function SettingsContent({
} }
} }
const handleApplyProxy = async () => {
if (!window.electronAPI?.setProxy) return
// Validate proxy URLs (must start with http:// or https://)
const validateProxyUrl = (url: string): boolean => {
if (!url) return true // Empty is OK
return url.startsWith("http://") || url.startsWith("https://")
}
const trimmedHttp = httpProxy.trim()
const trimmedHttps = httpsProxy.trim()
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
toast.error("HTTP Proxy must start with http:// or https://")
return
}
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
toast.error("HTTPS Proxy must start with http:// or https://")
return
}
setIsApplyingProxy(true)
try {
const result = await window.electronAPI.setProxy({
httpProxy: trimmedHttp || undefined,
httpsProxy: trimmedHttps || undefined,
})
if (result.success) {
toast.success(dict.settings.proxyApplied)
} else {
toast.error(result.error || "Failed to apply proxy settings")
}
} catch {
toast.error("Failed to apply proxy settings")
} finally {
setIsApplyingProxy(false)
}
}
return ( return (
<DialogContent className="sm:max-w-lg p-0 gap-0"> <DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */} {/* Header */}
@@ -395,6 +333,25 @@ function SettingsContent({
</Button> </Button>
</SettingItem> </SettingItem>
{/* Close Protection */}
<SettingItem
label={dict.settings.closeProtection}
description={dict.settings.closeProtectionDescription}
>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</SettingItem>
{/* Diagram Style */} {/* Diagram Style */}
<SettingItem <SettingItem
label={dict.settings.diagramStyle} label={dict.settings.diagramStyle}
@@ -413,110 +370,6 @@ function SettingsContent({
</span> </span>
</div> </div>
</SettingItem> </SettingItem>
{/* VLM Diagram Validation */}
<SettingItem
label={dict.settings.diagramValidation}
description={dict.settings.diagramValidationDescription}
>
<div className="flex items-center gap-2">
<Switch
id="vlm-validation"
checked={vlmValidationEnabled}
onCheckedChange={onVlmValidationChange}
/>
<span className="text-sm text-muted-foreground">
{vlmValidationEnabled
? dict.settings.enabled
: dict.settings.disabled}
</span>
</div>
</SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}
description={dict.settings.sendShortcutDescription}
>
<Select
value={sendShortcut}
onValueChange={(value) => {
setSendShortcut(value)
localStorage.setItem(
STORAGE_KEYS.sendShortcut,
value,
)
window.dispatchEvent(
new CustomEvent("sendShortcutChange", {
detail: value,
}),
)
}}
>
<SelectTrigger
id="send-shortcut-select"
className="w-[170px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">
{dict.settings.enterToSend}
</SelectItem>
<SelectItem value="ctrl-enter">
{dict.settings.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Proxy Settings - Electron only */}
{typeof window !== "undefined" &&
window.electronAPI?.isElectron && (
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{dict.settings.proxy}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.proxyDescription}
</p>
</div>
<div className="space-y-2">
<Input
id="http-proxy"
type="text"
value={httpProxy}
onChange={(e) =>
setHttpProxy(e.target.value)
}
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
type="text"
value={httpsProxy}
onChange={(e) =>
setHttpsProxy(e.target.value)
}
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
</div>
<Button
onClick={handleApplyProxy}
disabled={isApplyingProxy}
className="h-9 px-4 rounded-xl w-full"
>
{isApplyingProxy
? "..."
: dict.settings.applyProxy}
</Button>
</div>
)}
</div> </div>
</div> </div>
@@ -542,13 +395,13 @@ function SettingsContent({
<> <>
<span className="text-muted-foreground">·</span> <span className="text-muted-foreground">·</span>
<a <a
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`} href="/about"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1" className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
> >
<Info className="h-3 w-3" /> <Info className="h-3 w-3" />
{dict.nav.about} About
</a> </a>
</> </>
)} )}

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,116 +0,0 @@
"use client"
import { Link, Loader2 } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { useDictionary } from "@/hooks/use-dictionary"
interface UrlInputDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (url: string) => void
isExtracting: boolean
}
export function UrlInputDialog({
open,
onOpenChange,
onSubmit,
isExtracting,
}: UrlInputDialogProps) {
const dict = useDictionary()
const [url, setUrl] = useState("")
const [error, setError] = useState("")
const handleSubmit = () => {
setError("")
if (!url.trim()) {
setError(dict.url.enterUrl)
return
}
try {
new URL(url)
} catch {
setError(dict.url.invalidFormat)
return
}
onSubmit(url.trim())
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isExtracting) {
e.preventDefault()
handleSubmit()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.url.title}</DialogTitle>
<DialogDescription>
{dict.url.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value)
setError("")
}}
onKeyDown={handleKeyDown}
placeholder="https://example.com/article"
disabled={isExtracting}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isExtracting}
>
{dict.url.Cancel}
</Button>
<Button
onClick={handleSubmit}
disabled={isExtracting || !url.trim()}
>
{isExtracting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{dict.url.Extracting}
</>
) : (
<>
<Link className="mr-2 h-4 w-4" />
{dict.url.extract}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -3,20 +3,15 @@
import type React from "react" import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react" import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { toast } from "sonner" import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
extractDiagramXML,
isRealDiagram,
validateAndFixXml,
} from "../lib/utils"
interface DiagramContextType { interface DiagramContextType {
chartXML: string chartXML: string
latestSvg: string latestSvg: string
diagramHistory: { svg: string; xml: string }[] diagramHistory: { svg: string; xml: string }[]
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
loadDiagram: (chart: string, skipValidation?: boolean) => string | null loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void handleExport: () => void
handleExportWithoutHistory: () => void handleExportWithoutHistory: () => void
@@ -28,10 +23,8 @@ interface DiagramContextType {
filename: string, filename: string,
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
successMessage?: string,
) => void ) => void
getThumbnailSvg: () => Promise<string | null> saveDiagramToStorage: () => Promise<void>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void resetDrawioReady: () => void
@@ -48,54 +41,72 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
{ svg: string; xml: string }[] { svg: string; xml: string }[]
>([]) >([])
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null) const resolverRef = useRef<((value: string) => void) | null>(null)
// Resolver for PNG export (used for VLM validation)
const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change) // Track if diagram has been restored from localStorage
const hasDiagramRestoredRef = useRef<boolean>(false) const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
const onDrawioLoad = () => { const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops // Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true hasCalledOnLoadRef.current = true
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
setIsDrawioReady(true) setIsDrawioReady(true)
} }
const resetDrawioReady = () => { const resetDrawioReady = () => {
// console.log("[DiagramContext] Resetting DrawIO ready state")
hasCalledOnLoadRef.current = false hasCalledOnLoadRef.current = false
setIsDrawioReady(false) setIsDrawioReady(false)
} }
// Keep chartXMLRef in sync with state for restoration after remount // Restore diagram XML when DrawIO becomes ready
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
useEffect(() => { useEffect(() => {
chartXMLRef.current = chartXML // Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
}, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
useEffect(() => {
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
if (!isDrawioReady) { if (!isDrawioReady) {
hasDiagramRestoredRef.current = false hasDiagramRestoredRef.current = false
setCanSaveDiagram(false)
return return
} }
// Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one try {
const xmlToRestore = chartXMLRef.current const savedDiagramXml = localStorage.getItem(
if (isRealDiagram(xmlToRestore) && drawioRef.current) { STORAGE_DIAGRAM_XML_KEY,
drawioRef.current.load({ xml: xmlToRestore }) )
if (savedDiagramXml) {
// Skip validation for trusted saved diagrams
loadDiagram(savedDiagramXml, true)
}
} catch (error) {
console.error("Failed to restore diagram from localStorage:", error)
} }
// Allow saving after restore is complete
setTimeout(() => {
setCanSaveDiagram(true)
}, 500)
}, [isDrawioReady]) }, [isDrawioReady])
// Save diagram XML to localStorage whenever it changes (debounced)
useEffect(() => {
if (!canSaveDiagram) return
if (!chartXML || chartXML.length <= 300) return
const timeoutId = setTimeout(() => {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}, 1000)
return () => clearTimeout(timeoutId)
}, [chartXML, canSaveDiagram])
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null resolver: ((data: string) => void) | null
@@ -121,63 +132,27 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// Get current diagram as SVG for thumbnail (used by session storage) // Save current diagram to localStorage (used before theme/UI changes)
const getThumbnailSvg = async (): Promise<string | null> => { const saveDiagramToStorage = async (): Promise<void> => {
if (!drawioRef.current) return null if (!drawioRef.current) return
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
try { try {
const svgData = await Promise.race([ const currentXml = await Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
resolverRef.current = resolve resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" }) drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}), }),
new Promise<string>((_, reject) => new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 3000), setTimeout(() => reject(new Error("Export timeout")), 2000),
), ),
]) ])
// Update latestSvg so it's available for future saves // Only save if diagram has meaningful content (not empty template)
if (svgData?.includes("<svg")) { if (currentXml && currentXml.length > 300) {
setLatestSvg(svgData) localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
return svgData
} }
return null } catch (error) {
} catch { console.error("Failed to save diagram to storage:", error)
// Timeout is expected occasionally - don't log as error
return null
}
}
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
try {
const pngData = await Promise.race([
new Promise<string>((resolve) => {
pngResolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "png" })
}),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error("PNG export timeout")),
5000,
),
),
])
// PNG data should be a base64 data URL
if (pngData?.startsWith("data:image/png")) {
return pngData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
} }
} }
@@ -220,13 +195,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: any) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
pngResolverRef.current = null
return
}
// Handle save to file if requested (process raw data before extraction) // Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) { if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format const format = saveResolverRef.current.format
@@ -279,7 +247,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
filename: string, filename: string,
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
successMessage?: string,
) => { ) => {
if (!drawioRef.current) { if (!drawioRef.current) {
console.warn("Draw.io editor not ready") console.warn("Draw.io editor not ready")
@@ -306,6 +273,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
fileContent = xmlContent fileContent = xmlContent
mimeType = "application/xml" mimeType = "application/xml"
extension = ".drawio" extension = ".drawio"
// Save to localStorage when user manually saves
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
} else if (format === "png") { } else if (format === "png") {
// PNG data comes as base64 data URL // PNG data comes as base64 data URL
fileContent = exportData fileContent = exportData
@@ -341,14 +311,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, {
position: "bottom-left",
duration: 2500,
})
}
// Delay URL revocation to ensure download completes // Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) { if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100) setTimeout(() => URL.revokeObjectURL(url), 100)
@@ -384,7 +346,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
chartXML, chartXML,
latestSvg, latestSvg,
diagramHistory, diagramHistory,
setDiagramHistory,
loadDiagram, loadDiagram,
handleExport, handleExport,
handleExportWithoutHistory, handleExportWithoutHistory,
@@ -393,8 +354,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport, handleDiagramExport,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, saveDiagramToStorage,
captureValidationPng,
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,

View File

@@ -11,7 +11,7 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
# environment: environment:
# # For subdirectory deployment, uncomment and set your path: # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio # NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio] depends_on: [drawio]

View File

@@ -4,7 +4,7 @@
**AI驱动的图表创建工具 - 对话、绘制、可视化** **AI驱动的图表创建工具 - 对话、绘制、可视化**
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md) [English](../README.md) | 中文 | [日本語](./README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
@@ -13,7 +13,7 @@
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/) [![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
</div> </div>
@@ -29,20 +29,15 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [示例](#示例) - [示例](#示例)
- [功能特性](#功能特性) - [功能特性](#功能特性)
- [MCP服务器预览](#mcp服务器预览) - [MCP服务器预览](#mcp服务器预览)
- [Claude Code CLI](#claude-code-cli)
- [快速开始](#快速开始) - [快速开始](#快速开始)
- [在线试用](#在线试用) - [在线试用](#在线试用)
- [桌面应用](#桌面应用) - [使用Docker运行推荐](#使用docker运行推荐)
- [使用Docker运行](#使用docker运行)
- [安装](#安装) - [安装](#安装)
- [部署](#部署) - [部署](#部署)
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
- [部署到Vercel](#部署到vercel)
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
- [多提供商支持](#多提供商支持) - [多提供商支持](#多提供商支持)
- [工作原理](#工作原理) - [工作原理](#工作原理)
- [项目结构](#项目结构)
- [支持与联系](#支持与联系) - [支持与联系](#支持与联系)
- [常见问题](#常见问题)
- [Star历史](#star历史) - [Star历史](#star历史)
## 示例 ## 示例
@@ -55,31 +50,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br /> <strong>动画Transformer连接器</strong><br />
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p> <p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" /> <img src="../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>GCP架构图</strong><br /> <strong>GCP架构图</strong><br />
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中用户连接到托管在实例上的前端。</p> <p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中用户连接到托管在实例上的前端。</p>
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" /> <img src="../public/gcp_demo.svg" alt="GCP架构图" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>AWS架构图</strong><br /> <strong>AWS架构图</strong><br />
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中用户连接到托管在实例上的前端。</p> <p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中用户连接到托管在实例上的前端。</p>
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" /> <img src="../public/aws_demo.svg" alt="AWS架构图" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Azure架构图</strong><br /> <strong>Azure架构图</strong><br />
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中用户连接到托管在实例上的前端。</p> <p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中用户连接到托管在实例上的前端。</p>
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" /> <img src="../public/azure_demo.svg" alt="Azure架构图" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>猫咪素描</strong><br /> <strong>猫咪素描</strong><br />
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p> <p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" /> <img src="../public/cat_demo.svg" alt="猫咪绘图" width="240" />
</td> </td>
</tr> </tr>
</table> </table>
@@ -98,7 +93,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## MCP服务器预览 ## MCP服务器预览
> **预览功能**:此功能为实验性功能,可能不稳定 > **预览功能**:此功能为实验性功能,可能会有变化
通过MCP模型上下文协议在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。 通过MCP模型上下文协议在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
@@ -124,7 +119,7 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
图表会实时显示在浏览器中! 图表会实时显示在浏览器中!
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md)了解VS Code、Cursor等客户端配置。 详情请参阅[MCP服务器README](../packages/mcp-server/README.md)了解VS Code、Cursor等客户端配置。
## 快速开始 ## 快速开始
@@ -132,19 +127,39 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
无需安装!直接在我们的演示站点试用: 无需安装!直接在我们的演示站点试用:
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。 > **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
### 桌面应用 ### 使用Docker运行推荐
从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用: 如果您只想在本地运行最好的方式是使用Docker。
支持的平台Windows、macOS、Linux。 首先如果您还没有安装Docker请先安装[获取Docker](https://docs.docker.com/get-docker/)
### 使用Docker运行 然后运行
[查看 Docker 指南](./docker.md) ```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
或者使用 env 文件:
```bash
cp env.example .env
# 编辑 .env 填写您的配置
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
在浏览器中打开 [http://localhost:3000](http://localhost:3000)。
请根据您首选的AI提供商配置替换环境变量。可用选项请参阅[多提供商支持](#多提供商支持)。
> **离线部署:** 如果 `embed.diagrams.net` 被屏蔽,请参阅 [离线部署指南](./offline-deployment.md) 了解配置选项。
### 安装 ### 安装
@@ -153,44 +168,52 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
```
2. 安装依赖:
```bash
npm install npm install
```
3. 配置您的AI提供商
在根目录创建 `.env.local` 文件:
```bash
cp env.example .env.local cp env.example .env.local
``` ```
编辑 `.env.local` 并配置您选择的提供商:
- 将 `AI_PROVIDER` 设置为您选择的提供商bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, doubao
- 将 `AI_MODEL` 设置为您要使用的特定模型
- 添加您的提供商所需的API密钥
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。 详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
2. 运行开发服务器: 4. 运行开发服务器:
```bash ```bash
npm run dev npm run dev
``` ```
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。 5. 在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看应用。
## 部署 ## 部署
### 部署到腾讯云EdgeOne Pages 部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。
直接点击此按钮一键部署:
[![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。
同时通过腾讯云EdgeOne Pages部署也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
### 部署到Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。 查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
### 部署到Cloudflare Workers 或者您可以通过此按钮部署:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
[查看 Cloudflare 部署指南](./cloudflare-deploy.md) 请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
## 多提供商支持 ## 多提供商支持
@@ -200,27 +223,19 @@ npm run dev
- OpenAI - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Google Vertex AI
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。 除AWS Bedrock和OpenRouter外所有提供商都支持自定义端点。
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。 📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
### 服务端多模型配置 **模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量JSON 字符串)或 `ai-models.json` 文件配置 注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练因此如果您想创建AWS架构图这是最佳选择
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
## 工作原理 ## 工作原理
@@ -233,6 +248,24 @@ npm run dev
图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。 图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
## 项目结构
```
app/ # Next.js App Router
api/chat/ # 带AI工具的聊天API端点
page.tsx # 带DrawIO嵌入的主页面
components/ # React组件
chat-panel.tsx # 带图表控制的聊天界面
chat-input.tsx # 带文件上传的用户输入组件
history-dialog.tsx # 图表版本历史查看器
ui/ # UI组件按钮、卡片等
contexts/ # React上下文提供者
diagram-context.tsx # 全局图表状态管理
lib/ # 工具函数和辅助程序
ai-providers.ts # 多提供商AI配置
utils.ts # XML处理和转换工具
public/ # 静态资源包括示例图片
```
## 支持与联系 ## 支持与联系
@@ -244,10 +277,6 @@ npm run dev
- 邮箱me[at]jiang.jp - 邮箱me[at]jiang.jp
## 常见问题
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
## Star历史 ## Star历史
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)

View File

@@ -4,7 +4,7 @@
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化** **AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語 [English](../README.md) | [中文](./README_CN.md) | 日本語
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
@@ -13,7 +13,7 @@
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/) [![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
</div> </div>
@@ -29,20 +29,15 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [](#例) - [](#例)
- [機能](#機能) - [機能](#機能)
- [MCPサーバープレビュー](#mcpサーバープレビュー) - [MCPサーバープレビュー](#mcpサーバープレビュー)
- [Claude Code CLI](#claude-code-cli)
- [はじめに](#はじめに) - [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す) - [オンラインで試す](#オンラインで試す)
- [デスクトップアプリケーション](#デスクトップアプリケーション) - [Dockerで実行推奨](#dockerで実行推奨)
- [Dockerで実行](#dockerで実行)
- [インストール](#インストール) - [インストール](#インストール)
- [デプロイ](#デプロイ) - [デプロイ](#デプロイ)
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
- [Vercelへのデプロイ](#vercelへのデプロイ)
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
- [マルチプロバイダーサポート](#マルチプロバイダーサポート) - [マルチプロバイダーサポート](#マルチプロバイダーサポート)
- [仕組み](#仕組み) - [仕組み](#仕組み)
- [プロジェクト構造](#プロジェクト構造)
- [サポート&お問い合わせ](#サポートお問い合わせ) - [サポート&お問い合わせ](#サポートお問い合わせ)
- [よくある質問](#よくある質問)
- [スター履歴](#スター履歴) - [スター履歴](#スター履歴)
## 例 ## 例
@@ -55,31 +50,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>アニメーションTransformerコネクタ</strong><br /> <strong>アニメーションTransformerコネクタ</strong><br />
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p> <p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" /> <img src="../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>GCPアーキテクチャ図</strong><br /> <strong>GCPアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p> <p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" /> <img src="../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>AWSアーキテクチャ図</strong><br /> <strong>AWSアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p> <p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" /> <img src="../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Azureアーキテクチャ図</strong><br /> <strong>Azureアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p> <p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" /> <img src="../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>猫のスケッチ</strong><br /> <strong>猫のスケッチ</strong><br />
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p> <p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" /> <img src="../public/cat_demo.svg" alt="猫の絵" width="240" />
</td> </td>
</tr> </tr>
</table> </table>
@@ -98,7 +93,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
## MCPサーバープレビュー ## MCPサーバープレビュー
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。 > **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
MCPModel Context Protocolを介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。 MCPModel Context Protocolを介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
@@ -124,7 +119,7 @@ Claudeにダイアグラムの作成を依頼
ダイアグラムがリアルタイムでブラウザに表示されます! ダイアグラムがリアルタイムでブラウザに表示されます!
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧くださいVS Code、Cursorなどのクライアント設定も含む 詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧くださいVS Code、Cursorなどのクライアント設定も含む
## はじめに ## はじめに
@@ -132,19 +127,39 @@ Claudeにダイアグラムの作成を依頼
インストール不要!デモサイトで直接お試しください: インストール不要!デモサイトで直接お試しください:
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [![Live Demo](../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> **自分のAPIキーを使用**自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。 > **自分のAPIキーを使用**自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
### デスクトップアプリケーション ### Dockerで実行推奨
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください: ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
対応プラットフォームWindows、macOS、Linux。 まず、Dockerをインストールしていない場合はインストールしてください[Dockerを入手](https://docs.docker.com/get-docker/)
### Dockerで実行 次に実行
[Docker ガイドを参照](./docker.md) ```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
または env ファイルを使用:
```bash
cp env.example .env
# .env を編集して設定を入力
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
ブラウザで [http://localhost:3000](http://localhost:3000) を開いてください。
環境変数はお好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては[マルチプロバイダーサポート](#マルチプロバイダーサポート)を参照してください。
> **オフラインデプロイ:** `embed.diagrams.net` がブロックされている場合は、[オフラインデプロイガイド](./offline-deployment.md) で設定オプションをご確認ください。
### インストール ### インストール
@@ -153,45 +168,52 @@ Claudeにダイアグラムの作成を依頼
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
```
2. 依存関係をインストール:
```bash
npm install npm install
```
3. AIプロバイダーを設定
ルートディレクトリに`.env.local`ファイルを作成:
```bash
cp env.example .env.local cp env.example .env.local
``` ```
`.env.local`を編集して選択したプロバイダーを設定:
- `AI_PROVIDER`を選択したプロバイダーに設定bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, doubao
- `AI_MODEL`を使用する特定のモデルに設定
- プロバイダーに必要なAPIキーを追加
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。 詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
2. 開発サーバーを起動: 4. 開発サーバーを起動:
```bash ```bash
npm run dev npm run dev
``` ```
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。 5. ブラウザで[http://localhost:3000](http://localhost:3000)を開いてアプリケーションを確認。
## デプロイ ## デプロイ
### EdgeOne Pagesへのデプロイ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。
このボタンでデプロイ:
[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
詳細は[Tencent EdgeOne Pagesドキュメント](https://pages.edgeone.ai/document/deployment-overview)をご覧ください。
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
### Vercelへのデプロイ
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。 詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
### Cloudflare Workersへのデプロイ または、このボタンでデプロイできます:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md) ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
## マルチプロバイダーサポート ## マルチプロバイダーサポート
@@ -201,27 +223,19 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- OpenAI - OpenAI
- Anthropic - Anthropic
- Google AI - Google AI
- Google Vertex AI
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。 AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。 📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
### サーバーサイドマルチモデル設定 **モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数JSON文字列または `ai-models.json` ファイルで設定します。 注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
## 仕組み ## 仕組み
@@ -234,6 +248,24 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。 ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
## プロジェクト構造
```
app/ # Next.js App Router
api/chat/ # AIツール付きチャットAPIエンドポイント
page.tsx # DrawIO埋め込み付きメインページ
components/ # Reactコンポーネント
chat-panel.tsx # ダイアグラム制御付きチャットインターフェース
chat-input.tsx # ファイルアップロード付きユーザー入力コンポーネント
history-dialog.tsx # ダイアグラムバージョン履歴ビューア
ui/ # UIコンポーネントボタン、カードなど
contexts/ # Reactコンテキストプロバイダー
diagram-context.tsx # グローバルダイアグラム状態管理
lib/ # ユーティリティ関数とヘルパー
ai-providers.ts # マルチプロバイダーAI設定
utils.ts # XML処理と変換ユーティリティ
public/ # サンプル画像を含む静的アセット
```
## サポート&お問い合わせ ## サポート&お問い合わせ
@@ -245,10 +277,6 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
- メールme[at]jiang.jp - メールme[at]jiang.jp
## よくある質問
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
## スター履歴 ## スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)

View File

@@ -33,21 +33,6 @@ Optional custom endpoint:
GOOGLE_BASE_URL=https://your-custom-endpoint GOOGLE_BASE_URL=https://your-custom-endpoint
``` ```
### Google Vertex AI (Enterprise GCP)
Google Vertex AI offers enterprise-grade features and data residency. **Express Mode** allows for simple API key authentication, making it compatible with edge runtimes like Vercel and Cloudflare.
```bash
GOOGLE_VERTEX_API_KEY=your_api_key
AI_MODEL=gemini-2.0-flash
```
Optional custom endpoint:
```bash
GOOGLE_VERTEX_BASE_URL=https://your-custom-endpoint
```
### OpenAI ### OpenAI
```bash ```bash
@@ -100,18 +85,7 @@ Optional custom endpoint (defaults to the recommended domain):
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1 SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1
``` ```
### SGLang
```bash
SGLANG_API_KEY=your_api_key
AI_MODEL=your_model_id
```
Optional custom endpoint:
```bash
SGLANG_BASE_URL=https://your-custom-endpoint/v1
```
### Azure OpenAI ### Azure OpenAI
@@ -173,19 +147,6 @@ Optional custom URL:
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
``` ```
### ModelScope
```bash
MODELSCOPE_API_KEY=your_api_key
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
```
Optional custom endpoint:
```bash
MODELSCOPE_BASE_URL=https://your-custom-endpoint
```
### Vercel AI Gateway ### Vercel AI Gateway
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys. Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
@@ -229,66 +190,9 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`: If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway
``` ```
## Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
### Configuration Methods
**Option 1: Environment Variable** (recommended for cloud deployments)
Set `AI_MODELS_CONFIG` as a JSON string:
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**Option 2: Config File**
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
### Example Configuration
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### Field Reference
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Display name (supports multiple configs for same provider) |
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
| `models` | Yes | List of model IDs |
| `default` | No | Set to `true` to auto-select this provider's first model as default |
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
| `baseUrlEnv` | No | Custom base URL env var name |
### Notes
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
## Model Capability Requirements ## Model Capability Requirements
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML). This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).

View File

@@ -1,78 +0,0 @@
# 常见问题解答 (FAQ)
---
## 1. 无法导出 PDF
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
**解决方案**: 先导出为图片PNG再打印转成 PDF
**相关 Issue**: #539, #125
---
## 2. 无法访问 embed.diagrams.net离线/内网部署)
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**
**解决方案**: 必须在构建时通过 `args` 传入:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
ports: ["3000:3000"]
env_file: .env
```
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
**相关 Issue**: #295, #317
---
## 3. 自建模型只思考不画图
**问题**: 本地部署的模型(如 Qwen、LiteLLM只输出思考过程不生成图表
**可能原因**:
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
**解决方案**: 开启 tool calling例如 vLLM
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**相关 Issue**: #269, #75
---
## 4. 上传图片后提示"未提供图片"
**问题**: 上传图片后,系统显示"未提供图片"错误
**可能原因**:
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
**解决方案**:
- 使用支持视觉的模型GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
- 模型名带 `vision``vl` 的支持图片
- 更新到最新版本v0.4.9+
**相关 Issue**: #324, #421, #469

View File

@@ -1,306 +0,0 @@
# AI 提供商配置
本指南介绍如何为 next-ai-draw-io 配置不同的 AI 模型提供商。
## 快速开始
1.`.env.example` 复制为 `.env.local`
2. 设置所选提供商的 API 密钥
3.`AI_MODEL` 设置为所需的模型
4. 运行 `npm run dev`
## 支持的提供商
### 豆包 (字节跳动火山引擎)
> **免费 Token**:在 [火山引擎 ARK 平台](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) 注册,即可获得所有模型 50 万免费 Token
```bash
DOUBAO_API_KEY=your_api_key
AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型
```
### Google Gemini
```bash
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
AI_MODEL=gemini-2.0-flash
```
可选的自定义端点:
```bash
GOOGLE_BASE_URL=https://your-custom-endpoint
```
### OpenAI
```bash
OPENAI_API_KEY=your_api_key
AI_MODEL=gpt-4o
```
可选的自定义端点(用于 OpenAI 兼容服务):
```bash
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### Anthropic
```bash
ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
可选的自定义端点:
```bash
ANTHROPIC_BASE_URL=https://your-custom-endpoint
```
### DeepSeek
```bash
DEEPSEEK_API_KEY=your_api_key
AI_MODEL=deepseek-chat
```
可选的自定义端点:
```bash
DEEPSEEK_BASE_URL=https://your-custom-endpoint
```
### SiliconFlow (OpenAI 兼容)
```bash
SILICONFLOW_API_KEY=your_api_key
AI_MODEL=deepseek-ai/DeepSeek-V3 # 示例;使用任何 SiliconFlow 模型 ID
```
可选的自定义端点(默认为推荐域名):
```bash
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # 或 https://api.siliconflow.cn/v1
```
### SGLang
```bash
SGLANG_API_KEY=your_api_key
AI_MODEL=your_model_id
```
可选的自定义端点:
```bash
SGLANG_BASE_URL=https://your-custom-endpoint/v1
```
### Azure OpenAI
```bash
AZURE_API_KEY=your_api_key
AZURE_RESOURCE_NAME=your-resource-name # 必填:您的 Azure 资源名称
AI_MODEL=your-deployment-name
```
或者使用自定义端点代替资源名称:
```bash
AZURE_API_KEY=your_api_key
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME 的替代方案
AI_MODEL=your-deployment-name
```
可选的推理配置:
```bash
AZURE_REASONING_EFFORT=low # 可选low, medium, high
AZURE_REASONING_SUMMARY=detailed # 可选none, brief, detailed
```
### AWS Bedrock
```bash
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
注意:在 AWS 环境Lambda、带有 IAM 角色的 EC2凭证会自动从 IAM 角色获取。
### OpenRouter
```bash
OPENROUTER_API_KEY=your_api_key
AI_MODEL=anthropic/claude-sonnet-4
```
可选的自定义端点:
```bash
OPENROUTER_BASE_URL=https://your-custom-endpoint
```
### Ollama (本地)
```bash
AI_PROVIDER=ollama
AI_MODEL=llama3.2
```
### ModelScope
```bash
MODELSCOPE_API_KEY=your_api_key
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
```
可选的自定义端点:
```bash
MODELSCOPE_BASE_URL=https://your-custom-endpoint
```
可选的自定义 URL
```bash
OLLAMA_BASE_URL=http://localhost:11434
```
### Vercel AI Gateway
Vercel AI Gateway 通过单个 API 密钥提供对多个 AI 提供商的统一访问。这简化了身份验证,让您无需管理多个 API 密钥即可在不同提供商之间切换。
**基本用法Vercel 托管网关):**
```bash
AI_GATEWAY_API_KEY=your_gateway_api_key
AI_MODEL=openai/gpt-4o
```
**自定义网关 URL用于本地开发或自托管网关**
```bash
AI_GATEWAY_API_KEY=your_custom_api_key
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
AI_MODEL=openai/gpt-4o
```
模型格式使用 `provider/model` 语法:
- `openai/gpt-4o` - OpenAI GPT-4o
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
**配置说明:**
- 如果未设置 `AI_GATEWAY_BASE_URL`,则使用默认的 Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`)
- 自定义基础 URL 适用于:
- 使用自定义网关实例进行本地开发
- 自托管 AI Gateway 部署
- 企业代理配置
- 当使用自定义基础 URL 时,必须同时提供 `AI_GATEWAY_API_KEY`
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`
```bash
AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
```
## 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。
### 配置方式
**方式一:环境变量**(推荐用于云部署)
设置 `AI_MODELS_CONFIG` 为 JSON 字符串:
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**方式二:配置文件**
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
### 配置示例
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### 字段说明
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | 显示名称(支持同一提供商多个配置) |
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
| `models` | 是 | 模型 ID 列表 |
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY` |
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
### 说明
- API Key 和凭证通过环境变量提供。默认使用标准变量名(如 `OPENAI_API_KEY`),也可通过 `apiKeyEnv` 指定自定义变量名。
- `name` 字段允许同一提供商多个配置(例如 "OpenAI Production" 和 "OpenAI Staging" 都使用 `provider: "openai"``apiKeyEnv` 不同)。
- 如果配置不存在,应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。
## 模型能力要求
此任务对模型能力要求极高因为它涉及生成具有严格格式约束draw.io XML的长文本。
**推荐模型**
- Claude Sonnet 4.5 / Opus 4.5
**关于 Ollama 的说明**:虽然支持将 Ollama 作为提供商,但除非您在本地运行像 DeepSeek R1 或 Qwen3-235B 这样的高性能模型,否则对于此用例通常不太实用。
## 温度设置 (Temperature)
您可以通过环境变量选择性地配置温度:
```bash
TEMPERATURE=0 # 输出更具确定性(推荐用于图表)
```
**重要提示**:对于不支持温度设置的模型(例如以下模型),请勿设置 `TEMPERATURE`
- GPT-5.1 和其他推理模型
- 某些专用模型
未设置时,模型将使用其默认行为。
## 推荐
- **最佳体验**使用支持视觉的模型GPT-4o, Claude, Gemini以获得图像转图表功能
- **经济实惠**DeepSeek 提供具有竞争力的价格
- **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持)
- **灵活性**OpenRouter 通过单一 API 提供对众多模型的访问

View File

@@ -1,267 +0,0 @@
# 部署到 Cloudflare Workers
本项目可以通过 **OpenNext 适配器** 部署为 **Cloudflare Worker**,为您提供:
- 全球边缘部署
- 极低延迟
- 免费的 `workers.dev` 域名托管
- 通过 R2 实现完整的 Next.js ISR 支持(可选)
> **Windows 用户重要提示:** OpenNext 和 Wrangler 在 **原生 Windows 环境下并不完全可靠**。建议方案:
>
> - 使用 **GitHub Codespaces**(完美运行)
> - 或者使用 **WSL (Linux)**
>
> 纯 Windows 构建可能会因为 WASM 文件路径问题而失败。
---
## 前置条件
1. 一个 **Cloudflare 账户**(免费版即可满足基本部署需求)
2. **Node.js 18+**
3. 安装 **Wrangler CLI**(作为开发依赖安装即可):
```bash
npm install -D wrangler
```
4. 登录 Cloudflare
```bash
npx wrangler login
```
> **注意:** 只有在启用 R2 进行 ISR 缓存时才需要绑定支付方式。基本的 Workers 部署是免费的。
---
## 第一步 — 安装依赖
```bash
npm install
```
---
## 第二步 — 配置环境变量
Cloudflare 在本地测试时使用不同的文件。
### 1) 创建 `.dev.vars`(用于 Cloudflare 本地调试 + 部署)
```bash
cp env.example .dev.vars
```
填入您的 API 密钥和配置信息。
### 2) 确保 `.env.local` 也存在(用于常规 Next.js 开发)
```bash
cp env.example .env.local
```
在此处填入相同的值。
---
## 第三步 — 选择部署类型
### 选项 A不使用 R2 部署(简单,免费)
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
**1. 使用简单的 `open-next.config.ts`**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. 使用简单的 `wrangler.jsonc`(不包含 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"
}
]
}
```
直接跳至 **第四步**
---
### 选项 B使用 R2 部署(完整的 ISR 支持)
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
- 进入 **Storage & Databases → R2**
- 点击 **Create bucket**
- 命名为:`next-inc-cache`
**2. 配置 `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. 配置 `wrangler.jsonc`(包含 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"
}
]
}
```
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
---
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
在首次部署之前,您需要一个 workers.dev 子域名。
**选项 1通过 Cloudflare 控制台(推荐)**
访问https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
**选项 2在部署过程中**
运行 `npm run deploy`Wrangler 可能会提示:
```
Would you like to register a workers.dev subdomain? (Y/n)
```
输入 `Y` 并选择一个子域名。
> **注意:** 在 CI/CD 或非交互式环境中,该提示不会出现。请先通过控制台进行注册。
---
## 第五步 — 部署到 Cloudflare
```bash
npm run deploy
```
该脚本执行的操作:
- 构建 Next.js 应用
- 通过 OpenNext 将其转换为 Cloudflare Worker
- 上传静态资源
- 发布 Worker
您的应用将可通过以下地址访问:
```
https://<worker-name>.<your-subdomain>.workers.dev
```
---
## 常见问题与修复
### `You need to register a workers.dev subdomain`
**原因:** 您的账户尚未注册 workers.dev 子域名。
**修复:** 前往 https://dash.cloudflare.com → Workers & Pages → Set up a subdomain。
---
### `Please enable R2 through the Cloudflare Dashboard`
**原因:** wrangler.jsonc 中配置了 R2但您的账户尚未启用该功能。
**修复:** 启用 R2需要支付方式或使用选项 A不使用 R2 部署)。
---
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
**原因:** `wrangler.jsonc` 中缺少 `r2_buckets` 配置。
**修复:** 添加 `r2_buckets` 部分或切换到选项 A不使用 R2
---
### `Can't set compatibility date in the future`
**原因:** wrangler 配置中的 `compatibility_date` 设置为了未来的日期。
**修复:**`compatibility_date` 修改为今天或更早的日期。
---
### Windows 错误:`resvg.wasm?module` (ENOENT)
**原因:** Windows 文件名不能包含 `?`,但某个 wasm 资源文件名中使用了 `?module`
**修复:** 在 Linux 环境WSL、Codespaces 或 CI上进行构建/部署。
---
## 可选:本地预览
部署前在本地预览 Worker
```bash
npm run preview
```
---
## 总结
| 功能 | 不使用 R2 | 使用 R2 |
|---------|------------|---------|
| 成本 | 免费 | 需要绑定支付方式 |
| ISR 缓存 | 无 | 有 |
| 静态页面 | 支持 | 支持 |
| API 路由 | 支持 | 支持 |
| 配置复杂度 | 简单 | 中等 |
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**

View File

@@ -1,29 +0,0 @@
# 使用 Docker 运行
如果您只是想在本地运行,最好的方式是使用 Docker。
首先,如果您尚未安装 Docker请先安装[获取 Docker](https://docs.docker.com/get-docker/)
然后运行:
```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
或者使用环境变量文件:
```bash
cp env.example .env
# 编辑 .env 文件并填入您的配置
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
在浏览器中打开 [http://localhost:3000](http://localhost:3000)。
请将环境变量替换为您首选的 AI 提供商配置。查看 [AI 提供商](./ai-providers.md) 了解可用选项。
> **离线部署:** 如果无法访问 `embed.diagrams.net`,请参阅 [离线部署](./offline-deployment.md) 了解配置选项。

View File

@@ -1,38 +0,0 @@
# 离线部署
通过自托管 draw.io 来替代 `embed.diagrams.net`,从而离线部署 Next AI Draw.io。
**注意:** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。
## Docker Compose 设置
1. 克隆仓库并在 `.env` 文件中定义 API 密钥。
2. 创建 `docker-compose.yml`
```yaml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
ports: ["3000:3000"]
env_file: .env
depends_on: [drawio]
```
3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`
## 配置与重要警告
**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**
| 场景 | URL 值 |
|----------|-----------|
| 本地主机 (Localhost) | `http://localhost:8080` |
| 远程/服务器 | `http://YOUR_SERVER_IP:8080` |
**切勿使用** Docker 内部别名(如 `http://drawio:8080`),因为浏览器无法解析它们。

View File

@@ -1,78 +0,0 @@
# Frequently Asked Questions (FAQ)
---
## 1. Cannot Export PDF
**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens
**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe
**Solution**: Export as image (PNG) first, then print to PDF
**Related Issues**: #539, #125
---
## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
**Solution**: Must pass via `args` at build time:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://your-server-ip:8080/
ports: ["3000:3000"]
env_file: .env
```
**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet
**Related Issues**: #295, #317
---
## 3. Self-hosted Model Only Thinks But Doesn't Draw
**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams
**Possible Causes**:
1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models
2. **Tool calling not enabled** - Model service needs tool use configuration
**Solution**: Enable tool calling, e.g., vLLM:
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**Related Issues**: #269, #75
---
## 4. "No Image Provided" After Uploading Image
**Problem**: After uploading an image, the system shows "No image provided" error
**Possible Causes**:
1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)
**Solution**:
- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro
- Models with `vision` or `vl` in name support images
- Update to latest version (v0.4.9+)
**Related Issues**: #324, #421, #469

View File

@@ -1,50 +0,0 @@
# Run with Docker
If you just want to run it locally, the best way is to use Docker.
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
Then run:
```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Or use an env file:
```bash
cp env.example .env
# Edit .env with your configuration
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
### Using server-side model configuration
You can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:
```bash
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=your_api_key \
-v $(pwd)/ai-models.json:/app/ai-models.json:ro \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
If you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:
```bash
docker run -d -p 3000:3000 \
-e OPENAI_API_KEY=your_api_key \
-e AI_MODELS_CONFIG_PATH=/config/ai-models.json \
-v $(pwd)/ai-models.json:/config/ai-models.json:ro \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.
> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./offline-deployment.md) for configuration options.

View File

@@ -1,78 +0,0 @@
# よくある質問 (FAQ)
---
## 1. PDFをエクスポートできない
**問題**: Web版でPDFエクスポートをクリックすると `convert.diagrams.net/node/export` にリダイレクトされ、その後何も起こらない
**原因**: 埋め込みDraw.ioは直接PDFエクスポートをサポートしておらず、外部変換サービスに依存しているが、iframe内では正常に動作しない
**解決策**: まず画像PNGとしてエクスポートし、その後PDFに印刷する
**関連Issue**: #539, #125
---
## 2. embed.diagrams.netにアクセスできないオフライン/イントラネットデプロイ)
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
**解決策**: ビルド時に `args` で渡す必要があります:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://あなたのサーバーIP:8080/
ports: ["3000:3000"]
env_file: .env
```
**イントラネットユーザー**: 外部ネットワークでDockerfileを修正してイメージをビルドし、イントラネットに転送する
**関連Issue**: #295, #317
---
## 3. 自前モデルが思考するだけで描画しない
**問題**: ローカルデプロイのモデルQwen、LiteLLMなどが思考過程のみを出力し、図表を生成しない
**考えられる原因**:
1. **モデルが小さすぎる** - 小さいモデルはtool calling指示に正しく従うことが難しい、32B+パラメータのモデルを推奨
2. **tool callingが有効になっていない** - モデルサービスでtool use機能を設定する必要がある
**解決策**: tool callingを有効にする、例えばvLLM
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**関連Issue**: #269, #75
---
## 4. 画像アップロード後「画像が提供されていません」と表示される
**問題**: 画像をアップロードした後、「画像が提供されていません」というエラーが表示される
**考えられる原因**:
1. モデルがビジョン機能をサポートしていないKimi K2、DeepSeek、Qwenテキストモデルなど
**解決策**:
- ビジョン対応モデルを使用GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
- モデル名に `vision` または `vl` が含まれているものは画像をサポート
- 最新バージョンv0.4.9+)にアップデート
**関連Issue**: #324, #421, #469

View File

@@ -1,306 +0,0 @@
# AIプロバイダーの設定
このガイドでは、next-ai-draw-io でさまざまな AI モデルプロバイダーを設定する方法について説明します。
## クイックスタート
1. `.env.example``.env.local` にコピーします
2. 選択したプロバイダーの API キーを設定します
3. `AI_MODEL` を希望のモデルに設定します
4. `npm run dev` を実行します
## 対応プロバイダー
### Doubao (ByteDance Volcengine)
> **無料トークン**: [Volcengine ARK プラットフォーム](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)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます
```bash
DOUBAO_API_KEY=your_api_key
AI_MODEL=doubao-seed-1-8-251215 # または他の Doubao モデル
```
### Google Gemini
```bash
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
AI_MODEL=gemini-2.0-flash
```
任意のカスタムエンドポイント:
```bash
GOOGLE_BASE_URL=https://your-custom-endpoint
```
### OpenAI
```bash
OPENAI_API_KEY=your_api_key
AI_MODEL=gpt-4o
```
任意のカスタムエンドポイントOpenAI 互換サービス用):
```bash
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### Anthropic
```bash
ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
任意のカスタムエンドポイント:
```bash
ANTHROPIC_BASE_URL=https://your-custom-endpoint
```
### DeepSeek
```bash
DEEPSEEK_API_KEY=your_api_key
AI_MODEL=deepseek-chat
```
任意のカスタムエンドポイント:
```bash
DEEPSEEK_BASE_URL=https://your-custom-endpoint
```
### SiliconFlow (OpenAI 互換)
```bash
SILICONFLOW_API_KEY=your_api_key
AI_MODEL=deepseek-ai/DeepSeek-V3 # 例; 任意の SiliconFlow モデル ID を使用
```
任意のカスタムエンドポイント(デフォルトは推奨ドメイン):
```bash
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # または https://api.siliconflow.cn/v1
```
### SGLang
```bash
SGLANG_API_KEY=your_api_key
AI_MODEL=your_model_id
```
任意のカスタムエンドポイント:
```bash
SGLANG_BASE_URL=https://your-custom-endpoint/v1
```
### Azure OpenAI
```bash
AZURE_API_KEY=your_api_key
AZURE_RESOURCE_NAME=your-resource-name # 必須: Azure リソース名
AI_MODEL=your-deployment-name
```
またはリソース名の代わりにカスタムエンドポイントを使用:
```bash
AZURE_API_KEY=your_api_key
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME の代替
AI_MODEL=your-deployment-name
```
任意の推論設定:
```bash
AZURE_REASONING_EFFORT=low # 任意: low, medium, high
AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
```
### AWS Bedrock
```bash
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
注: AWS 上IAM ロールを持つ Lambda や EC2では、認証情報は IAM ロールから自動的に取得されます。
### OpenRouter
```bash
OPENROUTER_API_KEY=your_api_key
AI_MODEL=anthropic/claude-sonnet-4
```
任意のカスタムエンドポイント:
```bash
OPENROUTER_BASE_URL=https://your-custom-endpoint
```
### Ollama (ローカル)
```bash
AI_PROVIDER=ollama
AI_MODEL=llama3.2
```
任意のカスタム URL:
```bash
OLLAMA_BASE_URL=http://localhost:11434
```
### ModelScope
```bash
MODELSCOPE_API_KEY=your_api_key
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
```
任意のカスタムエンドポイント:
```bash
MODELSCOPE_BASE_URL=https://your-custom-endpoint
```
### Vercel AI Gateway
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
**基本的な使用法 (Vercel ホストの Gateway):**
```bash
AI_GATEWAY_API_KEY=your_gateway_api_key
AI_MODEL=openai/gpt-4o
```
**カスタム Gateway URL (ローカル開発またはセルフホスト Gateway 用):**
```bash
AI_GATEWAY_API_KEY=your_custom_api_key
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
AI_MODEL=openai/gpt-4o
```
モデル形式は `provider/model` 構文を使用します:
- `openai/gpt-4o` - OpenAI GPT-4o
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
**設定に関する注意点:**
- `AI_GATEWAY_BASE_URL` が設定されていない場合、デフォルトの Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) が使用されます
- カスタムベース URL は以下の場合に便利です:
- カスタム Gateway インスタンスを使用したローカル開発
- セルフホスト AI Gateway デプロイメント
- エンタープライズプロキシ設定
- カスタムベース URL を使用する場合、`AI_GATEWAY_API_KEY` も指定する必要があります
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
```
## サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。
### 設定方法
**方法1環境変数**(クラウドデプロイ推奨)
`AI_MODELS_CONFIG` をJSON文字列として設定
```bash
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
```
**方法2設定ファイル**
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
### 設定例
```json
{
"providers": [
{
"name": "OpenAI Production",
"provider": "openai",
"models": ["gpt-4o", "gpt-4o-mini"],
"default": true
},
{
"name": "Custom DeepSeek",
"provider": "deepseek",
"models": ["deepseek-chat"],
"apiKeyEnv": "MY_DEEPSEEK_KEY",
"baseUrlEnv": "MY_DEEPSEEK_URL"
}
]
}
```
### フィールド説明
| フィールド | 必須 | 説明 |
|------------|------|------|
| `name` | はい | 表示名(同一プロバイダーの複数設定をサポート) |
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
| `models` | はい | モデルIDのリスト |
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名デフォルトは `OPENAI_API_KEY` などの標準変数) |
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
### 備考
- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名`OPENAI_API_KEY`)を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。
- `name` フィールドにより同一プロバイダーの複数設定が可能です「OpenAI Production」と「OpenAI Staging」が両方とも `provider: "openai"` を使用しつつ、異なる `apiKeyEnv` を持つ)。
- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。
## モデル性能要件
このタスクは、厳密なフォーマット制約draw.io XMLを伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
**推奨モデル**:
- Claude Sonnet 4.5 / Opus 4.5
**Ollama に関する注意**: Ollama はプロバイダーとしてサポートされていますが、DeepSeek R1 や Qwen3-235B のような高性能モデルをローカルで実行していない限り、このユースケースでは一般的に実用的ではありません。
## Temperature温度設定
環境変数で Temperature を任意に設定できます:
```bash
TEMPERATURE=0 # より決定論的な出力(ダイアグラムに推奨)
```
**重要**: 以下の Temperature 設定をサポートしていないモデルでは、`TEMPERATURE` を未設定のままにしてください:
- GPT-5.1 およびその他の推論モデル
- 一部の特殊なモデル
未設定の場合、モデルはデフォルトの挙動を使用します。
## 推奨事項
- **最高の体験**: 画像からダイアグラムを生成する機能には、ビジョン画像認識をサポートするモデルGPT-4o, Claude, Geminiを使用してください
- **低コスト**: DeepSeek は競争力のある価格を提供しています
- **プライバシー**: 完全にローカルなオフライン操作には Ollama を使用してください(強力なハードウェアが必要です)
- **柔軟性**: OpenRouter は単一の API で多数のモデルへのアクセスを提供します

View File

@@ -1,267 +0,0 @@
# Cloudflare Workers へのデプロイ
このプロジェクトは **OpenNext アダプター** を使用して **Cloudflare Worker** としてデプロイすることができ、以下のメリットがあります:
- グローバルエッジへのデプロイ
- 超低レイテンシー
- 無料の `workers.dev` ホスティング
- R2 を介した完全な Next.js ISR サポート(オプション)
> **Windows ユーザー向けの重要な注意:** OpenNext と Wrangler は、**ネイティブ Windows 環境では完全には信頼できません**。以下の方法を推奨します:
>
> - **GitHub Codespaces** を使用する(完全に動作します)
> - または **WSL (Linux)** を使用する
>
> 純粋な Windows 環境でのビルドは、WASM ファイルパスの問題により失敗する可能性があります。
---
## 前提条件
1. **Cloudflare アカウント**(基本的なデプロイには無料プランで十分です)
2. **Node.js 18以上**
3. **Wrangler CLI** のインストール(開発依存関係で問題ありません):
```bash
npm install -D wrangler
```
4. Cloudflare へのログイン:
```bash
npx wrangler login
```
> **注意:** 支払い方法の登録が必要なのは、ISR キャッシュのために R2 を有効にする場合のみです。基本的な Workers へのデプロイは無料です。
---
## ステップ 1 — 依存関係のインストール
```bash
npm install
```
---
## ステップ 2 — 環境変数の設定
Cloudflare はローカルテスト用に別のファイルを使用します。
### 1) `.dev.vars` の作成Cloudflare ローカルおよびデプロイ用)
```bash
cp env.example .dev.vars
```
API キーと設定を入力してください。
### 2) `.env.local` も存在することを確認(通常の Next.js 開発用)
```bash
cp env.example .env.local
```
同じ値を入力してください。
---
## ステップ 3 — デプロイタイプの選択
### オプション A: R2 なしでのデプロイ(シンプル、無料)
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
**1. シンプルな `open-next.config.ts` を使用:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. シンプルな `wrangler.jsonc` を使用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"
}
]
}
```
**ステップ 4** へ進んでください。
---
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
**1. R2 バケットの作成**Cloudflare ダッシュボードにて):
- **Storage & Databases → R2** へ移動
- **Create bucket** をクリック
- 名前を入力: `next-inc-cache`
**2. `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. `wrangler.jsonc` の設定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"
}
]
}
```
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
---
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
初回デプロイの前に、workers.dev サブドメインが必要です。
**オプション 1: Cloudflare ダッシュボード経由(推奨)**
アクセス先: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
**オプション 2: デプロイ時**
`npm run deploy` を実行した際、Wrangler が以下のように尋ねてくる場合があります:
```
Would you like to register a workers.dev subdomain? (Y/n)
```
`Y` を入力し、サブドメイン名を選択してください。
> **注意:** CI/CD や非対話型環境では、このプロンプトは表示されません。事前にダッシュボードで登録してください。
---
## ステップ 5 — Cloudflare へのデプロイ
```bash
npm run deploy
```
スクリプトの処理内容:
- Next.js アプリのビルド
- OpenNext を介した Cloudflare Worker への変換
- 静的アセットのアップロード
- Worker の公開
アプリは以下の URL で利用可能になります:
```
https://<worker-name>.<your-subdomain>.workers.dev
```
---
## よくある問題と解決策
### `You need to register a workers.dev subdomain`
**原因:** アカウントに workers.dev サブドメインが登録されていません。
**解決策:** https://dash.cloudflare.com → Workers & Pages → Set up a subdomain から登録してください。
---
### `Please enable R2 through the Cloudflare Dashboard`
**原因:** `wrangler.jsonc` で R2 が設定されていますが、アカウントで R2 が有効になっていません。
**解決策:** R2 を有効にする(支払い方法が必要)か、オプション AR2 なしでデプロイ)を使用してください。
---
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
**原因:** `wrangler.jsonc``r2_buckets` がありません。
**解決策:** `r2_buckets` セクションを追加するか、オプション AR2 なし)に切り替えてください。
---
### `Can't set compatibility date in the future`
**原因:** wrangler 設定の `compatibility_date` が未来の日付に設定されています。
**解決策:** `compatibility_date` を今日またはそれ以前の日付に変更してください。
---
### Windows エラー: `resvg.wasm?module` (ENOENT)
**原因:** Windows のファイル名には `?` を含めることができませんが、wasm アセットのファイル名に `?module` が使用されているためです。
**解決策:** Linux 環境WSL、Codespaces、または CIでビルド/デプロイしてください。
---
## オプション: ローカルでのプレビュー
デプロイ前に Worker をローカルでプレビューできます:
```bash
npm run preview
```
---
## まとめ
| 機能 | R2 なし | R2 あり |
|---------|------------|---------|
| コスト | 無料 | 支払い方法が必要 |
| ISR キャッシュ | なし | あり |
| 静的ページ | あり | あり |
| API ルート | あり | あり |
| 設定の複雑さ | シンプル | 普通 |
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。

View File

@@ -1,29 +0,0 @@
# Dockerで実行する
ローカルで実行したいだけであれば、Dockerを使用するのが最も良い方法です。
まず、Dockerがまだインストールされていない場合はインストールしてください: [Dockerを入手](https://docs.docker.com/get-docker/)
次に、以下を実行します。
```bash
docker run -d -p 3000:3000 \
-e AI_PROVIDER=openai \
-e AI_MODEL=gpt-4o \
-e OPENAI_API_KEY=your_api_key \
ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
または、envファイルを使用します。
```bash
cp env.example .env
# .envを構成に合わせて編集します
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
```
ブラウザで[http://localhost:3000](http://localhost:3000)を開きます。
環境変数は、お好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては、[AIプロバイダー](./ai-providers.md)を参照してください。
> **オフラインデプロイ:** `embed.diagrams.net`がブロックされている場合は、構成オプションについて[オフラインデプロイ](./offline-deployment.md)を参照してください。

View File

@@ -1,38 +0,0 @@
# オフラインデプロイ
`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。
**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。
## Docker Compose のセットアップ
1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。
2. `docker-compose.yml` を作成します。
```yaml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
ports: ["3000:3000"]
env_file: .env
depends_on: [drawio]
```
3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。
## 設定と重要な警告
**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**
| シナリオ | URL の値 |
|----------|-----------|
| ローカルホスト | `http://localhost:8080` |
| リモート/サーバー | `http://YOUR_SERVER_IP:8080` |
**`http://drawio:8080` のような Docker 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。

View File

@@ -1,270 +0,0 @@
/**
* EdgeOne Pages Edge Function for OpenAI-compatible Chat Completions API
*
* This endpoint provides an OpenAI-compatible API that can be used with
* AI SDK's createOpenAI({ baseURL: '/api/edgeai' })
*
* Uses EdgeOne Edge AI's AI.chatCompletions() which now supports native tool calling.
*/
import { z } from "zod"
// EdgeOne Pages global AI object
declare const AI: {
chatCompletions(options: {
model: string
messages: Array<{ role: string; content: string | null }>
stream?: boolean
max_tokens?: number
temperature?: number
tools?: any
tool_choice?: any
}): Promise<ReadableStream<Uint8Array> | any>
}
const messageItemSchema = z
.object({
role: z.enum(["user", "assistant", "system", "tool", "function"]),
content: z.string().nullable().optional(),
})
.passthrough()
const messageSchema = z
.object({
messages: z.array(messageItemSchema),
model: z.string().optional(),
stream: z.boolean().optional(),
tools: z.any().optional(),
tool_choice: z.any().optional(),
functions: z.any().optional(),
function_call: z.any().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
max_tokens: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
stop: z.union([z.string(), z.array(z.string())]).optional(),
response_format: z.any().optional(),
seed: z.number().optional(),
user: z.string().optional(),
n: z.number().int().optional(),
logit_bias: z.record(z.string(), z.number()).optional(),
parallel_tool_calls: z.boolean().optional(),
stream_options: z.any().optional(),
})
.passthrough()
// Model configuration
const ALLOWED_MODELS = [
"@tx/deepseek-ai/deepseek-v32",
"@tx/deepseek-ai/deepseek-r1-0528",
"@tx/deepseek-ai/deepseek-v3-0324",
]
const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
}
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
/**
* Create standardized response with CORS headers
*/
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders,
},
})
}
/**
* Handle OPTIONS request for CORS preflight
*/
function handleOptionsRequest(): Response {
return new Response(null, {
headers: {
...CORS_HEADERS,
"Access-Control-Max-Age": "86400",
},
})
}
export async function onRequest({ request, env: _env }: any) {
if (request.method === "OPTIONS") {
return handleOptionsRequest()
}
request.headers.delete("accept-encoding")
try {
const json = await request.clone().json()
const parseResult = messageSchema.safeParse(json)
if (!parseResult.success) {
return createResponse(
{
error: {
message: parseResult.error.message,
type: "invalid_request_error",
},
},
400,
)
}
const { messages, model, stream, tools, tool_choice, ...extraParams } =
parseResult.data
// Validate messages
const userMessages = messages.filter(
(message) => message.role === "user",
)
if (!userMessages.length) {
return createResponse(
{
error: {
message: "No user message found",
type: "invalid_request_error",
},
},
400,
)
}
// Resolve model
const requestedModel = model || ALLOWED_MODELS[0]
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
if (!ALLOWED_MODELS.includes(selectedModel)) {
return createResponse(
{
error: {
message: `Invalid model: ${requestedModel}.`,
type: "invalid_request_error",
},
},
429,
)
}
console.log(
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
)
try {
const isStream = !!stream
// Non-streaming: return mock response for validation
// AI.chatCompletions doesn't support non-streaming mode
if (!isStream) {
const mockResponse = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: selectedModel,
choices: [
{
index: 0,
message: {
role: "assistant",
content: "OK",
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 1,
total_tokens: 11,
},
}
return createResponse(mockResponse)
}
// Build AI.chatCompletions options for streaming
const aiOptions: any = {
...extraParams,
model: selectedModel,
messages,
stream: true,
}
// Add tools if provided
if (tools && tools.length > 0) {
aiOptions.tools = tools
}
if (tool_choice !== undefined) {
aiOptions.tool_choice = tool_choice
}
const aiResponse = await AI.chatCompletions(aiOptions)
// Streaming response
return new Response(aiResponse, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-store, no-transform",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
...CORS_HEADERS,
},
})
} catch (error: any) {
// Handle EdgeOne specific errors
try {
const message = JSON.parse(error.message)
if (message.code === 14020) {
return createResponse(
{
error: {
message:
"The daily public quota has been exhausted. After deployment, you can enjoy a personal daily exclusive quota.",
type: "rate_limit_error",
},
},
429,
)
}
return createResponse(
{ error: { message: error.message, type: "api_error" } },
500,
)
} catch {
// Not a JSON error message
}
console.error("[EdgeOne] AI error:", error.message)
return createResponse(
{
error: {
message: error.message || "AI service error",
type: "api_error",
},
},
500,
)
}
} catch (error: any) {
console.error("[EdgeOne] Request error:", error.message)
return createResponse(
{
error: {
message: "Request processing failed",
type: "server_error",
details: error.message,
},
},
500,
)
}
}

View File

@@ -1,5 +0,0 @@
{
"nodeFunctionsConfig": {
"maxDuration": 120
}
}

View File

@@ -25,25 +25,6 @@ interface ApplyPresetResult {
env?: Record<string, string> env?: Record<string, string>
} }
/** Proxy configuration interface */
interface ProxyConfig {
httpProxy?: string
httpsProxy?: string
}
/** Result of setting proxy */
interface SetProxyResult {
success: boolean
error?: string
devMode?: boolean
}
/** Result of setting user locale */
interface SetUserLocaleResult {
success: boolean
error?: string
}
declare global { declare global {
interface Window { interface Window {
/** Main window Electron API */ /** Main window Electron API */
@@ -64,14 +45,6 @@ declare global {
openFile: () => Promise<string | null> openFile: () => Promise<string | null>
/** Save data to file via save dialog */ /** Save data to file via save dialog */
saveFile: (data: string) => Promise<boolean> saveFile: (data: string) => Promise<boolean>
/** Get proxy configuration */
getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
/** Get user's preferred locale */
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
/** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
} }
/** Settings window Electron API */ /** Settings window Electron API */
@@ -98,10 +71,4 @@ declare global {
} }
} }
export type { export { ConfigPreset, ApplyPresetResult }
ConfigPreset,
ApplyPresetResult,
ProxyConfig,
SetProxyResult,
SetUserLocaleResult,
}

View File

@@ -12,12 +12,11 @@ import {
getCurrentPresetId, getCurrentPresetId,
setCurrentPreset, setCurrentPreset,
} from "./config-manager" } from "./config-manager"
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
import { restartNextServer } from "./next-server" import { restartNextServer } from "./next-server"
import { showSettingsWindow } from "./settings-window" import { showSettingsWindow } from "./settings-window"
/** /**
* Build and set the application menu with i18n support * Build and set the application menu
*/ */
export function buildAppMenu(): void { export function buildAppMenu(): void {
const template = getMenuTemplate() const template = getMenuTemplate()
@@ -26,22 +25,18 @@ export function buildAppMenu(): void {
} }
/** /**
* Rebuild the menu (call this when presets change or language changes) * Rebuild the menu (call this when presets change)
*/ */
export function rebuildAppMenu(): void { export function rebuildAppMenu(): void {
buildAppMenu() buildAppMenu()
} }
/** /**
* Get the menu template with translations * Get the menu template
*/ */
function getMenuTemplate(): MenuItemConstructorOptions[] { function getMenuTemplate(): MenuItemConstructorOptions[] {
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
// Get translations for preferred locale (saved preference or system default)
const locale = getPreferredLocale(app.getLocale())
const t = getMenuTranslations(locale)
const template: MenuItemConstructorOptions[] = [] const template: MenuItemConstructorOptions[] = []
// macOS app menu // macOS app menu
@@ -49,10 +44,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
template.push({ template.push({
label: app.name, label: app.name,
submenu: [ submenu: [
{ role: "about" }, // System-translated { role: "about" },
{ type: "separator" }, { type: "separator" },
{ {
label: t.settings, label: "Settings...",
accelerator: "CmdOrCtrl+,", accelerator: "CmdOrCtrl+,",
click: () => { click: () => {
const win = BrowserWindow.getFocusedWindow() const win = BrowserWindow.getFocusedWindow()
@@ -60,26 +55,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
}, },
}, },
{ type: "separator" }, { type: "separator" },
{ role: "services" }, // System-translated { role: "services" },
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, // System-translated { role: "hide" },
{ role: "hideOthers" }, // System-translated { role: "hideOthers" },
{ role: "unhide" }, // System-translated { role: "unhide" },
{ type: "separator" }, { type: "separator" },
{ role: "quit" }, // System-translated { role: "quit" },
], ],
}) })
} }
// File menu // File menu
template.push({ template.push({
label: t.file, label: "File",
submenu: [ submenu: [
...(isMac ...(isMac
? [] ? []
: [ : [
{ {
label: t.settings, label: "Settings",
accelerator: "CmdOrCtrl+,", accelerator: "CmdOrCtrl+,",
click: () => { click: () => {
const win = BrowserWindow.getFocusedWindow() const win = BrowserWindow.getFocusedWindow()
@@ -88,76 +83,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
}, },
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
]), ]),
isMac ? { role: "close" } : { role: "quit" }, // System-translated isMac ? { role: "close" } : { role: "quit" },
], ],
}) })
// Edit menu // Edit menu
template.push({ template.push({
label: t.edit, label: "Edit",
submenu: [ submenu: [
{ role: "undo" }, // System-translated { role: "undo" },
{ role: "redo" }, // System-translated { role: "redo" },
{ type: "separator" }, { type: "separator" },
{ role: "cut" }, // System-translated { role: "cut" },
{ role: "copy" }, // System-translated { role: "copy" },
{ role: "paste" }, // System-translated { role: "paste" },
...(isMac ...(isMac
? [ ? [
{ {
role: "pasteAndMatchStyle", role: "pasteAndMatchStyle",
} as MenuItemConstructorOptions, // System-translated } as MenuItemConstructorOptions,
{ role: "delete" } as MenuItemConstructorOptions, // System-translated { role: "delete" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated { role: "selectAll" } as MenuItemConstructorOptions,
] ]
: [ : [
{ role: "delete" } as MenuItemConstructorOptions, // System-translated { role: "delete" } as MenuItemConstructorOptions,
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated { role: "selectAll" } as MenuItemConstructorOptions,
]), ]),
], ],
}) })
// View menu // View menu
template.push({ template.push({
label: t.view, label: "View",
submenu: [ submenu: [
{ role: "reload" }, // System-translated { role: "reload" },
{ role: "forceReload" }, // System-translated { role: "forceReload" },
{ role: "toggleDevTools" }, // System-translated { role: "toggleDevTools" },
{ type: "separator" }, { type: "separator" },
{ role: "resetZoom" }, // System-translated { role: "resetZoom" },
{ role: "zoomIn" }, // System-translated { role: "zoomIn" },
{ role: "zoomOut" }, // System-translated { role: "zoomOut" },
{ type: "separator" }, { type: "separator" },
{ role: "togglefullscreen" }, // System-translated { role: "togglefullscreen" },
], ],
}) })
// Configuration menu with presets // Configuration menu with presets
template.push(buildConfigMenu(t)) template.push(buildConfigMenu())
// Window menu // Window menu
template.push({ template.push({
label: t.window, label: "Window",
submenu: [ submenu: [
{ role: "minimize" }, // System-translated { role: "minimize" },
{ role: "zoom" }, // System-translated { role: "zoom" },
...(isMac ...(isMac
? [ ? [
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
{ role: "front" } as MenuItemConstructorOptions, // System-translated { role: "front" } as MenuItemConstructorOptions,
] ]
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated : [{ role: "close" } as MenuItemConstructorOptions]),
], ],
}) })
// Help menu // Help menu
template.push({ template.push({
label: t.help, label: "Help",
submenu: [ submenu: [
{ {
label: t.documentation, label: "Documentation",
click: async () => { click: async () => {
await shell.openExternal( await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io", "https://github.com/dayuanjiang/next-ai-draw-io",
@@ -165,7 +160,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
}, },
}, },
{ {
label: t.reportIssue, label: "Report Issue",
click: async () => { click: async () => {
await shell.openExternal( await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io/issues", "https://github.com/dayuanjiang/next-ai-draw-io/issues",
@@ -181,9 +176,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
/** /**
* Build the Configuration menu with presets * Build the Configuration menu with presets
*/ */
function buildConfigMenu( function buildConfigMenu(): MenuItemConstructorOptions {
t: ReturnType<typeof getMenuTranslations>,
): MenuItemConstructorOptions {
const presets = getAllPresets() const presets = getAllPresets()
const currentPresetId = getCurrentPresetId() const currentPresetId = getCurrentPresetId()
@@ -223,11 +216,11 @@ function buildConfigMenu(
})) }))
return { return {
label: t.configuration, label: "Configuration",
submenu: [ submenu: [
...(presetItems.length > 0 ...(presetItems.length > 0
? [ ? [
{ label: t.switchPreset, enabled: false }, { label: "Switch Preset", enabled: false },
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
...presetItems, ...presetItems,
{ type: "separator" } as MenuItemConstructorOptions, { type: "separator" } as MenuItemConstructorOptions,
@@ -236,8 +229,8 @@ function buildConfigMenu(
{ {
label: label:
presetItems.length > 0 presetItems.length > 0
? t.managePresets ? "Manage Presets..."
: t.addConfigurationPreset, : "Add Configuration Preset...",
click: () => { click: () => {
const win = BrowserWindow.getFocusedWindow() const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined) showSettingsWindow(win || undefined)

View File

@@ -137,7 +137,6 @@ interface ConfigPresetsFile {
version: 1 version: 1
currentPresetId: string | null currentPresetId: string | null
presets: ConfigPreset[] presets: ConfigPreset[]
userLocale?: "en" | "zh" | "ja"
} }
const CONFIG_FILE_NAME = "config-presets.json" const CONFIG_FILE_NAME = "config-presets.json"
@@ -162,7 +161,6 @@ export function loadPresets(): ConfigPresetsFile {
version: 1, version: 1,
currentPresetId: null, currentPresetId: null,
presets: [], presets: [],
userLocale: undefined,
} }
} }
@@ -183,7 +181,6 @@ export function loadPresets(): ConfigPresetsFile {
version: 1, version: 1,
currentPresetId: null, currentPresetId: null,
presets: [], presets: [],
userLocale: undefined,
} }
} }
} }
@@ -354,10 +351,6 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
apiKey: "SILICONFLOW_API_KEY", apiKey: "SILICONFLOW_API_KEY",
baseUrl: "SILICONFLOW_BASE_URL", baseUrl: "SILICONFLOW_BASE_URL",
}, },
modelscope: {
apiKey: "MODELSCOPE_API_KEY",
baseUrl: "MODELSCOPE_BASE_URL",
},
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_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 and ollama don't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" }, bedrock: { apiKey: "", baseUrl: "" },
@@ -465,21 +458,3 @@ export function getCurrentPresetEnv(): Record<string, string> {
} }
return env return env
} }
/**
* Get user's preferred locale from config
* Returns undefined if not set
*/
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
const data = loadPresets()
return data.userLocale
}
/**
* Set user's preferred locale in config
*/
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
const data = loadPresets()
data.userLocale = locale === null ? undefined : locale
savePresets(data)
}

View File

@@ -4,7 +4,6 @@ import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader" import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers" import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server" import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager"
import { registerSettingsWindowHandlers } from "./settings-window" import { registerSettingsWindowHandlers } from "./settings-window"
import { createWindow, getMainWindow } from "./window-manager" import { createWindow, getMainWindow } from "./window-manager"
@@ -25,9 +24,6 @@ if (!gotTheLock) {
// Load environment variables from .env files // Load environment variables from .env files
loadEnvFile() loadEnvFile()
// Apply proxy settings from saved config
applyProxyToEnv()
// Apply saved preset environment variables (overrides .env) // Apply saved preset environment variables (overrides .env)
const presetEnv = getCurrentPresetEnv() const presetEnv = getCurrentPresetEnv()
for (const [key, value] of Object.entries(presetEnv)) { for (const [key, value] of Object.entries(presetEnv)) {

View File

@@ -1,5 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain } from "electron" import { app, BrowserWindow, dialog, ipcMain } from "electron"
import { rebuildAppMenu } from "./app-menu"
import { import {
applyPresetToEnv, applyPresetToEnv,
type ConfigPreset, type ConfigPreset,
@@ -8,18 +7,10 @@ import {
getAllPresets, getAllPresets,
getCurrentPreset, getCurrentPreset,
getCurrentPresetId, getCurrentPresetId,
getUserLocale,
setCurrentPreset, setCurrentPreset,
setUserLocale,
updatePreset, updatePreset,
} from "./config-manager" } from "./config-manager"
import { restartNextServer } from "./next-server" import { restartNextServer } from "./next-server"
import {
applyProxyToEnv,
getProxyConfig,
type ProxyConfig,
saveProxyConfig,
} from "./proxy-manager"
/** /**
* Allowed configuration keys for presets * Allowed configuration keys for presets
@@ -218,68 +209,4 @@ export function registerIpcHandlers(): void {
return setCurrentPreset(id) return setCurrentPreset(id)
}, },
) )
// ==================== Proxy Settings ====================
ipcMain.handle("get-proxy", () => {
return getProxyConfig()
})
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
try {
// Save config to file
saveProxyConfig(config)
// Apply to current process environment
applyProxyToEnv()
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development, env vars are already applied
// Next.js dev server may need manual restart
return { success: true, devMode: true }
}
// Production: restart Next.js server to pick up new env vars
await restartNextServer()
return { success: true }
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to apply proxy settings",
}
}
})
// ==================== User Locale ====================
ipcMain.handle("get-user-locale", () => {
return getUserLocale()
})
ipcMain.handle("set-user-locale", (_event, locale: string) => {
// Validate locale is one of the supported values
if (!["en", "zh", "ja"].includes(locale)) {
return { success: false, error: "Invalid locale" }
}
try {
setUserLocale(locale as "en" | "zh" | "ja")
// Rebuild the menu to reflect the new locale
rebuildAppMenu()
return { success: true }
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to set locale",
}
}
})
} }

View File

@@ -1,162 +0,0 @@
/**
* Internationalization support for Electron menu
* Translations for menu labels that don't use Electron's built-in roles
*/
import { getUserLocale } from "./config-manager"
export type MenuLocale = "en" | "zh" | "ja"
export interface MenuTranslations {
// App menu (macOS only)
settings: string
// File menu
file: string
// Edit menu
edit: string
// View menu
view: string
// Configuration menu
configuration: string
switchPreset: string
managePresets: string
addConfigurationPreset: string
// Window menu
window: string
// Help menu
help: string
documentation: string
reportIssue: string
}
const translations: Record<MenuLocale, MenuTranslations> = {
en: {
// App menu
settings: "Settings...",
// File menu
file: "File",
// Edit menu
edit: "Edit",
// View menu
view: "View",
// Configuration menu
configuration: "Configuration",
switchPreset: "Switch Preset",
managePresets: "Manage Presets...",
addConfigurationPreset: "Add Configuration Preset...",
// Window menu
window: "Window",
// Help menu
help: "Help",
documentation: "Documentation",
reportIssue: "Report Issue",
},
zh: {
// App menu
settings: "设置...",
// File menu
file: "文件",
// Edit menu
edit: "编辑",
// View menu
view: "查看",
// Configuration menu
configuration: "配置",
switchPreset: "切换预设",
managePresets: "管理预设...",
addConfigurationPreset: "添加配置预设...",
// Window menu
window: "窗口",
// Help menu
help: "帮助",
documentation: "文档",
reportIssue: "报告问题",
},
ja: {
// App menu
settings: "設定...",
// File menu
file: "ファイル",
// Edit menu
edit: "編集",
// View menu
view: "表示",
// Configuration menu
configuration: "設定",
switchPreset: "プリセット切り替え",
managePresets: "プリセット管理...",
addConfigurationPreset: "設定プリセットを追加...",
// Window menu
window: "ウインドウ",
// Help menu
help: "ヘルプ",
documentation: "ドキュメント",
reportIssue: "問題を報告",
},
}
/**
* Get menu translations for a given locale
* Falls back to English if locale is not supported
*/
export function getMenuTranslations(locale: string): MenuTranslations {
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
const normalized = locale.toLowerCase().split("-")[0]
if (normalized === "zh") return translations.zh
if (normalized === "ja") return translations.ja
return translations.en
}
/**
* Detect system locale from Electron app
* Returns one of: "en", "zh", "ja"
*/
export function detectSystemLocale(appLocale: string): MenuLocale {
const normalized = appLocale.toLowerCase().split("-")[0]
if (normalized === "zh") return "zh"
if (normalized === "ja") return "ja"
return "en"
}
/**
* Get locale from stored preference or system default
* Checks config file for user's language preference first
*/
export function getPreferredLocale(appLocale: string): MenuLocale {
// Try to get from saved preference first
const savedLocale = getUserLocale()
if (savedLocale) {
return savedLocale
}
// Fall back to system locale
return detectSystemLocale(appLocale)
}

View File

@@ -69,8 +69,6 @@ export async function startNextServer(): Promise<string> {
NODE_ENV: "production", NODE_ENV: "production",
PORT: String(port), PORT: String(port),
HOSTNAME: "localhost", HOSTNAME: "localhost",
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1",
} }
// Set cache directory to a writable location (user's app data folder) // Set cache directory to a writable location (user's app data folder)
@@ -87,13 +85,6 @@ export async function startNextServer(): Promise<string> {
} }
} }
// Debug: log proxy-related env vars
console.log("Proxy env vars being passed to server:", {
HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || "not set",
HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || "not set",
NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "not set",
})
// Use Electron's utilityProcess API for running Node.js in background // Use Electron's utilityProcess API for running Node.js in background
// This is the recommended way to run Node.js code in Electron // This is the recommended way to run Node.js code in Electron
serverProcess = utilityProcess.fork(serverPath, [], { serverProcess = utilityProcess.fork(serverPath, [], {
@@ -123,41 +114,13 @@ export async function startNextServer(): Promise<string> {
} }
/** /**
* Stop the Next.js server process and wait for it to exit * Stop the Next.js server process
*/ */
export async function stopNextServer(): Promise<void> { export function stopNextServer(): void {
if (serverProcess) { if (serverProcess) {
console.log("Stopping Next.js server...") console.log("Stopping Next.js server...")
// Create a promise that resolves when the process exits
const exitPromise = new Promise<void>((resolve) => {
const proc = serverProcess
if (!proc) {
resolve()
return
}
const onExit = () => {
resolve()
}
proc.once("exit", onExit)
// Timeout after 5 seconds
setTimeout(() => {
proc.removeListener("exit", onExit)
resolve()
}, 5000)
})
serverProcess.kill() serverProcess.kill()
serverProcess = null serverProcess = null
// Wait for process to exit
await exitPromise
// Additional wait for OS to release port
await new Promise((resolve) => setTimeout(resolve, 500))
} }
} }
@@ -187,8 +150,8 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
export async function restartNextServer(): Promise<string> { export async function restartNextServer(): Promise<string> {
console.log("Restarting Next.js server...") console.log("Restarting Next.js server...")
// Stop the current server and wait for it to exit // Stop the current server
await stopNextServer() stopNextServer()
// Wait for the port to be released // Wait for the port to be released
await waitForServerStop() await waitForServerStop()

View File

@@ -1,75 +0,0 @@
import { app } from "electron"
import * as fs from "fs"
import * as path from "path"
import type { ProxyConfig } from "../electron.d"
export type { ProxyConfig }
const CONFIG_FILE = "proxy-config.json"
function getConfigPath(): string {
return path.join(app.getPath("userData"), CONFIG_FILE)
}
/**
* Load proxy configuration from JSON file
*/
export function loadProxyConfig(): ProxyConfig {
try {
const configPath = getConfigPath()
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, "utf-8")
return JSON.parse(data) as ProxyConfig
}
} catch (error) {
console.error("Failed to load proxy config:", error)
}
return {}
}
/**
* Save proxy configuration to JSON file
*/
export function saveProxyConfig(config: ProxyConfig): void {
try {
const configPath = getConfigPath()
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
} catch (error) {
console.error("Failed to save proxy config:", error)
throw error
}
}
/**
* Apply proxy configuration to process.env
* Must be called BEFORE starting the Next.js server
*/
export function applyProxyToEnv(): void {
const config = loadProxyConfig()
if (config.httpProxy) {
process.env.HTTP_PROXY = config.httpProxy
process.env.http_proxy = config.httpProxy
} else {
delete process.env.HTTP_PROXY
delete process.env.http_proxy
}
if (config.httpsProxy) {
process.env.HTTPS_PROXY = config.httpsProxy
process.env.https_proxy = config.httpsProxy
} else {
delete process.env.HTTPS_PROXY
delete process.env.https_proxy
}
}
/**
* Get current proxy configuration (from process.env)
*/
export function getProxyConfig(): ProxyConfig {
return {
httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || "",
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || "",
}
}

View File

@@ -21,14 +21,4 @@ contextBridge.exposeInMainWorld("electronAPI", {
// File operations // File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"), openFile: () => ipcRenderer.invoke("dialog-open-file"),
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data), saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
// Proxy settings
getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
ipcRenderer.invoke("set-proxy", config),
// User locale settings
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
setUserLocale: (locale: string) =>
ipcRenderer.invoke("set-user-locale", locale),
}) })

View File

@@ -55,7 +55,6 @@
<option value="openrouter">OpenRouter</option> <option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option> <option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option> <option value="siliconflow">SiliconFlow</option>
<option value="modelscope">ModelScope</option>
<option value="ollama">Ollama (Local)</option> <option value="ollama">Ollama (Local)</option>
</select> </select>
</div> </div>

View File

@@ -288,7 +288,6 @@ function getProviderLabel(provider) {
openrouter: "OpenRouter", openrouter: "OpenRouter",
deepseek: "DeepSeek", deepseek: "DeepSeek",
siliconflow: "SiliconFlow", siliconflow: "SiliconFlow",
modelscope: "ModelScope",
ollama: "Ollama", ollama: "Ollama",
} }
return labels[provider] || provider return labels[provider] || provider

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway # Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
@@ -40,14 +40,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking) # GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high) # GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
# Google Vertex AI Configuration (Enterprise GCP)
# For enterprise users needing data residency, VPC Service Controls, or GCP integration
# GOOGLE_VERTEX_API_KEY= # Required: Express Mode API key
# GOOGLE_VERTEX_BASE_URL=https://... # Optional: Custom endpoint URL
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
# GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000)
# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high)
# Azure OpenAI Configuration # Azure OpenAI Configuration
# Configure endpoint using ONE of these methods: # Configure endpoint using ONE of these methods:
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path} # 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
@@ -80,10 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SGLANG_API_KEY=your-sglang-api-key # SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint # SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# ModelScope Configuration
# MODELSCOPE_API_KEY=ms-...
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
# ByteDance Doubao Configuration (via Volcengine) # ByteDance Doubao Configuration (via Volcengine)
# DOUBAO_API_KEY=your-doubao-api-key # DOUBAO_API_KEY=your-doubao-api-key
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint # DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
@@ -101,11 +89,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# LANGFUSE_SECRET_KEY=sk-lf-... # LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US # LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
# Optional server-side multi-model configuration
# If set, points to a JSON file with server-provided models (see README for schema).
# Default: ./ai-models.json in project root
# AI_MODELS_CONFIG_PATH=/path/to/ai-models.json
# Temperature (Optional) # Temperature (Optional)
# Controls randomness in AI responses. Lower = more deterministic. # Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models) # Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
@@ -129,8 +112,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Enabled by default. Set to "false" to disable. # Enabled by default. Set to "false" to disable.
# ENABLE_PDF_INPUT=true # ENABLE_PDF_INPUT=true
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000) # NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
# Security Settings (Optional)
# Allow private/internal URLs for reverse proxy setups (default: true)
# Set to "false" to block private IPs, localhost, and internal hostnames
# ALLOW_PRIVATE_URLS=false

View File

@@ -1,12 +1,4 @@
import type { MutableRefObject } from "react" import type { MutableRefObject } from "react"
import { useRef } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import type {
ValidationState,
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
@@ -37,13 +29,11 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void type AddToolOutputFn = (params: AddToolOutputParams) => void
const MAX_VALIDATION_RETRIES = 3 interface DiagramOperation {
operation: "update" | "add" | "delete"
// Type for the validation function passed from useValidateDiagram hook cell_id: string
type ValidateDiagramFn = ( new_xml?: string
imageData: string, }
sessionId?: string,
) => Promise<ValidationResult>
interface UseDiagramToolHandlersParams { interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string> partialXmlRef: MutableRefObject<string>
@@ -52,14 +42,6 @@ interface UseDiagramToolHandlersParams {
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string> onFetchChart: (saveToHistory?: boolean) => Promise<string>
onExport: () => void onExport: () => void
captureValidationPng?: () => Promise<string | null>
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
sessionId?: string
onValidationStateChange?: (
toolCallId: string,
state: ValidationState,
) => void
} }
/** /**
@@ -76,34 +58,7 @@ export function useDiagramToolHandlers({
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart,
onExport, onExport,
captureValidationPng,
validateDiagram,
enableVlmValidation = true,
sessionId,
onValidationStateChange,
}: UseDiagramToolHandlersParams) { }: UseDiagramToolHandlersParams) {
// Track validation retry count per tool call
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
// Helper to update validation state
const updateValidationState = (
toolCallId: string,
status: ValidationStatus,
options?: {
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string
},
) => {
if (onValidationStateChange) {
onValidationStateChange(toolCallId, {
status,
...options,
})
}
}
const handleToolCall = async ( const handleToolCall = async (
{ toolCall }: { toolCall: ToolCall }, { toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn, addToolOutput: AddToolOutputFn,
@@ -205,159 +160,7 @@ ${finalXml}
// Success - diagram will be rendered by chat-message-display // Success - diagram will be rendered by chat-message-display
if (DEBUG) { if (DEBUG) {
console.log( console.log(
"[display_diagram] Success! Checking if VLM validation is enabled...", "[display_diagram] Success! Adding tool output with state: output-available",
)
}
// VLM validation after successful display
if (
enableVlmValidation &&
captureValidationPng &&
validateDiagram
) {
let capturedPngData: string | null = null
try {
// Notify UI that we're starting capture
updateValidationState(toolCall.toolCallId, "capturing")
// Small delay (100ms) to allow diagram rendering to complete before capture.
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
await new Promise((resolve) => setTimeout(resolve, 100))
capturedPngData = await captureValidationPng()
if (capturedPngData) {
if (DEBUG) {
console.log(
"[display_diagram] Captured PNG for validation",
)
}
const retryCount =
validationRetryCountRef.current.get(
toolCall.toolCallId,
) || 0
// Notify UI that we're validating (include the image)
updateValidationState(
toolCall.toolCallId,
"validating",
{
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
imageData: capturedPngData,
},
)
const result = await validateDiagram(
capturedPngData,
sessionId,
)
if (!result.valid) {
if (retryCount < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current.set(
toolCall.toolCallId,
retryCount + 1,
)
const feedback =
formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
// Notify UI of validation failure (include the image)
updateValidationState(
toolCall.toolCallId,
"failed",
{
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
result,
imageData: capturedPngData,
},
)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
})
return
} else {
// Max retries reached - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
// Notify UI that we're accepting with issues (include the image)
updateValidationState(
toolCall.toolCallId,
"skipped",
{ result, imageData: capturedPngData },
)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Diagram displayed (validation issues noted but max retries reached).",
})
return
}
} else {
// Validation passed - clean up retry count
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
if (DEBUG) {
console.log(
"[display_diagram] Validation passed!",
)
}
// Notify UI of success (include the image)
// Use "success_with_warnings" if valid but has issues
const hasWarnings = result.issues.length > 0
updateValidationState(
toolCall.toolCallId,
hasWarnings
? "success_with_warnings"
: "success",
{ result, imageData: capturedPngData },
)
}
} else {
// PNG capture failed - skip validation
updateValidationState(toolCall.toolCallId, "skipped")
}
} catch (error) {
// VLM validation error - log but don't block the user
console.warn(
"[display_diagram] VLM validation error:",
error,
)
updateValidationState(toolCall.toolCallId, "error", {
error:
error instanceof Error
? error.message
: "Validation failed",
imageData: capturedPngData || undefined,
})
}
}
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-available",
) )
} }
addToolOutput({ addToolOutput({

View File

@@ -1,7 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage" import { STORAGE_KEYS } from "@/lib/storage"
import { import {
createEmptyConfig, createEmptyConfig,
@@ -12,6 +11,7 @@ import {
flattenModels, flattenModels,
type ModelConfig, type ModelConfig,
type MultiModelConfig, type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig, type ProviderConfig,
type ProviderName, type ProviderName,
} from "@/lib/types/model-config" } from "@/lib/types/model-config"
@@ -133,56 +133,14 @@ export interface UseModelConfigReturn {
export function useModelConfig(): UseModelConfigReturn { export function useModelConfig(): UseModelConfigReturn {
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig) const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
const [serverLoaded, setServerLoaded] = useState(false)
// Load client config on mount // Load config on mount
useEffect(() => { useEffect(() => {
const loaded = loadConfig() const loaded = loadConfig()
setConfig(loaded) setConfig(loaded)
setIsLoaded(true) setIsLoaded(true)
}, []) }, [])
// Load server models on mount (if any)
useEffect(() => {
if (typeof window === "undefined") return
fetch("/api/server-models")
.then((res) => {
if (!res.ok) {
console.error(
"Failed to load server models:",
res.status,
res.statusText,
)
throw new Error(`Request failed with status ${res.status}`)
}
return res.json()
})
.then((data) => {
const raw: FlattenedServerModel[] = data?.models || []
setServerModels(raw)
setServerLoaded(true)
// Auto-select default server model if no model is currently selected
setConfig((prev) => {
if (!prev.selectedModelId && raw.length > 0) {
const defaultModel = raw.find((m) => m.isDefault)
if (defaultModel) {
return { ...prev, selectedModelId: defaultModel.id }
}
// If no default marked, use first server model
return { ...prev, selectedModelId: raw[0].id }
}
return prev
})
})
.catch((error) => {
console.error("Error while loading server models:", error)
setServerLoaded(true)
})
}, [])
// Save config whenever it changes (after initial load) // Save config whenever it changes (after initial load)
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded) {
@@ -191,33 +149,9 @@ export function useModelConfig(): UseModelConfigReturn {
}, [config, isLoaded]) }, [config, isLoaded])
// Derived state // Derived state
const userModels = flattenModels(config) const models = flattenModels(config)
const models: FlattenedModel[] = [
// Server models (read-only, credentials from env)
...serverModels.map((m) => ({
id: m.id,
modelId: m.modelId,
provider: m.provider,
providerLabel: `Server · ${m.providerLabel}`,
apiKey: "",
baseUrl: undefined,
awsAccessKeyId: undefined,
awsSecretAccessKey: undefined,
awsRegion: undefined,
awsSessionToken: undefined,
validated: true,
source: "server" as const,
isDefault: m.isDefault,
apiKeyEnv: m.apiKeyEnv,
baseUrlEnv: m.baseUrlEnv,
})),
// User models from local configuration
...userModels,
]
const selectedModel = config.selectedModelId const selectedModel = config.selectedModelId
? models.find((m) => m.id === config.selectedModelId) ? findModelById(config, config.selectedModelId)
: undefined : undefined
// Actions // Actions
@@ -349,7 +283,7 @@ export function useModelConfig(): UseModelConfigReturn {
return { return {
config, config,
isLoaded: isLoaded && serverLoaded, isLoaded,
models, models,
selectedModel, selectedModel,
selectedModelId: config.selectedModelId, selectedModelId: config.selectedModelId,
@@ -381,10 +315,6 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: string awsSecretAccessKey: string
awsRegion: string awsRegion: string
awsSessionToken: string awsSessionToken: string
// Selected model ID (for server model lookup)
selectedModelId: string
// Vertex AI credentials (Express Mode)
vertexApiKey: string
} { } {
const empty = { const empty = {
accessCode: "", accessCode: "",
@@ -396,8 +326,6 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "", awsSecretAccessKey: "",
awsRegion: "", awsRegion: "",
awsSessionToken: "", awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
} }
if (typeof window === "undefined") return empty if (typeof window === "undefined") return empty
@@ -420,8 +348,6 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "", awsSecretAccessKey: "",
awsRegion: "", awsRegion: "",
awsSessionToken: "", awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
} }
} }
@@ -432,32 +358,12 @@ export function getSelectedAIConfig(): {
return { ...empty, accessCode } return { ...empty, accessCode }
} }
// No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect) // No selected model = use server default
if (!config.selectedModelId) { if (!config.selectedModelId) {
return { ...empty, accessCode } return { ...empty, accessCode }
} }
// Server-side model selection (id = "server:<name-slug>:<modelId>") // Find selected model
// Provider is resolved server-side via findServerModelById()
if (config.selectedModelId.startsWith("server:")) {
const parts = config.selectedModelId.split(":")
const nameSlug = parts[1] || ""
const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs
return {
...empty,
accessCode,
// Note: nameSlug is NOT the provider, but we send it for backwards compat
// Server uses selectedModelId to lookup the actual provider
aiProvider: nameSlug,
aiBaseUrl: "",
aiApiKey: "",
aiModel: modelId,
selectedModelId: config.selectedModelId,
}
}
// Find selected user-defined model
const model = findModelById(config, config.selectedModelId) const model = findModelById(config, config.selectedModelId)
if (!model) { if (!model) {
return { ...empty, accessCode } return { ...empty, accessCode }
@@ -474,8 +380,5 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: model.awsSecretAccessKey || "", awsSecretAccessKey: model.awsSecretAccessKey || "",
awsRegion: model.awsRegion || "", awsRegion: model.awsRegion || "",
awsSessionToken: model.awsSessionToken || "", awsSessionToken: model.awsSessionToken || "",
selectedModelId: config.selectedModelId || "",
// Vertex AI credentials (Express Mode)
vertexApiKey: model.vertexApiKey || "",
} }
} }

View File

@@ -1,322 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import {
type ChatSession,
createEmptySession,
deleteSession as deleteSessionFromDB,
enforceSessionLimit,
extractTitle,
getAllSessionMetadata,
getSession,
isIndexedDBAvailable,
migrateFromLocalStorage,
type SessionMetadata,
type StoredMessage,
saveSession,
} from "@/lib/session-storage"
export interface SessionData {
messages: StoredMessage[]
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string
diagramHistory?: { svg: string; xml: string }[]
}
export interface UseSessionManagerReturn {
// State
sessions: SessionMetadata[]
currentSessionId: string | null
currentSession: ChatSession | null
isLoading: boolean
isAvailable: boolean
// Actions
switchSession: (id: string) => Promise<SessionData | null>
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
saveCurrentSession: (
data: SessionData,
forSessionId?: string | null,
) => Promise<void>
refreshSessions: () => Promise<void>
clearCurrentSession: () => void
}
interface UseSessionManagerOptions {
/** Session ID from URL param - if provided, load this session; if null, start blank */
initialSessionId?: string | null
}
export function useSessionManager(
options: UseSessionManagerOptions = {},
): UseSessionManagerReturn {
const { initialSessionId } = options
const [sessions, setSessions] = useState<SessionMetadata[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
)
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
null,
)
const [isLoading, setIsLoading] = useState(true)
const [isAvailable, setIsAvailable] = useState(false)
const isInitializedRef = useRef(false)
// Sequence guard for URL changes - prevents out-of-order async resolution
const urlChangeSequenceRef = useRef(0)
// Load sessions list
const refreshSessions = useCallback(async () => {
if (!isIndexedDBAvailable()) return
try {
const metadata = await getAllSessionMetadata()
setSessions(metadata)
} catch (error) {
console.error("Failed to refresh sessions:", error)
}
}, [])
// Initialize on mount
useEffect(() => {
if (isInitializedRef.current) return
isInitializedRef.current = true
async function init() {
setIsLoading(true)
if (!isIndexedDBAvailable()) {
setIsAvailable(false)
setIsLoading(false)
return
}
setIsAvailable(true)
try {
// Run migration first (one-time conversion from localStorage)
await migrateFromLocalStorage()
// Load sessions list
const metadata = await getAllSessionMetadata()
setSessions(metadata)
// Only load a session if initialSessionId is provided (from URL param)
if (initialSessionId) {
const session = await getSession(initialSessionId)
if (session) {
setCurrentSession(session)
setCurrentSessionId(session.id)
}
// If session not found, stay in blank state (URL has invalid session ID)
}
// If no initialSessionId, start with blank state (no auto-restore)
} catch (error) {
console.error("Failed to initialize session manager:", error)
} finally {
setIsLoading(false)
}
}
init()
}, [initialSessionId])
// Handle URL session ID changes after initialization
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
// when clearCurrentSession() is called before URL updates
useEffect(() => {
if (!isInitializedRef.current) return // Wait for initial load
if (!isAvailable) return
// Increment sequence to invalidate any pending async operations
urlChangeSequenceRef.current++
const currentSequence = urlChangeSequenceRef.current
async function handleSessionIdChange() {
if (initialSessionId) {
// URL has session ID - load it
const session = await getSession(initialSessionId)
// Check if this request is still the latest (sequence guard)
// If not, a newer URL change happened while we were loading
if (currentSequence !== urlChangeSequenceRef.current) {
return
}
if (session) {
// Only update if the session is different from current
setCurrentSessionId((current) => {
if (current !== session.id) {
setCurrentSession(session)
return session.id
}
return current
})
}
}
// Removed: else clause that clears session
// Clearing is now handled explicitly by clearCurrentSession()
// This prevents race conditions when URL update is async
}
handleSessionIdChange()
}, [initialSessionId, isAvailable])
// Refresh sessions on window focus (multi-tab sync)
useEffect(() => {
const handleFocus = () => {
refreshSessions()
}
window.addEventListener("focus", handleFocus)
return () => window.removeEventListener("focus", handleFocus)
}, [refreshSessions])
// Switch to a different session
const switchSession = useCallback(
async (id: string): Promise<SessionData | null> => {
if (id === currentSessionId) return null
// Save current session first if it has messages
if (currentSession && currentSession.messages.length > 0) {
await saveSession(currentSession)
}
// Load the target session
const session = await getSession(id)
if (!session) {
console.error("Session not found:", id)
return null
}
// Update state
setCurrentSession(session)
setCurrentSessionId(session.id)
return {
messages: session.messages,
xmlSnapshots: session.xmlSnapshots,
diagramXml: session.diagramXml,
thumbnailDataUrl: session.thumbnailDataUrl,
diagramHistory: session.diagramHistory,
}
},
[currentSessionId, currentSession],
)
// Delete a session
const deleteSession = useCallback(
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
const wasCurrentSession = id === currentSessionId
await deleteSessionFromDB(id)
// If deleting current session, clear state (caller will show new empty session)
if (wasCurrentSession) {
setCurrentSession(null)
setCurrentSessionId(null)
}
await refreshSessions()
return { wasCurrentSession }
},
[currentSessionId, refreshSessions],
)
// Save current session data (debounced externally by caller)
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
const saveCurrentSession = useCallback(
async (
data: SessionData,
forSessionId?: string | null,
): Promise<void> => {
// If forSessionId is provided, verify it matches current session
// This prevents stale debounced saves from overwriting a newly switched session
if (
forSessionId !== undefined &&
forSessionId !== currentSessionId
) {
return
}
if (!currentSession) {
// Create a new session if none exists
const newSession: ChatSession = {
...createEmptySession(),
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
await saveSession(newSession)
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
await refreshSessions()
return
}
// Update existing session
const updatedSession: ChatSession = {
...currentSession,
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl:
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
}
await saveSession(updatedSession)
setCurrentSession(updatedSession)
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length > 0,
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
}
: s,
),
)
},
[currentSession, currentSessionId, refreshSessions],
)
// Clear current session state (for starting fresh without loading another session)
const clearCurrentSession = useCallback(() => {
setCurrentSession(null)
setCurrentSessionId(null)
}, [])
return {
sessions,
currentSessionId,
currentSession,
isLoading,
isAvailable,
switchSession,
deleteSession,
saveCurrentSession,
refreshSessions,
clearCurrentSession,
}
}

View File

@@ -1,136 +0,0 @@
"use client"
/**
* Hook for VLM-based diagram validation using AI SDK's useObject.
*/
import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react"
import { getApiEndpoint } from "@/lib/base-path"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export type { ValidationResult }
// Default valid result for fallback cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
interface UseValidateDiagramOptions {
onSuccess?: (result: ValidationResult) => void
onError?: (error: Error) => void
}
// Track pending validation promises for imperative API
type PendingValidation = {
resolve: (result: ValidationResult) => void
reject: (error: Error) => void
}
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { onSuccess, onError } = options
const pendingValidationRef = useRef<PendingValidation | null>(null)
const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema,
onFinish: ({
object,
error: finishError,
}: {
object: ValidationResult | undefined
error: Error | undefined
}) => {
if (finishError) {
console.error(
"[useValidateDiagram] Validation error:",
finishError,
)
onError?.(finishError)
pendingValidationRef.current?.reject(finishError)
pendingValidationRef.current = null
return
}
if (object) {
const result = object as ValidationResult
onSuccess?.(result)
pendingValidationRef.current?.resolve(result)
pendingValidationRef.current = null
}
},
onError: (err: Error) => {
console.error("[useValidateDiagram] Stream error:", err)
onError?.(err)
pendingValidationRef.current?.reject(err)
pendingValidationRef.current = null
},
})
/**
* Validate a diagram image.
* Returns a promise that resolves with the validation result.
*/
const validate = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
// Reject any pending validation to prevent promise leaks
if (pendingValidationRef.current) {
pendingValidationRef.current.reject(
new Error("Validation superseded by new request"),
)
pendingValidationRef.current = null
}
return new Promise((resolve, reject) => {
// Store the promise handlers
pendingValidationRef.current = { resolve, reject }
// Submit the validation request
submit({ imageData, sessionId })
})
},
[submit],
)
/**
* Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures.
*/
const validateWithFallback = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
try {
return await validate(imageData, sessionId)
} catch (error) {
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
)
return DEFAULT_VALID_RESULT
}
},
[validate],
)
return {
// Validation functions
validate,
validateWithFallback,
stop,
// State
isValidating: isLoading,
partialResult: object as ValidationResult | undefined,
error,
}
}

26
lib/ai-config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { STORAGE_KEYS } from "./storage"
/**
* Get AI configuration from localStorage.
* Returns API keys and settings for custom AI providers.
* Used to override server defaults when user provides their own API key.
*/
export function getAIConfig() {
if (typeof window === "undefined") {
return {
accessCode: "",
aiProvider: "",
aiBaseUrl: "",
aiApiKey: "",
aiModel: "",
}
}
return {
accessCode: localStorage.getItem(STORAGE_KEYS.accessCode) || "",
aiProvider: localStorage.getItem(STORAGE_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(STORAGE_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(STORAGE_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(STORAGE_KEYS.aiModel) || "",
}
}

View File

@@ -4,14 +4,24 @@ import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway, gateway } from "@ai-sdk/gateway" import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI, openai } from "@ai-sdk/openai" import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2" import { createOllama, ollama } from "ollama-ai-provider-v2"
import type { ProviderName } from "@/lib/types/model-config"
export type { ProviderName } export type ProviderName =
| "bedrock"
| "openai"
| "anthropic"
| "google"
| "azure"
| "ollama"
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
| "doubao"
interface ModelConfig { interface ModelConfig {
model: any model: any
@@ -30,13 +40,6 @@ export interface ClientOverrides {
awsSecretAccessKey?: string | null awsSecretAccessKey?: string | null
awsRegion?: string | null awsRegion?: string | null
awsSessionToken?: string | null awsSessionToken?: string | null
// Vertex AI config
vertexApiKey?: string | null // Express Mode API key
// Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string>
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
} }
// Providers that can be used with client-provided API keys // Providers that can be used with client-provided API keys
@@ -44,7 +47,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openai", "openai",
"anthropic", "anthropic",
"google", "google",
"vertexai",
"azure", "azure",
"bedrock", "bedrock",
"openrouter", "openrouter",
@@ -52,9 +54,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"siliconflow", "siliconflow",
"sglang", "sglang",
"gateway", "gateway",
"edgeone",
"doubao", "doubao",
"modelscope",
] ]
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -69,62 +69,6 @@ const ANTHROPIC_BETA_HEADERS = {
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14", "anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
} }
/**
* Resolve baseURL based on whether user is providing their own API key.
* When user provides their own API key, we should NOT fall back to server's
* baseURL environment variable - user credentials should only be sent to
* user-specified endpoints or official provider endpoints.
*
* @param userApiKey - User-provided API key (if any)
* @param userBaseUrl - User-provided base URL (if any)
* @param serverBaseUrl - Server's base URL from environment variable
* @param defaultBaseUrl - Provider's official/default base URL (optional)
* @returns The resolved base URL to use
*/
export function resolveBaseURL(
userApiKey: string | null | undefined,
userBaseUrl: string | null | undefined,
serverBaseUrl: string | undefined,
defaultBaseUrl?: string,
): string | undefined {
if (userApiKey) {
// User provides their own API key - only use user's baseUrl or default
return userBaseUrl || defaultBaseUrl || undefined
}
// No user API key - fall back to server config
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
}
/**
* Resolve API key from custom env var name or default env var.
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
*
* Priority:
* 1. User-provided API key (overrides.apiKey)
* 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
* 3. Default provider env var (defaultEnvVar)
*/
function resolveApiKey(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
): string | undefined {
if (overrides?.apiKey) return overrides.apiKey
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
return process.env[defaultEnvVar]
}
/**
* Resolve base URL from custom env var name or default env var.
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
*/
function resolveBaseUrlEnv(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
): string | undefined {
if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv]
return process.env[defaultEnvVar]
}
/** /**
* Safely parse integer from environment variable with validation * Safely parse integer from environment variable with validation
*/ */
@@ -159,8 +103,6 @@ function parseIntSafe(
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled) * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000) * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high) * - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
* - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000)
* - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high)
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high) * - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed) * - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000) * - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
@@ -325,46 +267,7 @@ function buildProviderOptions(
} }
break break
} }
case "vertexai": {
const thinkingBudget = parseIntSafe(
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
"GOOGLE_VERTEX_THINKING_BUDGET",
1024,
100000,
)
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
const isGemini3 =
modelId?.includes("gemini-3") ||
modelId?.includes("gemini3")
const isGemini25 =
modelId?.includes("2.5") || modelId?.includes("2-5")
if (isGemini3 && thinkingLevel) {
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
thinkingConfig.thinkingLevel = thinkingLevel as
| "minimal"
| "low"
| "medium"
| "high"
} else if (isGemini25 && thinkingBudget) {
thinkingConfig.thinkingBudget = thinkingBudget
}
options.google = { thinkingConfig }
}
break
}
case "azure": { case "azure": {
const reasoningEffort = process.env.AZURE_REASONING_EFFORT const reasoningEffort = process.env.AZURE_REASONING_EFFORT
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
@@ -446,7 +349,6 @@ function buildProviderOptions(
case "siliconflow": case "siliconflow":
case "sglang": case "sglang":
case "gateway": case "gateway":
case "modelscope":
case "doubao": { case "doubao": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs // Gateway passes through to underlying providers which handle their own configs
@@ -466,7 +368,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openai: "OPENAI_API_KEY", openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY", anthropic: "ANTHROPIC_API_KEY",
google: "GOOGLE_GENERATIVE_AI_API_KEY", google: "GOOGLE_GENERATIVE_AI_API_KEY",
vertexai: "GOOGLE_VERTEX_API_KEY",
azure: "AZURE_API_KEY", azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
@@ -474,9 +375,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
siliconflow: "SILICONFLOW_API_KEY", siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY", sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY", gateway: "AI_GATEWAY_API_KEY",
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
doubao: "DOUBAO_API_KEY", doubao: "DOUBAO_API_KEY",
modelscope: "MODELSCOPE_API_KEY",
} }
/** /**
@@ -514,15 +413,9 @@ function detectProvider(): ProviderName | null {
/** /**
* Validate that required API keys are present for the selected provider * Validate that required API keys are present for the selected provider
* @param provider - The provider to validate
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
*/ */
function validateProviderCredentials( function validateProviderCredentials(provider: ProviderName): void {
provider: ProviderName, const requiredVar = PROVIDER_ENV_VARS[provider]
customApiKeyEnv?: string,
): void {
// Use custom env var name if provided, otherwise use default
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) { if (requiredVar && !process.env[requiredVar]) {
throw new Error( throw new Error(
`${requiredVar} environment variable is required for ${provider} provider. ` + `${requiredVar} environment variable is required for ${provider} provider. ` +
@@ -547,7 +440,7 @@ function validateProviderCredentials(
* Get the AI model based on environment variables * Get the AI model based on environment variables
* *
* Environment variables: * Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope) * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
* - AI_MODEL: The model ID/name for the selected provider * - AI_MODEL: The model ID/name for the selected provider
* *
* Provider-specific env vars: * Provider-specific env vars:
@@ -562,23 +455,15 @@ function validateProviderCredentials(
* - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key * - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1) * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
* - SGLANG_API_KEY: SGLang API key * - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional) * - SGLANG_BASE_URL: SGLang endpoint (optional)
* - MODELSCOPE_API_KEY: ModelScope API key
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
*/ */
export function getAIModel(overrides?: ClientOverrides): ModelConfig { export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
// If a custom baseUrl is provided, an API key MUST also be provided. // If a custom baseUrl is provided, an API key MUST also be provided.
// This prevents attackers from redirecting server API keys to malicious endpoints. // This prevents attackers from redirecting server API keys to malicious endpoints.
// Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime) if (overrides?.baseUrl && !overrides?.apiKey) {
if (
overrides?.baseUrl &&
!overrides?.apiKey &&
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
overrides?.provider !== "edgeone"
) {
throw new Error( throw new Error(
`API key is required when using a custom base URL. ` + `API key is required when using a custom base URL. ` +
`Please provide your own API key in Settings.`, `Please provide your own API key in Settings.`,
@@ -586,11 +471,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
// Check if client is providing their own provider override // Check if client is providing their own provider override
const isClientOverride = !!( const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
overrides?.provider &&
(overrides?.apiKey ||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
)
// Use client override if provided, otherwise fall back to env vars // Use client override if provided, otherwise fall back to env vars
const modelId = overrides?.modelId || process.env.AI_MODEL const modelId = overrides?.modelId || process.env.AI_MODEL
@@ -646,7 +527,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- AZURE_API_KEY for Azure\n` + `- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` + `- SGLANG_API_KEY for SGLang\n` +
`- MODELSCOPE_API_KEY for ModelScope\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`, `Or set AI_PROVIDER=ollama for local Ollama.`,
) )
} else { } else {
@@ -660,7 +540,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Only validate server credentials if client isn't providing their own API key // Only validate server credentials if client isn't providing their own API key
if (!isClientOverride) { if (!isClientOverride) {
validateProviderCredentials(provider, overrides?.apiKeyEnv) validateProviderCredentials(provider)
} }
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`) console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
@@ -683,8 +563,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
const bedrockProvider = hasClientCredentials const bedrockProvider = hasClientCredentials
? createAmazonBedrock({ ? createAmazonBedrock({
region: bedrockRegion, region: bedrockRegion,
accessKeyId: overrides.awsAccessKeyId as string, accessKeyId: overrides.awsAccessKeyId!,
secretAccessKey: overrides.awsSecretAccessKey as string, secretAccessKey: overrides.awsSecretAccessKey!,
...(overrides?.awsSessionToken && { ...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken, sessionToken: overrides.awsSessionToken,
}), }),
@@ -710,16 +590,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "openai": { case "openai": {
const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY") const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
overrides,
"OPENAI_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
if (baseURL) { if (baseURL) {
// Custom base URL = third-party proxy, use Chat Completions API // Custom base URL = third-party proxy, use Chat Completions API
// for compatibility (most proxies don't support /responses endpoint) // for compatibility (most proxies don't support /responses endpoint)
@@ -737,17 +609,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "anthropic": { case "anthropic": {
const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY") const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL =
overrides, overrides?.baseUrl ||
"ANTHROPIC_BASE_URL", process.env.ANTHROPIC_BASE_URL ||
) "https://api.anthropic.com/v1"
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
"https://api.anthropic.com/v1",
)
const customProvider = createAnthropic({ const customProvider = createAnthropic({
apiKey, apiKey,
baseURL, baseURL,
@@ -760,19 +626,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "google": { case "google": {
const apiKey = resolveApiKey( const apiKey =
overrides, overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
"GOOGLE_GENERATIVE_AI_API_KEY", const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
)
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"GOOGLE_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customGoogle = createGoogleGenerativeAI({ const customGoogle = createGoogleGenerativeAI({
apiKey, apiKey,
@@ -784,42 +640,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
break break
} }
case "vertexai": {
// Express Mode: Use API key for authentication
const vertexApiKey =
overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
if (!vertexApiKey) {
throw new Error(
"Vertex AI requires an API key for Express Mode. " +
"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.",
)
}
// Support custom base URL from env or client override
const baseURL =
overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL
const vertexProvider = createVertex({
apiKey: vertexApiKey,
...(baseURL && { baseURL }),
})
model = vertexProvider(modelId)
break
}
case "azure": { case "azure": {
const apiKey = resolveApiKey(overrides, "AZURE_API_KEY") const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL") const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
const baseURL = resolveBaseURL( const resourceName = process.env.AZURE_RESOURCE_NAME
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
// Only use server's resourceName if user is NOT providing their own API key
const resourceName = overrides?.apiKey
? undefined
: process.env.AZURE_RESOURCE_NAME
// Azure requires either baseURL or resourceName to construct the endpoint // Azure requires either baseURL or resourceName to construct the endpoint
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path} // resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
if (baseURL || resourceName || overrides?.apiKey) { if (baseURL || resourceName || overrides?.apiKey) {
@@ -848,16 +673,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
case "openrouter": { case "openrouter": {
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY") const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL =
overrides, overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
"OPENROUTER_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const openrouter = createOpenRouter({ const openrouter = createOpenRouter({
apiKey, apiKey,
...(baseURL && { baseURL }), ...(baseURL && { baseURL }),
@@ -867,16 +685,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "deepseek": { case "deepseek": {
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY") const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
overrides,
"DEEPSEEK_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
const customDeepSeek = createDeepSeek({ const customDeepSeek = createDeepSeek({
apiKey, apiKey,
@@ -890,17 +700,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "siliconflow": { case "siliconflow": {
const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY") const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL =
overrides, overrides?.baseUrl ||
"SILICONFLOW_BASE_URL", process.env.SILICONFLOW_BASE_URL ||
) "https://api.siliconflow.com/v1"
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
"https://api.siliconflow.cn/v1",
)
const siliconflowProvider = createOpenAI({ const siliconflowProvider = createOpenAI({
apiKey, apiKey,
baseURL, baseURL,
@@ -910,20 +714,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
} }
case "sglang": { case "sglang": {
const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY") const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
overrides,
"SGLANG_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const sglangProvider = createOpenAI({ const sglangProvider = createOpenAI({
apiKey, apiKey,
...(baseURL && { baseURL }), baseURL,
// Add a custom fetch wrapper to intercept and fix the stream from sglang // Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => { fetch: async (url, options) => {
const response = await fetch(url, options) const response = await fetch(url, options)
@@ -980,7 +776,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`data: ${JSON.stringify(data)}\n\n`, `data: ${JSON.stringify(data)}\n\n`,
), ),
) )
} catch (_e) { } catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream. // If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue( controller.enqueue(
new TextEncoder().encode( new TextEncoder().encode(
@@ -1027,16 +823,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Vercel AI Gateway - unified access to multiple AI providers // Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway // See: https://vercel.com/ai-gateway
const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY") const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL =
overrides, overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
"AI_GATEWAY_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
// Only use custom configuration if explicitly set (local dev or custom Gateway) // Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
if (baseURL || overrides?.apiKey) { if (baseURL || overrides?.apiKey) {
@@ -1051,77 +840,23 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "edgeone": {
// EdgeOne Pages Edge AI - uses OpenAI-compatible API
// AI SDK appends /chat/completions to baseURL
// /api/edgeai + /chat/completions = /api/edgeai/chat/completions
const baseURL = overrides?.baseUrl || "/api/edgeai"
const edgeoneProvider = createOpenAI({
apiKey: "edgeone", // Dummy key - EdgeOne doesn't require API key
baseURL,
// Pass cookies for EdgeOne Pages authentication (eo_token, eo_time)
...(overrides?.headers && { headers: overrides.headers }),
})
model = edgeoneProvider.chat(modelId)
break
}
case "doubao": { case "doubao": {
const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY") const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
const serverBaseUrl = resolveBaseUrlEnv( const baseURL =
overrides, overrides?.baseUrl ||
"DOUBAO_BASE_URL", process.env.DOUBAO_BASE_URL ||
) "https://ark.cn-beijing.volces.com/api/v3"
const baseURL = resolveBaseURL( const doubaoProvider = createDeepSeek({
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
"https://ark.cn-beijing.volces.com/api/v3",
)
const lowerModelId = modelId.toLowerCase()
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
const doubaoProvider = createDeepSeek({
apiKey,
baseURL,
})
model = doubaoProvider(modelId)
} else {
const doubaoProvider = createOpenAI({
apiKey,
baseURL,
})
model = doubaoProvider.chat(modelId)
}
break
}
case "modelscope": {
const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"MODELSCOPE_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
"https://api-inference.modelscope.cn/v1",
)
const modelscopeProvider = createOpenAI({
apiKey, apiKey,
baseURL, baseURL,
}) })
model = modelscopeProvider.chat(modelId) model = doubaoProvider(modelId)
break break
} }
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, doubao`,
) )
} }
@@ -1146,58 +881,3 @@ export function supportsPromptCaching(modelId: string): boolean {
modelId.startsWith("eu.anthropic") modelId.startsWith("eu.anthropic")
) )
} }
/**
* Check if a model supports image/vision input.
* Some models silently drop image parts without error (AI SDK warning only).
*/
export function supportsImageInput(modelId: string): boolean {
const lowerModelId = modelId.toLowerCase()
// Helper to check if model has vision capability indicator
const hasVisionIndicator =
lowerModelId.includes("vision") || lowerModelId.includes("vl")
// Models that DON'T support image/vision input (unless vision variant)
// Kimi K2 models don't support images
if (lowerModelId.includes("kimi") && !hasVisionIndicator) {
return false
}
// DeepSeek text models (not vision variants)
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
return false
}
// Qwen text models (not vision variants like qwen-vl)
if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
return false
}
// Default: assume model supports images
return true
}
/**
* Get the AI model for diagram validation.
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
* Throws if the model doesn't support image input.
*/
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
if (!modelId) {
throw new Error(
"No validation model configured. Set VALIDATION_MODEL or AI_MODEL.",
)
}
if (!supportsImageInput(modelId)) {
throw new Error(
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
)
}
const { model } = getAIModel({ modelId })
return model
}

View File

@@ -1,89 +0,0 @@
// Shared helper functions for chat route
// Exported for testing
// File upload limits (must match client-side)
export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
export const MAX_FILES = 5
// Helper function to validate file parts in messages
export function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) {
return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
}
}
}
}
return { valid: true }
}
// Helper function to check if diagram is minimal/empty
export function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
})
}

View File

@@ -1,64 +0,0 @@
/**
* Types and utilities for VLM-based diagram validation.
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
*/
// Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema"
/**
* Format validation feedback for display to the AI model.
* This creates a human-readable error message that guides the AI to fix issues.
*
* @param result - The validation result from VLM
* @returns Formatted string for tool error output
*/
export function formatValidationFeedback(result: ValidationResult): string {
// If validation passed with no issues, return empty string
if (result.valid && result.issues.length === 0) {
return ""
}
const lines: string[] = []
lines.push("DIAGRAM VISUAL VALIDATION FAILED")
lines.push("")
// Group issues by severity
const criticalIssues = result.issues.filter(
(i) => i.severity === "critical",
)
const warnings = result.issues.filter((i) => i.severity === "warning")
if (criticalIssues.length > 0) {
lines.push("Critical Issues (must fix):")
for (const issue of criticalIssues) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (warnings.length > 0) {
lines.push("Warnings:")
for (const issue of warnings) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (result.suggestions.length > 0) {
lines.push("Suggestions to fix:")
for (const suggestion of result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push(
"Please regenerate the diagram with corrected layout to fix these visual issues.",
)
return lines.join("\n")
}

View File

@@ -28,8 +28,7 @@
"azure": "Azure OpenAI", "azure": "Azure OpenAI",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow", "siliconflow": "SiliconFlow"
"modelscope": "ModelScope"
}, },
"chat": { "chat": {
"placeholder": "Describe your diagram or upload a file...", "placeholder": "Describe your diagram or upload a file...",
@@ -47,13 +46,11 @@
"copyResponse": "Copy response", "copyResponse": "Copy response",
"copied": "Copied!", "copied": "Copied!",
"failedToCopy": "Failed to copy", "failedToCopy": "Failed to copy",
"failedToCopyDetail": "Failed to copy message. Please copy manually or check clipboard permissions.",
"goodResponse": "Good response", "goodResponse": "Good response",
"badResponse": "Bad response", "badResponse": "Bad response",
"clickToEdit": "Click to edit", "clickToEdit": "Click to edit",
"editMessage": "Edit message", "editMessage": "Edit message",
"saveAndSubmit": "Save & Submit", "saveAndSubmit": "Save & Submit"
"ExtractURL": "Extract from URL"
}, },
"examples": { "examples": {
"title": "Create diagrams with AI", "title": "Create diagrams with AI",
@@ -100,26 +97,14 @@
"switchTo": "Switch to", "switchTo": "Switch to",
"minimal": "Minimal", "minimal": "Minimal",
"sketch": "Sketch", "sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramStyle": "Diagram Style", "diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.", "diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut",
"sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
"diagramActions": "Diagram Actions", "diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports", "diagramActionsDescription": "Manage diagram history and exports",
"history": "History", "history": "History",
"download": "Download", "download": "Download"
"proxy": "Proxy Settings",
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
"httpProxy": "HTTP Proxy",
"httpsProxy": "HTTPS Proxy",
"applyProxy": "Apply",
"proxyApplied": "Proxy settings applied",
"diagramValidation": "Diagram Validation (Experimental)",
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
"enabled": "Enabled",
"disabled": "Disabled"
}, },
"save": { "save": {
"title": "Save Diagram", "title": "Save Diagram",
@@ -131,8 +116,7 @@
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG Image", "png": "PNG Image",
"svg": "SVG Image" "svg": "SVG Image"
}, }
"savedSuccessfully": "Saved successfully!"
}, },
"history": { "history": {
"title": "Diagram History", "title": "Diagram History",
@@ -157,7 +141,6 @@
"invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.", "invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.",
"networkError": "Network error. Please check your connection.", "networkError": "Network error. Please check your connection.",
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.", "retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
"validationFailed": "Diagram validation failed. Please try regenerating.", "validationFailed": "Diagram validation failed. Please try regenerating.",
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.", "malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
"failedToProcess": "Failed to process diagram. Please try regenerating.", "failedToProcess": "Failed to process diagram. Please try regenerating.",
@@ -166,9 +149,7 @@
"failedToRestore": "Failed to restore from localStorage", "failedToRestore": "Failed to restore from localStorage",
"failedToPersist": "Failed to persist state before unload", "failedToPersist": "Failed to persist state before unload",
"failedToExport": "Error fetching chart data", "failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image", "failedToLoadExample": "Error loading example image"
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated"
}, },
"quota": { "quota": {
"dailyLimit": "Daily Quota Reached", "dailyLimit": "Daily Quota Reached",
@@ -200,35 +181,11 @@
"chars": "chars", "chars": "chars",
"removeFile": "Remove file" "removeFile": "Remove file"
}, },
"url": {
"title": "Extract Content from URL",
"description": "Paste a URL to extract and analyze its content",
"Extracting": "Extracting...",
"extract": "Extract",
"Cancel": "Cancel",
"enterUrl": "Please enter a URL",
"invalidFormat": "Invalid URL format"
},
"reasoning": { "reasoning": {
"thinking": "Thinking...", "thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds", "thoughtFor": "Thought for {duration} seconds",
"thoughtBrief": "Thought for a few seconds" "thoughtBrief": "Thought for a few seconds"
}, },
"dev": {
"title": "Dev: XML Streaming Simulator",
"preset": "Preset:",
"selectPreset": "Select a preset...",
"clear": "Clear",
"placeholder": "Paste mxCell XML here or select a preset...",
"interval": "Interval:",
"chars": "Chars:",
"streaming": "Streaming...",
"simulate": "Simulate",
"stop": "Stop",
"testQuotaToast": "Test Quota Toast",
"simulatingMessage": "[Dev] Simulating XML streaming",
"successMessage": "Successfully displayed the diagram."
},
"about": { "about": {
"modelChange": "Model Change & Usage Limits", "modelChange": "Model Change & Usage Limits",
"walletCrying": "(Or: Why My Wallet is Crying)", "walletCrying": "(Or: Why My Wallet is Crying)",
@@ -236,40 +193,6 @@
"contactMe": "Contact Me", "contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details." "usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
}, },
"sessionHistory": {
"tooltip": "Chat History",
"newChat": "New Chat",
"empty": "No chat history yet",
"emptyHint": "Start a conversation to begin",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"earlier": "Earlier",
"deleteTitle": "Delete this chat?",
"deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.",
"recentChats": "Recent Chats",
"justNow": "Just now",
"searchPlaceholder": "Search chats...",
"noResults": "No chats found"
},
"validation": {
"title": "Validate Diagram",
"capturing": "Capturing",
"validating": "Validating",
"validatingWithAttempt": "Validating ({attempt}/{max})",
"valid": "Valid",
"validWithWarnings": "Valid with Warnings",
"issuesFound": "Issues Found",
"error": "Error",
"skipped": "Skipped",
"capturedScreenshot": "Captured Screenshot:",
"issuesFoundLabel": "Issues Found:",
"suggestions": "Suggestions:",
"passedValidation": "Diagram passed visual validation - no issues detected.",
"improvementRequested": "Improvement requested - check the new diagram below",
"improveWithSuggestions": "Improve with Suggestions",
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
},
"modelConfig": { "modelConfig": {
"title": "AI Model Configuration", "title": "AI Model Configuration",
"description": "Configure multiple AI providers and models", "description": "Configure multiple AI providers and models",
@@ -327,13 +250,10 @@
"noModelsFound": "No models found.", "noModelsFound": "No models found.",
"default": "Default", "default": "Default",
"serverDefault": "Server Default", "serverDefault": "Server Default",
"serverModels": "Server Models",
"userModels": "User Models",
"configureModels": "Configure Models...", "configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown", "onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models", "showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)", "allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated", "unvalidatedModelWarning": "This model has not been validated"
"serverDefaultModel": "Server default model"
} }
} }

View File

@@ -28,8 +28,7 @@
"azure": "Azure OpenAI", "azure": "Azure OpenAI",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow", "siliconflow": "SiliconFlow"
"modelscope": "ModelScope"
}, },
"chat": { "chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
@@ -47,13 +46,11 @@
"copyResponse": "応答をコピー", "copyResponse": "応答をコピー",
"copied": "コピーしました!", "copied": "コピーしました!",
"failedToCopy": "コピーに失敗しました", "failedToCopy": "コピーに失敗しました",
"failedToCopyDetail": "メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。",
"goodResponse": "良い応答", "goodResponse": "良い応答",
"badResponse": "悪い応答", "badResponse": "悪い応答",
"clickToEdit": "クリックして編集", "clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集", "editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信", "saveAndSubmit": "保存して送信"
"ExtractURL": "URLから抽出"
}, },
"examples": { "examples": {
"title": "AI でダイアグラムを作成", "title": "AI でダイアグラムを作成",
@@ -100,26 +97,14 @@
"switchTo": "切り替え", "switchTo": "切り替え",
"minimal": "ミニマル", "minimal": "ミニマル",
"sketch": "スケッチ", "sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル", "diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。", "diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット",
"sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
"diagramActions": "ダイアグラム操作", "diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理", "diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴", "history": "履歴",
"download": "ダウンロード", "download": "ダウンロード"
"proxy": "プロキシ設定",
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
"httpProxy": "HTTP プロキシ",
"httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました",
"diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"enabled": "有効",
"disabled": "無効"
}, },
"save": { "save": {
"title": "ダイアグラムを保存", "title": "ダイアグラムを保存",
@@ -131,8 +116,7 @@
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG 画像", "png": "PNG 画像",
"svg": "SVG 画像" "svg": "SVG 画像"
}, }
"savedSuccessfully": "保存完了!"
}, },
"history": { "history": {
"title": "ダイアグラム履歴", "title": "ダイアグラム履歴",
@@ -157,7 +141,6 @@
"invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。", "invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。",
"networkError": "ネットワークエラー。接続を確認してください。", "networkError": "ネットワークエラー。接続を確認してください。",
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。", "retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。", "validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。", "malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。", "failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
@@ -166,9 +149,7 @@
"failedToRestore": "localStorage からの復元に失敗しました", "failedToRestore": "localStorage からの復元に失敗しました",
"failedToPersist": "アンロード前の状態の永続化に失敗しました", "failedToPersist": "アンロード前の状態の永続化に失敗しました",
"failedToExport": "チャートデータの取得エラー", "failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー", "failedToLoadExample": "例の画像の読み込みエラー"
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
}, },
"quota": { "quota": {
"dailyLimit": "1日の割当量に達しました", "dailyLimit": "1日の割当量に達しました",
@@ -200,35 +181,11 @@
"chars": "文字", "chars": "文字",
"removeFile": "ファイルを削除" "removeFile": "ファイルを削除"
}, },
"url": {
"title": "URLからコンテンツを抽出",
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
"Extracting": "抽出中...",
"extract": "抽出",
"Cancel": "キャンセル",
"enterUrl": "URLを入力してください",
"invalidFormat": "無効なURL形式です"
},
"reasoning": { "reasoning": {
"thinking": "考え中...", "thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました", "thoughtFor": "{duration} 秒考えました",
"thoughtBrief": "数秒考えました" "thoughtBrief": "数秒考えました"
}, },
"dev": {
"title": "開発XMLストリーミングシミュレーター",
"preset": "プリセット:",
"selectPreset": "プリセットを選択...",
"clear": "クリア",
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
"interval": "間隔:",
"chars": "文字:",
"streaming": "ストリーミング中...",
"simulate": "シミュレート",
"stop": "停止",
"testQuotaToast": "クォータトーストをテスト",
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
"successMessage": "ダイアグラムの表示に成功しました。"
},
"about": { "about": {
"modelChange": "モデル変更と利用制限について", "modelChange": "モデル変更と利用制限について",
"walletCrying": "(別名:お財布が悲鳴を上げています)", "walletCrying": "(別名:お財布が悲鳴を上げています)",
@@ -236,40 +193,6 @@
"contactMe": "お問い合わせ", "contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。" "usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
}, },
"sessionHistory": {
"tooltip": "チャット履歴",
"newChat": "新しいチャット",
"empty": "チャット履歴はまだありません",
"emptyHint": "会話を始めてください",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "このチャットを削除しますか?",
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
"recentChats": "最近のチャット",
"justNow": "たった今",
"searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません"
},
"validation": {
"title": "ダイアグラムを検証",
"capturing": "キャプチャ中",
"validating": "検証中",
"validatingWithAttempt": "検証中 ({attempt}/{max})",
"valid": "有効",
"validWithWarnings": "有効(警告あり)",
"issuesFound": "問題が見つかりました",
"error": "エラー",
"skipped": "スキップ",
"capturedScreenshot": "キャプチャした画像:",
"issuesFoundLabel": "検出された問題:",
"suggestions": "提案:",
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
"improveWithSuggestions": "提案で改善",
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": { "modelConfig": {
"title": "AIモデル設定", "title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定", "description": "複数のAIプロバイダーとモデルを設定",
@@ -327,13 +250,10 @@
"noModelsFound": "モデルが見つかりません。", "noModelsFound": "モデルが見つかりません。",
"default": "デフォルト", "default": "デフォルト",
"serverDefault": "サーバーデフォルト", "serverDefault": "サーバーデフォルト",
"serverModels": "サーバーモデル",
"userModels": "ユーザーモデル",
"configureModels": "モデルを設定...", "configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示", "onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示", "showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)", "allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません", "unvalidatedModelWarning": "このモデルは検証されていません"
"serverDefaultModel": "サーバーデフォルトモデル"
} }
} }

View File

@@ -28,8 +28,7 @@
"azure": "Azure OpenAI", "azure": "Azure OpenAI",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"siliconflow": "SiliconFlow", "siliconflow": "SiliconFlow"
"modelscope": "ModelScope"
}, },
"chat": { "chat": {
"placeholder": "描述您的图表或上传文件...", "placeholder": "描述您的图表或上传文件...",
@@ -47,13 +46,11 @@
"copyResponse": "复制响应", "copyResponse": "复制响应",
"copied": "已复制!", "copied": "已复制!",
"failedToCopy": "复制失败", "failedToCopy": "复制失败",
"failedToCopyDetail": "复制消息失败。请手动复制或检查剪贴板权限。",
"goodResponse": "有帮助", "goodResponse": "有帮助",
"badResponse": "无帮助", "badResponse": "无帮助",
"clickToEdit": "点击编辑", "clickToEdit": "点击编辑",
"editMessage": "编辑消息", "editMessage": "编辑消息",
"saveAndSubmit": "保存并提交", "saveAndSubmit": "保存并提交"
"ExtractURL": "从 URL 提取"
}, },
"examples": { "examples": {
"title": "用 AI 创建图表", "title": "用 AI 创建图表",
@@ -100,26 +97,14 @@
"switchTo": "切换到", "switchTo": "切换到",
"minimal": "简约", "minimal": "简约",
"sketch": "草图", "sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式", "diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。", "diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键",
"sendShortcutDescription": "选择发送消息的方式。",
"enterToSend": "回车发送",
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
"diagramActions": "图表操作", "diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出", "diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录", "history": "历史记录",
"download": "下载", "download": "下载"
"proxy": "代理设置",
"proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)",
"httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理",
"applyProxy": "应用",
"proxyApplied": "代理设置已应用",
"diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已启用",
"disabled": "已禁用"
}, },
"save": { "save": {
"title": "保存图表", "title": "保存图表",
@@ -131,8 +116,7 @@
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG 图片", "png": "PNG 图片",
"svg": "SVG 图片" "svg": "SVG 图片"
}, }
"savedSuccessfully": "保存成功!"
}, },
"history": { "history": {
"title": "图表历史", "title": "图表历史",
@@ -157,7 +141,6 @@
"invalidAccessCode": "无效或缺少访问码。请在设置中配置。", "invalidAccessCode": "无效或缺少访问码。请在设置中配置。",
"networkError": "网络错误。请检查您的连接。", "networkError": "网络错误。请检查您的连接。",
"retryLimit": "已达到自动重试限制({max})。请手动重试。", "retryLimit": "已达到自动重试限制({max})。请手动重试。",
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
"validationFailed": "图表验证失败。请尝试重新生成。", "validationFailed": "图表验证失败。请尝试重新生成。",
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。", "malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
"failedToProcess": "无法处理图表。请尝试重新生成。", "failedToProcess": "无法处理图表。请尝试重新生成。",
@@ -166,9 +149,7 @@
"failedToRestore": "无法从 localStorage 恢复", "failedToRestore": "无法从 localStorage 恢复",
"failedToPersist": "卸载前无法持久化状态", "failedToPersist": "卸载前无法持久化状态",
"failedToExport": "获取图表数据时出错", "failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错", "failedToLoadExample": "加载示例图片时出错"
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
}, },
"quota": { "quota": {
"dailyLimit": "已达每日配额", "dailyLimit": "已达每日配额",
@@ -200,35 +181,11 @@
"chars": "字符", "chars": "字符",
"removeFile": "移除文件" "removeFile": "移除文件"
}, },
"url": {
"title": "从 URL 提取内容",
"description": "粘贴 URL 以提取和分析其内容",
"Extracting": "提取中...",
"extract": "提取",
"Cancel": "取消",
"enterUrl": "请输入 URL",
"invalidFormat": "URL 格式无效"
},
"reasoning": { "reasoning": {
"thinking": "思考中...", "thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒", "thoughtFor": "思考了 {duration} 秒",
"thoughtBrief": "思考了几秒钟" "thoughtBrief": "思考了几秒钟"
}, },
"dev": {
"title": "开发XML 流式模拟器",
"preset": "预设:",
"selectPreset": "选择预设...",
"clear": "清除",
"placeholder": "在此粘贴 mxCell XML 或选择预设...",
"interval": "间隔:",
"chars": "字符:",
"streaming": "流式传输中...",
"simulate": "模拟",
"stop": "停止",
"testQuotaToast": "测试配额提示",
"simulatingMessage": "[开发] 模拟 XML 流式传输",
"successMessage": "成功显示图表。"
},
"about": { "about": {
"modelChange": "模型变更与用量限制", "modelChange": "模型变更与用量限制",
"walletCrying": "(别名:我的钱包顶不住了)", "walletCrying": "(别名:我的钱包顶不住了)",
@@ -236,40 +193,6 @@
"contactMe": "联系我", "contactMe": "联系我",
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。" "usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。"
}, },
"sessionHistory": {
"tooltip": "聊天历史",
"newChat": "新对话",
"empty": "暂无聊天记录",
"emptyHint": "开始对话吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"earlier": "更早",
"deleteTitle": "删除此对话?",
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
"recentChats": "最近对话",
"justNow": "刚刚",
"searchPlaceholder": "搜索对话...",
"noResults": "未找到对话"
},
"validation": {
"title": "验证图表",
"capturing": "截图中",
"validating": "验证中",
"validatingWithAttempt": "验证中 ({attempt}/{max})",
"valid": "通过",
"validWithWarnings": "通过(有警告)",
"issuesFound": "发现问题",
"error": "错误",
"skipped": "已跳过",
"capturedScreenshot": "截图预览:",
"issuesFoundLabel": "发现的问题:",
"suggestions": "建议:",
"passedValidation": "图表通过视觉验证 - 未发现问题。",
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
"improveWithSuggestions": "根据建议改进",
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": { "modelConfig": {
"title": "AI 模型配置", "title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型", "description": "配置多个 AI 提供商和模型",
@@ -327,13 +250,10 @@
"noModelsFound": "未找到模型。", "noModelsFound": "未找到模型。",
"default": "默认", "default": "默认",
"serverDefault": "服务器默认", "serverDefault": "服务器默认",
"serverModels": "服务器模型",
"userModels": "用户模型",
"configureModels": "配置模型...", "configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型", "onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型", "showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)", "allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证", "unvalidatedModelWarning": "此模型尚未验证"
"serverDefaultModel": "服务器默认模型"
} }
} }

View File

@@ -1,151 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { z } from "zod"
import type { ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO } from "@/lib/types/model-config"
export const ProviderNameSchema: z.ZodType<ProviderName> = z
.string()
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
message: "Invalid provider name",
})
export const ServerProviderSchema = z.object({
name: z.string().min(1),
provider: ProviderNameSchema,
models: z.array(z.string().min(1)),
// Optional: custom environment variable name for API key
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
apiKeyEnv: z.string().min(1).optional(),
// Optional: custom environment variable name for base URL
baseUrlEnv: z.string().min(1).optional(),
// Optional: mark the first model in this provider as the default
default: z.boolean().optional(),
})
export const ServerModelsConfigSchema = z.object({
providers: z.array(ServerProviderSchema),
})
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
export interface FlattenedServerModel {
id: string // "server:<slugified-name>:<modelId>" - name ensures uniqueness for multiple API keys per provider
modelId: string
provider: ProviderName
providerLabel: string
isDefault: boolean
// Custom env var names for credentials (optional)
apiKeyEnv?: string
baseUrlEnv?: string
}
/**
* Convert provider name to URL-safe slug for use in model ID
* e.g., "OpenAI Production" → "openai-production"
*/
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
}
function getConfigPath(): string {
const custom = process.env.AI_MODELS_CONFIG_PATH
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "ai-models.json")
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
const envConfig = process.env.AI_MODELS_CONFIG
if (envConfig && envConfig.trim().length > 0) {
try {
const json = JSON.parse(envConfig)
return ServerModelsConfigSchema.parse(json)
} catch (err) {
console.error(
"[server-model-config] Failed to parse AI_MODELS_CONFIG:",
err,
)
return null
}
}
// Priority 2: ai-models.json file
const configPath = getConfigPath()
try {
const jsonStr = await fs.readFile(configPath, "utf8")
const json = JSON.parse(jsonStr)
return ServerModelsConfigSchema.parse(json)
} catch (err: any) {
if (err?.code === "ENOENT") {
return null
}
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
return null
}
}
export async function loadFlattenedServerModels(): Promise<
FlattenedServerModel[]
> {
const cfg = await loadRawServerModelsConfig()
if (!cfg) return []
const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined
const defaultModelId = process.env.AI_MODEL
const flattened: FlattenedServerModel[] = []
for (const p of cfg.providers) {
const providerLabel =
p.name || PROVIDER_INFO[p.provider]?.label || p.provider
// Use slugified name for unique ID (supports multiple API keys per provider)
const nameSlug = slugify(p.name)
for (const modelId of p.models) {
const id = `server:${nameSlug}:${modelId}`
// Default model priority:
// 1. From ai-models.json: first model of provider with default: true
// 2. From env vars: AI_MODEL matches (legacy behavior)
const isDefault =
(p.default === true && modelId === p.models[0]) ||
(!!defaultModelId &&
modelId === defaultModelId &&
(!defaultProvider || defaultProvider === p.provider))
flattened.push({
id,
modelId,
provider: p.provider,
providerLabel,
isDefault,
apiKeyEnv: p.apiKeyEnv,
baseUrlEnv: p.baseUrlEnv,
})
}
}
return flattened
}
/**
* Find a server model by its ID (format: "server:<slugified-name>:<modelId>")
* Returns the model config including apiKeyEnv/baseUrlEnv if configured
*/
export async function findServerModelById(
modelId: string,
): Promise<FlattenedServerModel | null> {
if (!modelId.startsWith("server:")) return null
const models = await loadFlattenedServerModels()
return models.find((m) => m.id === modelId) || null
}

View File

@@ -1,338 +0,0 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
// Constants
const DB_NAME = "next-ai-drawio"
const DB_VERSION = 1
const STORE_NAME = "sessions"
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
const MAX_SESSIONS = 50
// Types
export interface ChatSession {
id: string
title: string
createdAt: number
updatedAt: number
messages: StoredMessage[]
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string // Small PNG preview of the diagram
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
}
export interface StoredMessage {
id: string
role: "user" | "assistant" | "system"
parts: Array<{ type: string; [key: string]: unknown }>
}
export interface SessionMetadata {
id: string
title: string
createdAt: number
updatedAt: number
messageCount: number
hasDiagram: boolean
thumbnailDataUrl?: string
}
interface ChatSessionDB extends DBSchema {
sessions: {
key: string
value: ChatSession
indexes: { "by-updated": number }
}
}
// Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) {
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: "id",
})
store.createIndex("by-updated", "updatedAt")
}
// Future migrations: if (oldVersion < 2) { ... }
},
})
}
return dbPromise
}
// Check if IndexedDB is available
export function isIndexedDBAvailable(): boolean {
if (typeof window === "undefined") return false
try {
return "indexedDB" in window && window.indexedDB !== null
} catch {
return false
}
}
// CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return []
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
} catch (error) {
console.error("Failed to get session metadata:", error)
return []
}
}
export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
} catch (error) {
console.error("Failed to get session:", error)
return null
}
}
export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (error) {
// Handle quota exceeded
if (
error instanceof DOMException &&
error.name === "QuotaExceededError"
) {
console.warn("Storage quota exceeded, deleting oldest session...")
await deleteOldestSession()
// Retry once
try {
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (retryError) {
console.error(
"Failed to save session after cleanup:",
retryError,
)
return false
}
} else {
console.error("Failed to save session:", error)
return false
}
}
}
export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
await db.delete(STORE_NAME, id)
} catch (error) {
console.error("Failed to delete session:", error)
}
}
export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0
try {
const db = await getDB()
return await db.count(STORE_NAME)
} catch (error) {
console.error("Failed to get session count:", error)
return 0
}
}
export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
} catch (error) {
console.error("Failed to delete oldest session:", error)
}
}
// Enforce max sessions limit
export async function enforceSessionLimit(): Promise<void> {
const count = await getSessionCount()
if (count > MAX_SESSIONS) {
const toDelete = count - MAX_SESSIONS
for (let i = 0; i < toDelete; i++) {
await deleteOldestSession()
}
}
}
// Helper: Create a new empty session
export function createEmptySession(): ChatSession {
return {
id: nanoid(),
title: "New Chat",
createdAt: Date.now(),
updatedAt: Date.now(),
messages: [],
xmlSnapshots: [],
diagramXml: "",
}
}
// Helper: Extract title from first user message (truncated to reasonable length)
const MAX_TITLE_LENGTH = 100
export function extractTitle(messages: StoredMessage[]): string {
const firstUserMessage = messages.find((m) => m.role === "user")
if (!firstUserMessage) return "New Chat"
const textPart = firstUserMessage.parts.find((p) => p.type === "text")
if (!textPart || typeof textPart.text !== "string") return "New Chat"
const text = textPart.text.trim()
if (!text) return "New Chat"
// Truncate long titles
if (text.length > MAX_TITLE_LENGTH) {
return text.slice(0, MAX_TITLE_LENGTH).trim() + "..."
}
return text
}
// Helper: Sanitize UIMessage to StoredMessage
export function sanitizeMessage(message: unknown): StoredMessage | null {
if (!message || typeof message !== "object") return null
const msg = message as Record<string, unknown>
if (!msg.id || !msg.role) return null
const role = msg.role as string
if (!["user", "assistant", "system"].includes(role)) return null
// Extract parts, removing streaming state artifacts
let parts: Array<{ type: string; [key: string]: unknown }> = []
if (Array.isArray(msg.parts)) {
parts = msg.parts.map((part: unknown) => {
if (!part || typeof part !== "object") return { type: "unknown" }
const p = part as Record<string, unknown>
// Remove streaming-related fields
const { isStreaming, streamingState, ...cleanPart } = p
return cleanPart as { type: string; [key: string]: unknown }
})
}
return {
id: msg.id as string,
role: role as "user" | "assistant" | "system",
parts,
}
}
export function sanitizeMessages(messages: unknown[]): StoredMessage[] {
return messages
.map(sanitizeMessage)
.filter((m): m is StoredMessage => m !== null)
}
// Migration from localStorage
export async function migrateFromLocalStorage(): Promise<string | null> {
if (typeof window === "undefined") return null
if (!isIndexedDBAvailable()) return null
// Check if already migrated
if (localStorage.getItem(MIGRATION_FLAG)) return null
try {
const savedMessages = localStorage.getItem("next-ai-draw-io-messages")
const savedSnapshots = localStorage.getItem(
"next-ai-draw-io-xml-snapshots",
)
const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml")
let newSessionId: string | null = null
let migrationSucceeded = false
if (savedMessages) {
const messages = JSON.parse(savedMessages)
if (Array.isArray(messages) && messages.length > 0) {
const sanitized = sanitizeMessages(messages)
const session: ChatSession = {
id: nanoid(),
title: extractTitle(sanitized),
createdAt: Date.now(),
updatedAt: Date.now(),
messages: sanitized,
xmlSnapshots: savedSnapshots
? JSON.parse(savedSnapshots)
: [],
diagramXml: savedXml || "",
}
const saved = await saveSession(session)
if (saved) {
// Verify the session was actually written
const verified = await getSession(session.id)
if (verified) {
newSessionId = session.id
migrationSucceeded = true
}
}
} else {
// Empty array or invalid data - nothing to migrate, mark as success
migrationSucceeded = true
}
} else {
// No data to migrate - mark as success
migrationSucceeded = true
}
// Only clean up old data if migration succeeded
if (migrationSucceeded) {
localStorage.setItem(MIGRATION_FLAG, "true")
localStorage.removeItem("next-ai-draw-io-messages")
localStorage.removeItem("next-ai-draw-io-xml-snapshots")
localStorage.removeItem("next-ai-draw-io-diagram-xml")
} else {
console.warn(
"Migration to IndexedDB failed - keeping localStorage data for retry",
)
}
return newSessionId
} catch (error) {
console.error("Migration failed:", error)
// Don't mark as migrated - allow retry on next load
return null
}
}

View File

@@ -1,63 +0,0 @@
/**
* SSRF (Server-Side Request Forgery) protection utilities
*/
/**
* Check if URL points to private/internal network
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
export function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback)
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
return true // Invalid URL - block it
}
}
/**
* Whether private URLs are allowed (defaults to true)
* Set ALLOW_PRIVATE_URLS=false to block private URLs
*/
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"

View File

@@ -1,7 +1,13 @@
// Centralized localStorage keys for quota tracking and settings // Centralized localStorage keys
// Chat data is now stored in IndexedDB via session-storage.ts // Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
export const STORAGE_KEYS = { export const STORAGE_KEYS = {
// Chat data
messages: "next-ai-draw-io-messages",
xmlSnapshots: "next-ai-draw-io-xml-snapshots",
diagramXml: "next-ai-draw-io-diagram-xml",
sessionId: "next-ai-draw-io-session-id",
// Quota tracking // Quota tracking
requestCount: "next-ai-draw-io-request-count", requestCount: "next-ai-draw-io-request-count",
requestDate: "next-ai-draw-io-request-date", requestDate: "next-ai-draw-io-request-date",
@@ -12,6 +18,7 @@ export const STORAGE_KEYS = {
// Settings // Settings
accessCode: "next-ai-draw-io-access-code", accessCode: "next-ai-draw-io-access-code",
closeProtection: "next-ai-draw-io-close-protection",
accessCodeRequired: "next-ai-draw-io-access-code-required", accessCodeRequired: "next-ai-draw-io-access-code-required",
aiProvider: "next-ai-draw-io-ai-provider", aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url", aiBaseUrl: "next-ai-draw-io-ai-base-url",
@@ -21,10 +28,4 @@ export const STORAGE_KEYS = {
// Multi-model configuration // Multi-model configuration
modelConfigs: "next-ai-draw-io-model-configs", modelConfigs: "next-ai-draw-io-model-configs",
selectedModelId: "next-ai-draw-io-selected-model-id", selectedModelId: "next-ai-draw-io-selected-model-id",
// Chat input preferences
sendShortcut: "next-ai-draw-io-send-shortcut",
// Diagram validation
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
} as const } as const

View File

@@ -276,7 +276,7 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
**Operations:** **Operations:**
- **update**: Replace an existing cell. Provide cell_id and new_xml. - **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. - **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:** **Input Format:**
\`\`\`json \`\`\`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>"}]} {"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 \`\`\`json
{"operations": [{"operation": "delete", "cell_id": "2"}]} {"operations": [{"operation": "delete", "cell_id": "5"}]}
\`\`\` \`\`\`
**Error Recovery:** **Error Recovery:**

39
lib/token-counter.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Token counting utilities using js-tiktoken
*
* Uses cl100k_base encoding (GPT-4) which is close to Claude's tokenization.
* This is a pure JavaScript implementation, no WASM required.
*/
import { encodingForModel } from "js-tiktoken"
import { DEFAULT_SYSTEM_PROMPT, EXTENDED_SYSTEM_PROMPT } from "./system-prompts"
const encoder = encodingForModel("gpt-4o")
/**
* Count the number of tokens in a text string
* @param text - The text to count tokens for
* @returns The number of tokens
*/
export function countTextTokens(text: string): number {
return encoder.encode(text).length
}
/**
* Get token counts for the system prompts
* Useful for debugging and optimizing prompt sizes
* @returns Object with token counts for default and extended prompts
*/
export function getSystemPromptTokenCounts(): {
default: number
extended: number
additions: number
} {
const defaultTokens = countTextTokens(DEFAULT_SYSTEM_PROMPT)
const extendedTokens = countTextTokens(EXTENDED_SYSTEM_PROMPT)
return {
default: defaultTokens,
extended: extendedTokens,
additions: extendedTokens - defaultTokens,
}
}

View File

@@ -4,18 +4,14 @@ export type ProviderName =
| "openai" | "openai"
| "anthropic" | "anthropic"
| "google" | "google"
| "vertexai"
| "azure" | "azure"
| "bedrock" | "bedrock"
| "ollama"
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow" | "siliconflow"
| "sglang" | "sglang"
| "gateway" | "gateway"
| "edgeone"
| "doubao" | "doubao"
| "modelscope"
// Individual model configuration // Individual model configuration
export interface ModelConfig { export interface ModelConfig {
@@ -37,9 +33,6 @@ export interface ProviderConfig {
awsSecretAccessKey?: string awsSecretAccessKey?: string
awsRegion?: string awsRegion?: string
awsSessionToken?: string // Optional, for temporary credentials awsSessionToken?: string // Optional, for temporary credentials
// Vertex AI specific fields
vertexApiKey?: string // Express Mode API key
models: ModelConfig[] models: ModelConfig[]
validated?: boolean // Has API key been validated validated?: boolean // Has API key been validated
} }
@@ -54,7 +47,7 @@ export interface MultiModelConfig {
// Flattened model for dropdown display // Flattened model for dropdown display
export interface FlattenedModel { export interface FlattenedModel {
id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId") id: string // Model config UUID
modelId: string // Actual model ID modelId: string // Actual model ID
provider: ProviderName provider: ProviderName
providerLabel: string // Provider display name providerLabel: string // Provider display name
@@ -65,17 +58,7 @@ export interface FlattenedModel {
awsSecretAccessKey?: string awsSecretAccessKey?: string
awsRegion?: string awsRegion?: string
awsSessionToken?: string awsSessionToken?: string
// Vertex AI specific fields
vertexApiKey?: string // Express Mode API key
validated?: boolean // Has this model been validated validated?: boolean // Has this model been validated
// Source of this model config: user-defined (client) or server-defined
source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
} }
// Provider metadata // Provider metadata
@@ -89,37 +72,27 @@ export const PROVIDER_INFO: Record<
defaultBaseUrl: "https://api.anthropic.com/v1", defaultBaseUrl: "https://api.anthropic.com/v1",
}, },
google: { label: "Google" }, google: { label: "Google" },
vertexai: { label: "Google Vertex AI" },
azure: { label: "Azure OpenAI" }, azure: { label: "Azure OpenAI" },
bedrock: { label: "Amazon Bedrock" }, bedrock: { label: "Amazon Bedrock" },
ollama: {
label: "Ollama",
defaultBaseUrl: "http://localhost:11434",
},
openrouter: { label: "OpenRouter" }, openrouter: { label: "OpenRouter" },
deepseek: { label: "DeepSeek" }, deepseek: { label: "DeepSeek" },
siliconflow: { siliconflow: {
label: "SiliconFlow", label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.cn/v1", defaultBaseUrl: "https://api.siliconflow.com/v1",
}, },
sglang: { sglang: {
label: "SGLang", label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1", defaultBaseUrl: "http://127.0.0.1:8000/v1",
}, },
gateway: { label: "AI Gateway" }, gateway: { label: "AI Gateway" },
edgeone: { label: "EdgeOne Pages" },
doubao: { doubao: {
label: "Doubao (ByteDance)", label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
}, },
modelscope: {
label: "ModelScope",
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
},
} }
// Suggested models per provider for quick add // Suggested models per provider for quick add
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = { export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
openai: [ openai: [
"gpt-5.2-pro", "gpt-5.2-pro",
"gpt-5.2-chat-latest", "gpt-5.2-chat-latest",
@@ -172,17 +145,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
// Legacy // Legacy
"gemini-pro", "gemini-pro",
], ],
vertexai: [
// Gemini 2.5 series
"gemini-2.5-pro",
"gemini-2.5-flash",
// Gemini 2.0 series
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
// Gemini 1.5 series
"gemini-1.5-pro",
"gemini-1.5-flash",
],
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"], azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
bedrock: [ bedrock: [
// Anthropic Claude // Anthropic Claude
@@ -257,7 +219,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"anthropic/claude-3-5-sonnet", "anthropic/claude-3-5-sonnet",
"google/gemini-2.0-flash", "google/gemini-2.0-flash",
], ],
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
doubao: [ doubao: [
// ByteDance Doubao models // ByteDance Doubao models
"doubao-1.5-thinking-pro-250415", "doubao-1.5-thinking-pro-250415",
@@ -267,17 +228,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"doubao-pro-32k-241215", "doubao-pro-32k-241215",
"doubao-pro-256k-241215", "doubao-pro-256k-241215",
], ],
modelscope: [
// Qwen
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-32B",
// DeepSeek
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-V3.2",
],
} }
// Helper to generate UUID // Helper to generate UUID
@@ -314,7 +264,7 @@ export function createModelConfig(modelId: string): ModelConfig {
} }
} }
// Get all models as flattened list for dropdown (user-defined only) // Get all models as flattened list for dropdown
export function flattenModels(config: MultiModelConfig): FlattenedModel[] { export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
const models: FlattenedModel[] = [] const models: FlattenedModel[] = []
@@ -336,12 +286,7 @@ export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
awsSecretAccessKey: provider.awsSecretAccessKey, awsSecretAccessKey: provider.awsSecretAccessKey,
awsRegion: provider.awsRegion, awsRegion: provider.awsRegion,
awsSessionToken: provider.awsSessionToken, awsSessionToken: provider.awsSessionToken,
// Vertex AI fields
vertexApiKey: provider.vertexApiKey,
validated: model.validated, validated: model.validated,
source: "user",
isDefault: false,
}) })
} }
} }

View File

@@ -1,49 +0,0 @@
import { z } from "zod"
export interface UrlData {
url: string
title: string
content: string
charCount: number
isExtracting: boolean
}
const UrlResponseSchema = z.object({
title: z.string().default("Untitled"),
content: z.string(),
charCount: z.number().int().nonnegative(),
})
export async function extractUrlContent(url: string): Promise<UrlData> {
const response = await fetch("/api/parse-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
})
// Try to parse JSON once
const raw = await response
.json()
.catch(() => ({ error: "Unexpected non-JSON response" }))
if (!response.ok) {
const message =
typeof raw === "object" && raw && "error" in raw
? String((raw as any).error)
: "Failed to extract URL content"
throw new Error(message)
}
const parsed = UrlResponseSchema.safeParse(raw)
if (!parsed.success) {
throw new Error("Malformed response from URL extraction API")
}
return {
url,
title: parsed.data.title,
content: parsed.data.content,
charCount: parsed.data.charCount,
isExtracting: false,
}
}

View File

@@ -1,33 +1,11 @@
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx"
import * as pako from "pako" import * as pako from "pako"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import type { DiagramOperation } from "@/components/chat/types"
export type { DiagramOperation }
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
// ============================================================================
// Diagram Constants
// ============================================================================
/**
* Minimum length for a "real" diagram XML (not just empty template).
* Empty mxfile templates are ~147-300 chars; real diagrams are larger.
*/
export const MIN_REAL_DIAGRAM_LENGTH = 300
/**
* Check if diagram XML represents a real diagram (not just empty template).
* @param xml - The diagram XML string to check
* @returns true if the XML is a real diagram with content
*/
export function isRealDiagram(xml: string | undefined | null): boolean {
return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH
}
// ============================================================================ // ============================================================================
// XML Validation/Fix Constants // XML Validation/Fix Constants
// ============================================================================ // ============================================================================
@@ -476,6 +454,12 @@ export function replaceNodes(currentXML: string, nodes: string): string {
// ID-based Diagram Operations // ID-based Diagram Operations
// ============================================================================ // ============================================================================
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface OperationError { export interface OperationError {
type: "update" | "add" | "delete" type: "update" | "add" | "delete"
cellId: string cellId: string
@@ -649,77 +633,32 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.operation === "delete") {
// Protect root cells from deletion const existingCell = cellMap.get(op.cell_id)
if (op.cell_id === "0" || op.cell_id === "1") { if (!existingCell) {
errors.push({ errors.push({
type: "delete", type: "delete",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cannot delete root cell "${op.cell_id}"`, message: `Cell with id="${op.cell_id}" not found`,
}) })
continue continue
} }
const existingCell = cellMap.get(op.cell_id) // Check for edges referencing this cell (warning only, still delete)
if (!existingCell) { const referencingEdges = root.querySelectorAll(
// Cell not found - might have been cascade-deleted by a previous operation `mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
// Skip silently instead of erroring (AI may redundantly list children/edges) )
continue if (referencingEdges.length > 0) {
} const edgeIds = Array.from(referencingEdges)
.map((e) => e.getAttribute("id"))
// Cascade delete: collect all cells to delete (children + edges + self) .join(", ")
const cellsToDelete = new Set<string>() console.warn(
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
// 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(", ")}`,
) )
} }
// Delete all collected cells // Remove the node
for (const cellId of cellsToDelete) { existingCell.parentNode?.removeChild(existingCell)
const cell = cellMap.get(cellId) cellMap.delete(op.cell_id)
if (cell) {
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
}
} }
} }

View File

@@ -1,22 +0,0 @@
/**
* VLM system prompt for diagram validation.
* Note: Response parsing is now handled via AI SDK's structured outputs (generateObject with schema).
*/
export const VALIDATION_SYSTEM_PROMPT = `You are a diagram quality validator. Analyze the rendered diagram image for visual issues.
Evaluate the diagram for the following issues:
1. **Overlapping elements** (critical): Shapes covering each other inappropriately, making content unreadable
2. **Edge routing issues** (critical): Lines/arrows crossing through shapes that are not their source or target
3. **Text readability** (warning): Labels cut off, overlapping, or too small to read
4. **Layout quality** (warning): Poor spacing, misalignment, or cramped elements
5. **Rendering errors** (critical): Incomplete, corrupted, or missing visual elements
Rules:
- Set "valid" to true ONLY if there are no critical issues
- Be specific about which elements have problems (e.g., "The 'Login' box overlaps with 'Register' box")
- Provide actionable suggestions (e.g., "Move the Login box 50 pixels to the left")
- Minor cosmetic issues (slight misalignment, non-uniform spacing) should be warnings, not critical
- Empty diagrams or diagrams with only 1-2 elements should pass unless they have obvious errors
- If the diagram looks generally acceptable, set valid to true even with minor warnings`

View File

@@ -1,38 +0,0 @@
/**
* Shared validation schema for VLM-based diagram validation.
* This file can be safely imported on both client and server.
*/
import { z } from "zod"
// Schema for structured validation output
export const ValidationResultSchema = z.object({
valid: z.boolean().describe("True if there are no critical issues"),
issues: z
.array(
z.object({
type: z
.enum([
"overlap",
"edge_routing",
"text",
"layout",
"rendering",
])
.describe("Type of visual issue"),
severity: z
.enum(["critical", "warning"])
.describe("Severity level"),
description: z
.string()
.describe("Clear description of the issue"),
}),
)
.describe("List of visual issues found"),
suggestions: z
.array(z.string())
.describe("Actionable suggestions to fix issues"),
})
export type ValidationResult = z.infer<typeof ValidationResultSchema>
export type ValidationIssue = ValidationResult["issues"][number]

8835
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", "name": "next-ai-draw-io",
"version": "0.4.11", "version": "0.4.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -24,11 +24,8 @@
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml", "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: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:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
"dist:win:build": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win --publish never",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux", "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:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
"test": "vitest",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/amazon-bedrock": "^4.0.1",
@@ -37,21 +34,18 @@
"@ai-sdk/deepseek": "^2.0.0", "@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0", "@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0", "@ai-sdk/google": "^3.0.0",
"@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0", "@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1", "@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",
"@formatjs/intl-localematcher": "^0.7.2", "@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9", "@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4", "@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9", "@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.14.8", "@opennextjs/cloudflare": "1.14.7",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -70,14 +64,14 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"idb": "^8.0.3", "js-tiktoken": "^1.0.21",
"jsdom": "^27.0.0",
"jsonrepair": "^3.13.1", "jsonrepair": "^3.13.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"nanoid": "^5.0.0",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
"ollama-ai-provider-v2": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.1.2", "react": "^19.1.2",
@@ -91,15 +85,9 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.0",
"unpdf": "^1.4.0", "unpdf": "^1.4.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
"lightningcss": "^1.30.2",
"lightningcss-linux-x64-gnu": "^1.30.2"
},
"lint-staged": { "lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [ "*.{js,ts,jsx,tsx,json,css}": [
"biome check --write --no-errors-on-unmatched", "biome check --write --no-errors-on-unmatched",
@@ -109,20 +97,13 @@
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.10",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/negotiator": "^0.6.4", "@types/negotiator": "^0.6.4",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^39.2.7", "electron": "^39.2.7",
@@ -131,15 +112,12 @@
"eslint": "9.39.2", "eslint": "9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"shx": "^0.4.0", "shx": "^0.4.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16",
"wait-on": "^9.0.3", "wait-on": "^9.0.3",
"wrangler": "4.58.0" "wrangler": "4.54.0"
}, },
"overrides": { "overrides": {
"@openrouter/ai-sdk-provider": { "@openrouter/ai-sdk-provider": {

View File

@@ -1,11 +0,0 @@
{
"name": "next-ai-drawio",
"version": "1.0.0",
"description": "AI-powered Draw.io diagram generation with real-time browser preview. Create flowcharts, architecture diagrams, and more through natural language.",
"author": {
"name": "DayuanJiang"
},
"repository": "https://github.com/DayuanJiang/next-ai-draw-io",
"homepage": "https://next-ai-drawio.jiang.jp",
"license": "Apache-2.0"
}

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}

View File

@@ -1,107 +0,0 @@
# Next AI Draw.io - Claude Code Plugin
AI-powered Draw.io diagram generation with real-time browser preview for Claude Code.
## Installation
### From Plugin Directory (Coming Soon)
Once approved, install via:
```
/plugin install next-ai-drawio
```
### Manual Installation
```bash
claude --plugin-dir /path/to/packages/claude-plugin
```
Or add the MCP server directly:
```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
```
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
- **Version History**: Restore previous diagram versions with visual thumbnails
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, no external dependencies required
## Use Case Examples
### 1. Create Architecture Diagrams
```
Generate an AWS architecture diagram with Lambda, API Gateway, DynamoDB,
and S3 for a serverless REST API
```
### 2. Flowchart Generation
```
Create a flowchart showing the CI/CD pipeline: code commit -> build ->
test -> staging deploy -> production deploy with approval gates
```
### 3. System Design Documentation
```
Design a microservices e-commerce system with user service, product catalog,
shopping cart, order processing, and payment gateway
```
### 4. Cloud Architecture (AWS/GCP/Azure)
```
Generate a GCP architecture diagram with Cloud Run, Cloud SQL, and
Cloud Storage for a web application
```
### 5. Sequence Diagrams
```
Create a sequence diagram showing OAuth 2.0 authorization code flow
between user, client app, auth server, and resource server
```
## Available Tools
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `create_new_diagram` | Create a new diagram from XML |
| `edit_diagram` | Edit diagram by ID-based operations |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |
## How It Works
```
Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
```
1. Ask Claude to create a diagram
2. Claude calls `start_session` to open a browser window
3. Claude generates diagram XML and sends it to the browser
4. You see the diagram update in real-time!
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
## Links
- [Homepage](https://next-ai-drawio.jiang.jp)
- [GitHub Repository](https://github.com/DayuanJiang/next-ai-draw-io)
- [MCP Server Documentation](https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server)
## License
Apache-2.0

View File

@@ -64,24 +64,6 @@ Add to Cursor MCP config (`~/.cursor/mcp.json`):
} }
``` ```
### Cline (VS Code Extension)
1. Click the **MCP Servers** icon in Cline's top menu bar
2. Select the **Configure** tab
3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`
4. Add the drawio server:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI ### Claude Code CLI
```bash ```bash
@@ -108,14 +90,14 @@ Use the standard MCP configuration with:
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions - **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files - **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
## Available Tools ## Available Tools
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `start_session` | Opens browser with real-time diagram preview | | `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) | | `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML | | `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file | | `export_diagram` | Save diagram to a `.drawio` file |
@@ -148,33 +130,6 @@ Use the standard MCP configuration with:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server | | `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
### Private Deployment (Self-hosted draw.io)
For security-sensitive environments that require private deployment of draw.io:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"],
"env": {
"DRAWIO_BASE_URL": "https://drawio.your-company.com"
}
}
}
}
```
You can deploy your own draw.io instance using the official Docker image:
```bash
docker run -d -p 8080:8080 jgraph/drawio
```
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
## Troubleshooting ## Troubleshooting

View File

@@ -1,18 +1,18 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.12", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.12", "version": "0.1.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^4.0.0" "zod": "^3.24.0"
}, },
"bin": { "bin": {
"next-ai-drawio-mcp": "dist/index.js" "next-ai-drawio-mcp": "dist/index.js"
@@ -481,9 +481,9 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.2", "version": "1.25.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
@@ -520,9 +520,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.6", "version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==", "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2051,9 +2051,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.5", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.13", "version": "0.1.6",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -21,16 +21,16 @@
"claude", "claude",
"model-context-protocol" "model-context-protocol"
], ],
"author": "DayuanJiang", "author": "Biki-dev",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/DayuanJiang/next-ai-draw-io", "url": "https://github.com/Biki-dev/next-ai-draw-io",
"directory": "packages/mcp-server" "directory": "packages/mcp-server"
}, },
"homepage": "https://next-ai-drawio.jiang.jp", "homepage": "https://next-ai-drawio.jiang.jp",
"bugs": { "bugs": {
"url": "https://github.com/DayuanJiang/next-ai-draw-io/issues" "url": "https://github.com/Biki-dev/next-ai-draw-io/issues"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -39,7 +39,7 @@
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^4.0.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

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