mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Compare commits
97 Commits
feat/mcp-h
...
3df60ad81d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df60ad81d | ||
|
|
7bdc1fe612 | ||
|
|
03ac9a79de | ||
|
|
f97934d6e0 | ||
|
|
73a36cf9de | ||
|
|
69f9df1792 | ||
|
|
aaa2938dac | ||
|
|
24afa0b58a | ||
|
|
1d19127855 | ||
|
|
ca21a5bb27 | ||
|
|
ad80e9c6f5 | ||
|
|
1ab8d260a2 | ||
|
|
2d62496f9f | ||
|
|
c2aa7f49be | ||
|
|
30b30550d9 | ||
|
|
49b086cef3 | ||
|
|
27f26d8b26 | ||
|
|
6d1e12bb39 | ||
|
|
226c336671 | ||
|
|
1527883360 | ||
|
|
641a715d44 | ||
|
|
41184969fa | ||
|
|
c92975f831 | ||
|
|
9ac99a4690 | ||
|
|
6d84dade56 | ||
|
|
43f3fbb5ee | ||
|
|
1915c817c3 | ||
|
|
eeab1ba75d | ||
|
|
1f4eb02b0b | ||
|
|
5d60ca74f7 | ||
|
|
9fa1dd075b | ||
|
|
743b317387 | ||
|
|
5ed23784e7 | ||
|
|
3a22e11651 | ||
|
|
eb89b9c052 | ||
|
|
9c1117e8b0 | ||
|
|
39bf3d6a49 | ||
|
|
ecd689162f | ||
|
|
7a03aec9be | ||
|
|
95541dd284 | ||
|
|
49af6676b5 | ||
|
|
18ab1bffa0 | ||
|
|
571ba3c6b0 | ||
|
|
467561df47 | ||
|
|
e67ab37383 | ||
|
|
31644dbcd8 | ||
|
|
067d309927 | ||
|
|
d1d0de3dea | ||
|
|
8c736cee0d | ||
|
|
c5a04c9e50 | ||
|
|
44c453403f | ||
|
|
9727aa5b39 | ||
|
|
51858dbf5d | ||
|
|
3047d19238 | ||
|
|
ed069afdea | ||
|
|
d2e5afb298 | ||
|
|
d3fb2314ee | ||
|
|
447bb30745 | ||
|
|
63398d9f34 | ||
|
|
82f4deb23a | ||
|
|
1fab261cd0 | ||
|
|
7a4a04c263 | ||
|
|
0d2e7a7ad6 | ||
|
|
3218ccc909 | ||
|
|
d3be96de79 | ||
|
|
b2dfd5b890 | ||
|
|
72d647de7a | ||
|
|
c6b0e5ac62 | ||
|
|
7de192e1fa | ||
|
|
97ae9395cd | ||
|
|
5ec05eb100 | ||
|
|
9aec7eda79 | ||
|
|
a0fbc0ad33 | ||
|
|
0385c45a10 | ||
|
|
5262b7bfb2 | ||
|
|
8cb7494d16 | ||
|
|
98625dd72a | ||
|
|
b5734aa5e1 | ||
|
|
87cdc53665 | ||
|
|
b4fc259de8 | ||
|
|
28f9a81e7b | ||
|
|
0f67884ead | ||
|
|
3521495ead | ||
|
|
6446454cd7 | ||
|
|
84959637db | ||
|
|
9e9ea10beb | ||
|
|
deae5c2c38 | ||
|
|
6e2d98e52d | ||
|
|
85cb441e26 | ||
|
|
b088a0653e | ||
|
|
b25b944600 | ||
|
|
4f07a5fafc | ||
|
|
fc5eca877a | ||
|
|
f58274bb84 | ||
|
|
e03b65328d | ||
|
|
14c1aa8e1c | ||
|
|
9e651a51e6 |
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: Enhancement
|
||||
about: Suggest an improvement to existing functionality
|
||||
title: '[Enhancement] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
|
||||
|
||||
## Current Behavior
|
||||
Describe how the feature currently works.
|
||||
|
||||
## Proposed Enhancement
|
||||
How you'd like this to be improved.
|
||||
|
||||
## Motivation
|
||||
Why this enhancement would be beneficial.
|
||||
|
||||
## Screenshots / Mockups
|
||||
If applicable, add screenshots or mockups to illustrate the proposed changes.
|
||||
|
||||
## Additional Context
|
||||
Any other information about the enhancement request.
|
||||
40
.github/renovate.json
vendored
Normal file
40
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["after 10am on saturday"],
|
||||
"timezone": "Asia/Tokyo",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "minor and patch dependencies",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@ai-sdk/*"],
|
||||
"groupName": "AI SDK packages"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@radix-ui/*"],
|
||||
"groupName": "Radix UI packages"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["electron", "electron-builder"],
|
||||
"groupName": "Electron packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
27
.github/workflows/auto-format.yml
vendored
27
.github/workflows/auto-format.yml
vendored
@@ -12,21 +12,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install Biome
|
||||
run: npm install --save-dev @biomejs/biome
|
||||
node-version: '24'
|
||||
|
||||
- name: Run Biome format
|
||||
run: npx @biomejs/biome check --write --no-errors-on-unmatched .
|
||||
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
@@ -37,11 +34,21 @@ jobs:
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# For fork PRs, just fail if formatting is needed (can't push to forks)
|
||||
- name: Fail if fork PR needs formatting
|
||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: |
|
||||
echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes."
|
||||
git diff --stat
|
||||
exit 1
|
||||
|
||||
# For same-repo PRs, commit and push the changes
|
||||
- name: Commit changes
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||
git add .
|
||||
git commit -m "style: auto-format with Biome"
|
||||
git push
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
|
||||
44
.github/workflows/ci.yml
vendored
Normal file
44
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Security audit
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -63,11 +63,13 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
|
||||
|
||||
# Push to AWS ECR for App Runner auto-deploy
|
||||
- name: Configure AWS credentials
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
46
.github/workflows/electron-release.yml
vendored
Normal file
46
.github/workflows/electron-release.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Electron Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag (e.g., v0.4.5)"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and publish Electron app
|
||||
run: npm run dist:${{ matrix.platform }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -50,3 +50,19 @@ push-via-ec2.sh
|
||||
.wrangler/
|
||||
.env*.local
|
||||
|
||||
# Electron
|
||||
/dist-electron/
|
||||
/release/
|
||||
/electron-standalone/
|
||||
*.dmg
|
||||
*.exe
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.rpm
|
||||
*.snap
|
||||
|
||||
CLAUDE.md
|
||||
.spec-workflow
|
||||
|
||||
# edgeone
|
||||
.edgeone
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,7 +1,7 @@
|
||||
# Multi-stage Dockerfile for Next.js
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:24-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
@@ -9,10 +9,10 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
|
||||
# Stage 2: Build application
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy node_modules from deps stage
|
||||
@@ -26,11 +26,15 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
||||
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
||||
|
||||
# Build-time argument to show About link and Notice icon
|
||||
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||
|
||||
# Build Next.js application (standalone mode)
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production runtime
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
127
README.md
127
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
||||
|
||||
English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
|
||||
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
@@ -19,6 +19,7 @@ English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
|
||||
|
||||
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||
|
||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
@@ -26,19 +27,23 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
|
||||
|
||||
## Table of Contents
|
||||
- [Next AI Draw.io ](#next-ai-drawio-)
|
||||
- [Next AI Draw.io](#next-ai-drawio)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Examples](#examples)
|
||||
- [Features](#features)
|
||||
- [MCP Server (Preview)](#mcp-server-preview)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Try it Online](#try-it-online)
|
||||
- [Run with Docker (Recommended)](#run-with-docker-recommended)
|
||||
- [Desktop Application](#desktop-application)
|
||||
- [Run with Docker](#run-with-docker)
|
||||
- [Installation](#installation)
|
||||
- [Deployment](#deployment)
|
||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
|
||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||
- [Multi-Provider Support](#multi-provider-support)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Support \& Contact](#support--contact)
|
||||
- [Star History](#star-history)
|
||||
|
||||
@@ -95,7 +100,7 @@ Here are some example prompts and their generated diagrams:
|
||||
|
||||
## MCP Server (Preview)
|
||||
|
||||
> **Preview Feature**: This feature is experimental and may not stable.
|
||||
> **Preview Feature**: This feature is experimental and may not be stable.
|
||||
|
||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||
|
||||
@@ -131,39 +136,19 @@ No installation needed! Try the app directly on our demo site:
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
> Note: Due to high traffic, the demo site currently uses minimax-m2. For best results, we recommend self-hosting with Claude Sonnet 4.5 or Claude Opus 4.5.
|
||||
|
||||
|
||||
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
|
||||
|
||||
### Run with Docker (Recommended)
|
||||
### Desktop Application
|
||||
|
||||
If you just want to run it locally, the best way is to use Docker.
|
||||
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
||||
|
||||
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
|
||||
Supported platforms: Windows, macOS, Linux.
|
||||
|
||||
Then run:
|
||||
### Run with 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
|
||||
```
|
||||
|
||||
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.
|
||||
[Go to Docker Guide](./docs/en/docker.md)
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -172,56 +157,51 @@ Replace the environment variables with your preferred AI provider configuration.
|
||||
```bash
|
||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||
cd next-ai-draw-io
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure your AI provider:
|
||||
|
||||
Create a `.env.local` file in the root directory:
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` and configure your chosen provider:
|
||||
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
|
||||
|
||||
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- Set `AI_MODEL` to the specific model you want to use
|
||||
- Add the required API keys for your provider
|
||||
- `TEMPERATURE`: Optional temperature setting (e.g., `0` for deterministic output). Leave unset for models that don't support it (e.g., reasoning models).
|
||||
- `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:
|
||||
2. Run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
|
||||
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.
|
||||
|
||||
## Deployment
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js.
|
||||
### Deploy to EdgeOne Pages
|
||||
|
||||
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
|
||||
|
||||
Deploy by this button:
|
||||
|
||||
[](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 (Recommended)
|
||||
|
||||
Or you can deploy by this button.
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||
The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||
|
||||
See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
### Deploy on Cloudflare Workers
|
||||
|
||||
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
|
||||
|
||||
|
||||
|
||||
## Multi-Provider Support
|
||||
|
||||
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -231,14 +211,17 @@ Be sure to **set the environment variables** in the Vercel dashboard as you did
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
|
||||
|
||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
|
||||
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
|
||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
||||
|
||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||
|
||||
Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
||||
Note that 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.
|
||||
|
||||
|
||||
## How It Works
|
||||
@@ -251,27 +234,11 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
**Special thanks to [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
||||
|
||||
@@ -72,96 +72,70 @@ export default function AboutCN() {
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||
</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 className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
模型变更与用量限制{" "}
|
||||
<span className="text-sm text-amber-600 font-medium italic font-normal">
|
||||
(或者说:我的钱包顶不住了)
|
||||
</span>
|
||||
由字节跳动豆包提供支持
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
大家对这个项目的热情太高了——看来大家都真的很喜欢画图!但这也带来了一个幸福的烦恼:我们经常触发出上游
|
||||
AI 接口的频率限制
|
||||
(TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。
|
||||
</p>
|
||||
<p>
|
||||
由于使用量过高,我已将模型从 Claude 更换为{" "}
|
||||
好消息!感谢{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>
|
||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
minimax-m2
|
||||
</span>
|
||||
,以降低成本。
|
||||
</p>
|
||||
<p>
|
||||
作为一个
|
||||
K2-thinking
|
||||
</span>{" "}
|
||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
独立开发者
|
||||
50万免费Token
|
||||
</span>
|
||||
,目前的 API
|
||||
费用全是我自己在掏腰包(纯属为爱发电)。为了保证服务能细水长流,同时也为了避免我个人陷入财务危机,我还设置了以下临时用量限制:
|
||||
,适用于所有模型!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Limits Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
Token 用量
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(tpmLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/天
|
||||
</span>
|
||||
</div>
|
||||
{/* 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="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
每日请求数
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{dailyRequestLimit}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
次
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
@@ -171,48 +145,19 @@ export default function AboutCN() {
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center mb-5">
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
使用自己的 API Key
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
您可以使用自己的 API Key
|
||||
来绕过这些限制。点击聊天面板中的设置图标即可配置您的
|
||||
Provider 和 API Key。
|
||||
您也可以使用自己的 API
|
||||
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
您的 Key
|
||||
仅保存在浏览器本地,不会被存储在服务器上。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Sponsorship CTA */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
寻求赞助 (求大佬捞一把)
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
要想彻底解除这些限制,扩容后端是唯一的办法。我正在积极寻求
|
||||
AI API 提供商或云平台的赞助。
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
作为回报(无论是额度支持还是资金支持),我将在
|
||||
GitHub 仓库和 Live Demo
|
||||
网站的显眼位置展示贵公司的 Logo
|
||||
作为平台赞助商。
|
||||
</p>
|
||||
<a
|
||||
href="mailto:me@jiang.jp"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
联系我
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -377,6 +322,16 @@ export default function AboutCN() {
|
||||
多提供商支持
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock(默认)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI兼容API(通过{" "}
|
||||
@@ -388,6 +343,7 @@ export default function AboutCN() {
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||
@@ -395,18 +351,21 @@ export default function AboutCN() {
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
支持与联系
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
height="32"
|
||||
width="114"
|
||||
style={{ border: 0, borderRadius: 6 }}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
支持与联系
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
特别感谢{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>{" "}
|
||||
为本站提供 API Token 支持!
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
如果您觉得这个项目有用,请考虑{" "}
|
||||
<a
|
||||
|
||||
@@ -80,97 +80,70 @@ export default function AboutJA() {
|
||||
AI搭載のダイアグラム作成ツール -
|
||||
チャット、描画、可視化
|
||||
</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 className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
モデル変更と利用制限について{" "}
|
||||
<span className="text-sm text-amber-600 font-medium italic font-normal">
|
||||
(別名:お財布が悲鳴を上げています)
|
||||
</span>
|
||||
ByteDance Doubao提供
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
予想以上の反響をいただき、ありがとうございます!皆様にダイアグラム作成を楽しんでいただいているのは嬉しい限りですが、その熱量により
|
||||
AI API のレート制限 (TPS/TPM)
|
||||
に頻繁に引っかかってしまっています。制限に達するとシステムが一時停止し、エラーが発生してしまいます。
|
||||
</p>
|
||||
<p>
|
||||
利用量の増加に伴い、コスト削減のためモデルを
|
||||
Claude から{" "}
|
||||
朗報です!
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
様のご支援により、デモサイトでは強力な{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
minimax-m2
|
||||
K2-thinking
|
||||
</span>{" "}
|
||||
に変更しました。
|
||||
</p>
|
||||
<p>
|
||||
私は現在、
|
||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
個人開発者
|
||||
50万トークン
|
||||
</span>
|
||||
として API
|
||||
費用を全額自腹で負担しています。サービスを継続し、かつ私自身が借金を背負わないようにするため(笑)、一時的に以下の利用制限も設けさせていただきました。
|
||||
が無料でもらえます!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Limits Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
トークン使用量
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(tpmLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/分
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/日
|
||||
</span>
|
||||
</div>
|
||||
{/* 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="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
1日のリクエスト数
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{dailyRequestLimit}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
回
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
@@ -180,44 +153,17 @@ export default function AboutJA() {
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center mb-5">
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
自分のAPIキーを使用
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
自分のAPIキーを使用することで、これらの制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。
|
||||
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Sponsorship CTA */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
スポンサー募集
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
これらの制限を取り払い、バックエンドをスケールさせるには皆様の支援が必要です。現在、AI
|
||||
API
|
||||
プロバイダー様やクラウドプラットフォーム様からのスポンサー支援を積極的に募集しています。
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
ご支援(クレジット提供や資金援助)をいただける場合、GitHub
|
||||
リポジトリおよびデモサイトにて、プラットフォームスポンサーとして貴社を大々的にご紹介させていただきます。
|
||||
</p>
|
||||
<a
|
||||
href="mailto:me@jiang.jp"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
お問い合わせ
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -391,6 +337,16 @@ export default function AboutJA() {
|
||||
マルチプロバイダーサポート
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock(デフォルト)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||
@@ -402,6 +358,7 @@ export default function AboutJA() {
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注:<code>claude-sonnet-4-5</code>
|
||||
@@ -409,18 +366,21 @@ export default function AboutJA() {
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
height="32"
|
||||
width="114"
|
||||
style={{ border: 0, borderRadius: 6 }}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>{" "}
|
||||
様に、心より感謝申し上げます。
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||
<a
|
||||
|
||||
@@ -80,103 +80,72 @@ export default function About() {
|
||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||
Visualize
|
||||
</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 className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-rose-400 opacity-20" />
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
Model Change & Usage Limits{" "}
|
||||
<span className="text-sm text-amber-600 font-medium italic font-normal">
|
||||
(Or: Why My Wallet is Crying)
|
||||
</span>
|
||||
Sponsored by ByteDance Doubao
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
The response to this project has been
|
||||
incredible—you all love making diagrams!
|
||||
However, this enthusiasm means we are
|
||||
frequently hitting the AI API rate limits
|
||||
(TPS/TPM). When this happens, the system
|
||||
pauses, leading to failed requests.
|
||||
</p>
|
||||
<p>
|
||||
Due to the high usage, I have changed the
|
||||
model from Claude to{" "}
|
||||
Great news! Thanks to the generous
|
||||
sponsorship from{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
, the demo site now uses the powerful{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
minimax-m2
|
||||
</span>
|
||||
, which is more cost-effective.
|
||||
</p>
|
||||
<p>
|
||||
As an{" "}
|
||||
K2-thinking
|
||||
</span>{" "}
|
||||
model for better diagram generation! Sign up
|
||||
via the link to get{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
indie developer
|
||||
</span>
|
||||
, I am currently footing the entire API
|
||||
bill. To keep the lights on and ensure the
|
||||
service remains available to everyone
|
||||
without sending me into debt, I have also
|
||||
implemented the following temporary caps:
|
||||
500K free tokens
|
||||
</span>{" "}
|
||||
for all models!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Limits Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
Token Usage
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(tpmLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/min
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatNumber(dailyTokenLimit)}
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
/day
|
||||
</span>
|
||||
</div>
|
||||
{/* 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="rounded-xl bg-gradient-to-br from-amber-100 to-orange-100 p-4 text-center">
|
||||
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
|
||||
Daily Requests
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{dailyRequestLimit}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
requests
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
@@ -186,51 +155,21 @@ export default function About() {
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center mb-5">
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
Bring Your Own API Key
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
You can use your own API key to bypass these
|
||||
limits. Click the Settings icon in the chat
|
||||
panel to configure your provider and API
|
||||
key.
|
||||
You can also use your own API key with any
|
||||
supported provider. Click the Settings icon
|
||||
in the chat panel to configure your provider
|
||||
and API key.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
Your key is stored locally in your browser
|
||||
and is never stored on the server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Sponsorship CTA */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
Call for Sponsorship
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
Scaling the backend is the only way to
|
||||
remove these limits. I am actively seeking
|
||||
sponsorship from AI API providers or Cloud
|
||||
Platforms.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-4 max-w-md mx-auto">
|
||||
In return for support (credits or funding),
|
||||
I will prominently feature your company as a
|
||||
platform sponsor on both the GitHub
|
||||
repository and the live demo site.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:me@jiang.jp"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium text-sm shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Contact Me
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,6 +356,16 @@ export default function About() {
|
||||
Multi-Provider Support
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock (default)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||
@@ -428,6 +377,7 @@ export default function About() {
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||
@@ -437,18 +387,21 @@ export default function About() {
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
Support & Contact
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
height="32"
|
||||
width="114"
|
||||
style={{ border: 0, borderRadius: 6 }}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Support & Contact
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
Special thanks to{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>{" "}
|
||||
for sponsoring the API token usage of the demo site!
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
If you find this project useful, please consider{" "}
|
||||
<a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
|
||||
const drawioBaseUrl =
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||
@@ -24,6 +26,10 @@ export default function Home() {
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
} = useDiagram()
|
||||
const router = useRouter()
|
||||
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 [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||
@@ -58,6 +64,18 @@ export default function Home() {
|
||||
|
||||
// Load preferences from localStorage after mount
|
||||
useEffect(() => {
|
||||
// Restore saved locale and redirect if needed
|
||||
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
|
||||
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
|
||||
const pathParts = pathname.split("/").filter(Boolean)
|
||||
const currentLocale = pathParts[0]
|
||||
if (currentLocale !== savedLocale) {
|
||||
pathParts[0] = savedLocale
|
||||
router.replace(`/${pathParts.join("/")}`)
|
||||
return // Wait for redirect
|
||||
}
|
||||
}
|
||||
|
||||
const savedUi = localStorage.getItem("drawio-theme")
|
||||
if (savedUi === "min" || savedUi === "sketch") {
|
||||
setDrawioUi(savedUi)
|
||||
@@ -84,7 +102,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
}, [pathname, router])
|
||||
|
||||
const handleDarkModeChange = async () => {
|
||||
await saveDiagramToStorage()
|
||||
@@ -191,7 +209,7 @@ export default function Home() {
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
||||
{isLoaded ? (
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}`}
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={onDrawioLoad}
|
||||
@@ -204,6 +222,7 @@ export default function Home() {
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
dark: darkMode,
|
||||
lang: currentLang,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -12,8 +12,17 @@ import fs from "fs/promises"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
||||
import {
|
||||
getAIModel,
|
||||
supportsImageInput,
|
||||
supportsPromptCaching,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import {
|
||||
getTelemetryConfig,
|
||||
setTraceInput,
|
||||
@@ -21,6 +30,7 @@ import {
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const maxDuration = 120
|
||||
|
||||
@@ -162,9 +172,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
||||
|
||||
// Get user IP for Langfuse tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
// Get user ID for Langfuse tracking and quota
|
||||
const userId = getUserIdFromRequest(req)
|
||||
|
||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||
const validSessionId =
|
||||
@@ -173,9 +182,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
: undefined
|
||||
|
||||
// Extract user input text for Langfuse trace
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((m: any) => m.role === "user")
|
||||
const userInputText =
|
||||
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
|
||||
// Update Langfuse trace with input, session, and user
|
||||
setTraceInput({
|
||||
@@ -184,6 +196,33 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
userId: userId,
|
||||
})
|
||||
|
||||
// === SERVER-SIDE QUOTA CHECK START ===
|
||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||
const hasOwnApiKey = !!(
|
||||
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
|
||||
)
|
||||
|
||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
// === SERVER-SIDE QUOTA CHECK END ===
|
||||
|
||||
// === FILE VALIDATION START ===
|
||||
const fileValidation = validateFileParts(messages)
|
||||
if (!fileValidation.valid) {
|
||||
@@ -209,11 +248,34 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// === CACHE CHECK END ===
|
||||
|
||||
// Read client AI provider overrides from headers
|
||||
const provider = req.headers.get("x-ai-provider")
|
||||
let baseUrl = req.headers.get("x-ai-base-url")
|
||||
|
||||
// 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")
|
||||
|
||||
const clientOverrides = {
|
||||
provider: req.headers.get("x-ai-provider"),
|
||||
baseUrl: req.headers.get("x-ai-base-url"),
|
||||
provider,
|
||||
baseUrl,
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
modelId: req.headers.get("x-ai-model"),
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||
awsRegion: req.headers.get("x-aws-region"),
|
||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||
// Pass cookies for EdgeOne Pages authentication
|
||||
...(provider === "edgeone" &&
|
||||
cookieHeader && {
|
||||
headers: { cookie: cookieHeader },
|
||||
}),
|
||||
}
|
||||
|
||||
// Read minimal style preference from header
|
||||
@@ -232,9 +294,21 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||
|
||||
// Extract file parts (images) from the last message
|
||||
// Extract file parts (images) from the last user message
|
||||
const fileParts =
|
||||
lastMessage.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
|
||||
const formattedUserInput = `User input:
|
||||
@@ -243,7 +317,7 @@ ${userInputText}
|
||||
"""`
|
||||
|
||||
// Convert UIMessages to ModelMessages and add system message
|
||||
const modelMessages = convertToModelMessages(messages)
|
||||
const modelMessages = await convertToModelMessages(messages)
|
||||
|
||||
// DEBUG: Log incoming messages structure
|
||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||
@@ -497,12 +571,26 @@ ${userInputText}
|
||||
userId,
|
||||
}),
|
||||
}),
|
||||
onFinish: ({ text, usage }) => {
|
||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||
setTraceOutput(text, {
|
||||
promptTokens: usage?.inputTokens,
|
||||
completionTokens: usage?.outputTokens,
|
||||
})
|
||||
onFinish: ({ text, totalUsage }) => {
|
||||
// AI SDK 6 telemetry auto-reports token usage on its spans
|
||||
setTraceOutput(text)
|
||||
|
||||
// Record token usage for server-side quota tracking (if enabled)
|
||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||
// Include all 4 token types: input, output, cache read, cache write
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!hasOwnApiKey &&
|
||||
userId !== "anonymous" &&
|
||||
totalUsage
|
||||
) {
|
||||
const totalTokens =
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0) +
|
||||
(totalUsage.cachedInputTokens || 0) +
|
||||
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
|
||||
recordTokenUsage(userId, totalTokens)
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
@@ -550,18 +638,26 @@ Notes:
|
||||
Operations:
|
||||
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
||||
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||
- delete: Remove a cell by its id. Only cell_id is needed.
|
||||
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||
|
||||
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
||||
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||
|
||||
Example - Add a rectangle:
|
||||
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
||||
|
||||
Example - Delete container (children & edges auto-deleted):
|
||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
||||
inputSchema: z.object({
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z
|
||||
operation: z
|
||||
.enum(["update", "add", "delete"])
|
||||
.describe("Operation type"),
|
||||
.describe(
|
||||
"Operation to perform: add, update, or delete",
|
||||
),
|
||||
cell_id: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -672,19 +768,9 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
const usage = (part as any).totalUsage
|
||||
if (!usage) {
|
||||
console.warn(
|
||||
"[messageMetadata] No usage data in finish part",
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
// Total input = non-cached + cached (these are separate counts)
|
||||
// Note: cacheWriteInputTokens is not available on finish part
|
||||
const totalInputTokens =
|
||||
(usage.inputTokens ?? 0) + (usage.cachedInputTokens ?? 0)
|
||||
// AI SDK 6 provides totalTokens directly
|
||||
return {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
totalTokens: usage?.totalTokens ?? 0,
|
||||
finishReason: (part as any).finishReason,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
messageId: z.string().min(1).max(200),
|
||||
@@ -27,9 +28,13 @@ export async function POST(req: Request) {
|
||||
|
||||
const { messageId, feedback, sessionId } = data
|
||||
|
||||
// Get user IP for tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
// Get user ID for tracking
|
||||
const userId = getUserIdFromRequest(req)
|
||||
|
||||
try {
|
||||
// Find the most recent chat trace for this session to attach the score to
|
||||
|
||||
@@ -27,6 +27,11 @@ export async function POST(req: Request) {
|
||||
|
||||
const { filename, format, sessionId } = data
|
||||
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
|
||||
317
app/api/validate-model/route.ts
Normal file
317
app/api/validate-model/route.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||
import { createGateway } from "@ai-sdk/gateway"
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { generateText } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
/**
|
||||
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||
*/
|
||||
function isPrivateUrl(urlString: string): boolean {
|
||||
try {
|
||||
const url = new URL(urlString)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
|
||||
// Block localhost
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Block AWS/cloud metadata endpoints
|
||||
if (
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for private IPv4 ranges
|
||||
const ipv4Match = hostname.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||
)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if (a === 169 && b === 254) return true
|
||||
// 127.0.0.0/8 (loopback)
|
||||
if (a === 127) return true
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".localhost")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch {
|
||||
// Invalid URL - block it
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
interface ValidateRequest {
|
||||
provider: string
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
modelId: string
|
||||
// AWS Bedrock specific
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body: ValidateRequest = await req.json()
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
modelId,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
} = body
|
||||
|
||||
if (!provider || !modelId) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Provider and model ID are required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && isPrivateUrl(baseUrl)) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials based on provider
|
||||
if (provider === "bedrock") {
|
||||
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let model: any
|
||||
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "anthropic": {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
||||
})
|
||||
model = anthropic(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "google": {
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = google(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "azure": {
|
||||
const azure = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
})
|
||||
model = azure.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "bedrock": {
|
||||
const bedrock = createAmazonBedrock({
|
||||
accessKeyId: awsAccessKeyId,
|
||||
secretAccessKey: awsSecretAccessKey,
|
||||
region: awsRegion,
|
||||
})
|
||||
model = bedrock(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "openrouter": {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = openrouter(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
if (baseUrl || apiKey) {
|
||||
const ds = createDeepSeek({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = ds(modelId)
|
||||
} else {
|
||||
model = deepseek(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "siliconflow": {
|
||||
const sf = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
||||
})
|
||||
model = sf.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
const ollama = createOllama({
|
||||
baseURL: baseUrl || "http://localhost:11434",
|
||||
})
|
||||
model = ollama(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "gateway": {
|
||||
const gw = createGateway({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = gw(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
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": {
|
||||
// SGLang is OpenAI-compatible
|
||||
const sglang = createOpenAI({
|
||||
apiKey: apiKey || "not-needed",
|
||||
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
||||
})
|
||||
model = sglang.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "doubao": {
|
||||
// ByteDance Doubao uses DeepSeek-compatible API
|
||||
const doubao = createDeepSeek({
|
||||
apiKey,
|
||||
baseURL:
|
||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
||||
})
|
||||
model = doubao(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Make a minimal test request
|
||||
const startTime = Date.now()
|
||||
await generateText({
|
||||
model,
|
||||
prompt: "Say 'OK'",
|
||||
maxOutputTokens: 20,
|
||||
})
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[validate-model] Error:", error)
|
||||
|
||||
let errorMessage = "Validation failed"
|
||||
if (error instanceof Error) {
|
||||
// Extract meaningful error message
|
||||
if (
|
||||
error.message.includes("401") ||
|
||||
error.message.includes("Unauthorized")
|
||||
) {
|
||||
errorMessage = "Invalid API key"
|
||||
} else if (
|
||||
error.message.includes("404") ||
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
errorMessage = "Model not found"
|
||||
} else if (
|
||||
error.message.includes("429") ||
|
||||
error.message.includes("rate limit")
|
||||
) {
|
||||
errorMessage = "Rate limited - try again later"
|
||||
} else if (error.message.includes("ECONNREFUSED")) {
|
||||
errorMessage = "Cannot connect to server"
|
||||
} else {
|
||||
errorMessage = error.message.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: errorMessage },
|
||||
{ status: 200 }, // Return 200 so client can read error message
|
||||
)
|
||||
}
|
||||
}
|
||||
142
app/globals.css
142
app/globals.css
@@ -144,6 +144,68 @@
|
||||
--sidebar-ring: oklch(0.7 0.16 265);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED MINIMAL DESIGN SYSTEM
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Surface layers for depth */
|
||||
--surface-0: oklch(1 0 0);
|
||||
--surface-1: oklch(0.985 0.002 240);
|
||||
--surface-2: oklch(0.97 0.004 240);
|
||||
--surface-elevated: oklch(1 0 0);
|
||||
|
||||
/* Subtle borders */
|
||||
--border-subtle: oklch(0.94 0.008 260);
|
||||
--border-default: oklch(0.91 0.012 260);
|
||||
|
||||
/* Interactive states */
|
||||
--interactive-hover: oklch(0.96 0.015 260);
|
||||
--interactive-active: oklch(0.93 0.02 265);
|
||||
|
||||
/* Success state */
|
||||
--success: oklch(0.65 0.18 145);
|
||||
--success-muted: oklch(0.95 0.03 145);
|
||||
|
||||
/* Animation timing */
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--surface-0: oklch(0.15 0.015 260);
|
||||
--surface-1: oklch(0.18 0.015 260);
|
||||
--surface-2: oklch(0.22 0.015 260);
|
||||
--surface-elevated: oklch(0.25 0.015 260);
|
||||
|
||||
--border-subtle: oklch(0.25 0.012 260);
|
||||
--border-default: oklch(0.3 0.015 260);
|
||||
|
||||
--interactive-hover: oklch(0.25 0.02 265);
|
||||
--interactive-active: oklch(0.3 0.025 270);
|
||||
|
||||
--success: oklch(0.7 0.16 145);
|
||||
--success-muted: oklch(0.25 0.04 145);
|
||||
}
|
||||
|
||||
/* Expose surface colors to Tailwind */
|
||||
@theme inline {
|
||||
--color-surface-0: var(--surface-0);
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-elevated: var(--surface-elevated);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-interactive-hover: var(--interactive-hover);
|
||||
--color-interactive-active: var(--interactive-active);
|
||||
--color-success: var(--success);
|
||||
--color-success-muted: var(--success-muted);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -257,3 +319,83 @@
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED DIALOG STYLES
|
||||
============================================ */
|
||||
|
||||
/* Refined dialog shadow - multi-layer soft shadow */
|
||||
.shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.03),
|
||||
0 2px 4px oklch(0 0 0 / 0.02),
|
||||
0 12px 24px oklch(0 0 0 / 0.06),
|
||||
0 24px 48px oklch(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.dark .shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(1 0 0 / 0.05),
|
||||
0 2px 4px oklch(0 0 0 / 0.2),
|
||||
0 12px 24px oklch(0 0 0 / 0.3),
|
||||
0 24px 48px oklch(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* Dialog animations */
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dialog-in {
|
||||
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
.animate-dialog-out {
|
||||
animation: dialog-out 150ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
/* Check pop animation for validation success */
|
||||
@keyframes check-pop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-check-pop {
|
||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-dialog-in,
|
||||
.animate-dialog-out,
|
||||
.animate-check-pop {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Next AI Draw.io",
|
||||
short_name: "AIDraw.io",
|
||||
description:
|
||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
start_url: "/",
|
||||
start_url: getAssetUrl("/"),
|
||||
display: "standalone",
|
||||
background_color: "#f9fafb",
|
||||
theme_color: "#171d26",
|
||||
icons: [
|
||||
{
|
||||
src: "/favicon-192x192.png",
|
||||
src: getAssetUrl("/favicon-192x192.png"),
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/favicon-512x512.png",
|
||||
src: getAssetUrl("/favicon-512x512.png"),
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
|
||||
170
components/ai-elements/model-selector.tsx
Normal file
170
components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Cloud } from "lucide-react"
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
)
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||
|
||||
export const ModelSelectorList = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorListProps) => (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => {
|
||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||
if (provider === "amazon-bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
|
||||
interface ExampleCardProps {
|
||||
icon: React.ReactNode
|
||||
@@ -79,7 +80,7 @@ export default function ExamplePanel({
|
||||
setInput("Replicate this flowchart.")
|
||||
|
||||
try {
|
||||
const response = await fetch("/example.png")
|
||||
const response = await fetch(getAssetUrl("/example.png"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "example.png", { type: "image/png" })
|
||||
setFiles([file])
|
||||
@@ -92,7 +93,7 @@ export default function ExamplePanel({
|
||||
setInput("Replicate this in aws style")
|
||||
|
||||
try {
|
||||
const response = await fetch("/architecture.png")
|
||||
const response = await fetch(getAssetUrl("/architecture.png"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "architecture.png", {
|
||||
type: "image/png",
|
||||
@@ -107,7 +108,7 @@ export default function ExamplePanel({
|
||||
setInput("Summarize this paper as a diagram")
|
||||
|
||||
try {
|
||||
const response = await fetch("/chain-of-thought.txt")
|
||||
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "chain-of-thought.txt", {
|
||||
type: "text/plain",
|
||||
|
||||
@@ -14,20 +14,17 @@ import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
@@ -150,12 +147,15 @@ interface ChatInputProps {
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
showHistory?: boolean
|
||||
onToggleHistory?: (show: boolean) => void
|
||||
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
minimalStyle?: boolean
|
||||
onMinimalStyleChange?: (value: boolean) => void
|
||||
// Model selector props
|
||||
models?: FlattenedModel[]
|
||||
selectedModelId?: string
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
showUnvalidatedModels?: boolean
|
||||
onConfigureModels?: () => void
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -167,24 +167,23 @@ export function ChatInput({
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
pdfData = new Map(),
|
||||
showHistory = false,
|
||||
onToggleHistory = () => {},
|
||||
sessionId,
|
||||
error = null,
|
||||
minimalStyle = false,
|
||||
onMinimalStyleChange = () => {},
|
||||
models = [],
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
showUnvalidatedModels = false,
|
||||
onConfigureModels = () => {},
|
||||
}: ChatInputProps) {
|
||||
const dict = useDictionary()
|
||||
const {
|
||||
diagramHistory,
|
||||
saveDiagramToFile,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
} = useDiagram()
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [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
|
||||
@@ -372,101 +371,67 @@ export function ChatInput({
|
||||
onOpenChange={setShowClearDialog}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
<HistoryDialog
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={onToggleHistory}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
id="minimal-style"
|
||||
checked={minimalStyle}
|
||||
onCheckedChange={onMinimalStyleChange}
|
||||
className="scale-75"
|
||||
/>
|
||||
<label
|
||||
htmlFor="minimal-style"
|
||||
className={`text-xs cursor-pointer select-none ${
|
||||
minimalStyle
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{minimalStyle
|
||||
? dict.chat.minimalStyle
|
||||
: dict.chat.styledMode}
|
||||
</label>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{dict.chat.minimalTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 overflow-hidden justify-end">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleHistory(true)}
|
||||
disabled={isDisabled || diagramHistory.length === 0}
|
||||
tooltipContent={dict.chat.diagramHistory}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowHistory(true)}
|
||||
disabled={
|
||||
isDisabled || diagramHistory.length === 0
|
||||
}
|
||||
tooltipContent={dict.chat.diagramHistory}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.saveDiagram}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.uploadFile}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onSelect={onModelSelect}
|
||||
onConfigure={onConfigureModels}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.saveDiagram}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(filename, format, sessionId)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
showUnvalidatedModels={showUnvalidatedModels}
|
||||
/>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.uploadFile}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
@@ -488,6 +453,20 @@ export function ChatInput({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HistoryDialog
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
/>
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(filename, format, sessionId)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@ import {
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
applyDiagramOperations,
|
||||
convertToLegalXml,
|
||||
extractCompleteMxCells,
|
||||
isMxCellXmlComplete,
|
||||
replaceNodes,
|
||||
validateAndFixXml,
|
||||
@@ -38,7 +41,7 @@ import ExamplePanel from "./chat-example-panel"
|
||||
import { CodeBlock } from "./code-block"
|
||||
|
||||
interface DiagramOperation {
|
||||
type: "update" | "add" | "delete"
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
@@ -51,12 +54,12 @@ function getCompleteOperations(
|
||||
return operations.filter(
|
||||
(op) =>
|
||||
op &&
|
||||
typeof op.type === "string" &&
|
||||
["update", "add", "delete"].includes(op.type) &&
|
||||
typeof op.operation === "string" &&
|
||||
["update", "add", "delete"].includes(op.operation) &&
|
||||
typeof op.cell_id === "string" &&
|
||||
op.cell_id.length > 0 &&
|
||||
// delete doesn't need new_xml, update/add do
|
||||
(op.type === "delete" || typeof op.new_xml === "string"),
|
||||
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,20 +80,20 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op.type}-${op.cell_id}-${index}`}
|
||||
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.type === "delete"
|
||||
op.operation === "delete"
|
||||
? "text-red-600"
|
||||
: op.type === "add"
|
||||
: op.operation === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op.type}
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
@@ -203,6 +206,7 @@ export function ChatMessageDisplay({
|
||||
onEditMessage,
|
||||
status = "idle",
|
||||
}: ChatMessageDisplayProps) {
|
||||
const dict = useDictionary()
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const previousXML = useRef<string>("")
|
||||
@@ -226,6 +230,12 @@ export function ChatMessageDisplay({
|
||||
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 [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
||||
string | null
|
||||
@@ -241,12 +251,38 @@ export function ChatMessageDisplay({
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
|
||||
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||
const setCopyState = (
|
||||
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 {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
setCopiedMessageId(messageId)
|
||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||
setCopyState(messageId, isToolCall, true)
|
||||
} catch (err) {
|
||||
// Fallback for non-secure contexts (HTTP) or permission denied
|
||||
const textarea = document.createElement("textarea")
|
||||
@@ -262,15 +298,11 @@ export function ChatMessageDisplay({
|
||||
if (!success) {
|
||||
throw new Error("Copy command failed")
|
||||
}
|
||||
setCopiedMessageId(messageId)
|
||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||
setCopyState(messageId, isToolCall, true)
|
||||
} catch (fallbackErr) {
|
||||
console.error("Failed to copy message:", fallbackErr)
|
||||
toast.error(
|
||||
"Failed to copy message. Please copy manually or check clipboard permissions.",
|
||||
)
|
||||
setCopyFailedMessageId(messageId)
|
||||
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
||||
toast.error(dict.chat.failedToCopyDetail)
|
||||
setCopyState(messageId, isToolCall, false)
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
@@ -291,7 +323,7 @@ export function ChatMessageDisplay({
|
||||
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
||||
|
||||
try {
|
||||
await fetch("/api/log-feedback", {
|
||||
await fetch(getApiEndpoint("/api/log-feedback"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -302,7 +334,7 @@ export function ChatMessageDisplay({
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to log feedback:", error)
|
||||
toast.error("Failed to record your feedback. Please try again.")
|
||||
toast.error(dict.errors.failedToRecordFeedback)
|
||||
// Revert optimistic UI update
|
||||
setFeedback((prev) => {
|
||||
const next = { ...prev }
|
||||
@@ -314,12 +346,28 @@ export function ChatMessageDisplay({
|
||||
|
||||
const handleDisplayChart = useCallback(
|
||||
(xml: string, showToast = false) => {
|
||||
const currentXml = xml || ""
|
||||
let currentXml = xml || ""
|
||||
const startTime = performance.now()
|
||||
|
||||
// During streaming (showToast=false), extract only complete mxCell elements
|
||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||
if (!showToast) {
|
||||
const completeCells = extractCompleteMxCells(currentXml)
|
||||
if (!completeCells) {
|
||||
return
|
||||
}
|
||||
currentXml = completeCells
|
||||
}
|
||||
|
||||
const convertedXml = convertToLegalXml(currentXml)
|
||||
if (convertedXml !== previousXML.current) {
|
||||
// Parse and validate XML BEFORE calling replaceNodes
|
||||
const parser = new DOMParser()
|
||||
const testDoc = parser.parseFromString(convertedXml, "text/xml")
|
||||
// Wrap in root element for parsing multiple mxCell elements
|
||||
const testDoc = parser.parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
const parseError = testDoc.querySelector("parsererror")
|
||||
|
||||
if (parseError) {
|
||||
@@ -331,9 +379,7 @@ export function ChatMessageDisplay({
|
||||
console.error(
|
||||
"[ChatMessageDisplay] Malformed XML detected in final output",
|
||||
)
|
||||
toast.error(
|
||||
"AI generated invalid diagram XML. Please try regenerating.",
|
||||
)
|
||||
toast.error(dict.errors.malformedXml)
|
||||
}
|
||||
return // Skip this update
|
||||
}
|
||||
@@ -346,7 +392,22 @@ 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>`
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
|
||||
// Validate and auto-fix the XML
|
||||
const xmlProcessTime = performance.now() - startTime
|
||||
|
||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||
// The quick DOM parse check above catches malformed XML
|
||||
// Full validation runs on final output (showToast=true)
|
||||
if (!showToast) {
|
||||
previousXML.current = convertedXml
|
||||
const loadStartTime = performance.now()
|
||||
onDisplayChart(replacedXML, true)
|
||||
console.log(
|
||||
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Final output: run full validation and auto-fix
|
||||
const validation = validateAndFixXml(replacedXML)
|
||||
if (validation.valid) {
|
||||
previousXML.current = convertedXml
|
||||
@@ -359,18 +420,17 @@ export function ChatMessageDisplay({
|
||||
)
|
||||
}
|
||||
// Skip validation in loadDiagram since we already validated above
|
||||
const loadStartTime = performance.now()
|
||||
onDisplayChart(xmlToLoad, true)
|
||||
console.log(
|
||||
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||
)
|
||||
} else {
|
||||
console.error(
|
||||
"[ChatMessageDisplay] XML validation failed:",
|
||||
validation.error,
|
||||
)
|
||||
// Only show toast if this is the final XML (not during streaming)
|
||||
if (showToast) {
|
||||
toast.error(
|
||||
"Diagram validation failed. Please try regenerating.",
|
||||
)
|
||||
}
|
||||
toast.error(dict.errors.validationFailed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -379,9 +439,7 @@ export function ChatMessageDisplay({
|
||||
)
|
||||
// Only show toast if this is the final XML (not during streaming)
|
||||
if (showToast) {
|
||||
toast.error(
|
||||
"Failed to process diagram. Please try regenerating.",
|
||||
)
|
||||
toast.error(dict.errors.failedToProcess)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,17 +660,10 @@ export function ChatMessageDisplay({
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup: clear any pending debounce timeout on unmount
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current)
|
||||
debounceTimeoutRef.current = null
|
||||
}
|
||||
if (editDebounceTimeoutRef.current) {
|
||||
clearTimeout(editDebounceTimeoutRef.current)
|
||||
editDebounceTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// NOTE: Don't cleanup debounce timeouts here!
|
||||
// The cleanup runs on every re-render (when messages changes),
|
||||
// which would cancel the timeout before it fires.
|
||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||
}, [messages, handleDisplayChart, chartXML])
|
||||
|
||||
const renderToolPart = (part: ToolPartLike) => {
|
||||
@@ -620,6 +671,7 @@ export function ChatMessageDisplay({
|
||||
const { state, input, output } = part
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const isCopied = copiedToolCallId === callId
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedTools((prev) => ({
|
||||
@@ -641,6 +693,35 @@ export function ChatMessageDisplay({
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
copyMessageToClipboard(callId, textToCopy, true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={callId}
|
||||
@@ -660,9 +741,32 @@ export function ChatMessageDisplay({
|
||||
<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>
|
||||
<>
|
||||
<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" &&
|
||||
(() => {
|
||||
@@ -805,7 +909,10 @@ export function ChatMessageDisplay({
|
||||
)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||
title="Edit message"
|
||||
title={
|
||||
dict.chat
|
||||
.editMessage
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -822,11 +929,13 @@ export function ChatMessageDisplay({
|
||||
title={
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "Copied!"
|
||||
? dict.chat.copied
|
||||
: copyFailedMessageId ===
|
||||
message.id
|
||||
? "Failed to copy"
|
||||
: "Copy message"
|
||||
? dict.chat
|
||||
.failedToCopy
|
||||
: dict.chat
|
||||
.copyResponse
|
||||
}
|
||||
>
|
||||
{copiedMessageId ===
|
||||
@@ -941,7 +1050,7 @@ export function ChatMessageDisplay({
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{dict.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -963,7 +1072,7 @@ export function ChatMessageDisplay({
|
||||
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"
|
||||
>
|
||||
Save & Submit
|
||||
{dict.chat.saveAndSubmit}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1096,7 +1205,8 @@ export function ChatMessageDisplay({
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
? "Click to edit"
|
||||
? dict.chat
|
||||
.clickToEdit
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -1298,8 +1408,8 @@ export function ChatMessageDisplay({
|
||||
title={
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "Copied!"
|
||||
: "Copy response"
|
||||
? dict.chat.copied
|
||||
: dict.chat.copyResponse
|
||||
}
|
||||
>
|
||||
{copiedMessageId ===
|
||||
@@ -1325,7 +1435,9 @@ export function ChatMessageDisplay({
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Regenerate response"
|
||||
title={
|
||||
dict.chat.regenerate
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -1347,7 +1459,7 @@ export function ChatMessageDisplay({
|
||||
? "text-green-600 bg-green-100"
|
||||
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
||||
}`}
|
||||
title="Good response"
|
||||
title={dict.chat.goodResponse}
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -1366,7 +1478,7 @@ export function ChatMessageDisplay({
|
||||
? "text-red-600 bg-red-100"
|
||||
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
||||
}`}
|
||||
title="Bad response"
|
||||
title={dict.chat.badResponse}
|
||||
>
|
||||
<ThumbsDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -3,33 +3,34 @@
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
import {
|
||||
AlertTriangle,
|
||||
MessageSquarePlus,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ChatInput } from "@/components/chat-input"
|
||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAIConfig } from "@/lib/ai-config"
|
||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
import { formatXML } from "@/lib/utils"
|
||||
import { ChatMessageDisplay } from "./chat-message-display"
|
||||
import LanguageToggle from "./language-toggle"
|
||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||
|
||||
// localStorage keys for persistence
|
||||
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
||||
@@ -71,6 +72,8 @@ const TOOL_ERROR_STATE = "output-error" as const
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
const MAX_AUTO_RETRY_COUNT = 1
|
||||
|
||||
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
|
||||
|
||||
/**
|
||||
* Check if auto-resubmit should happen based on tool errors.
|
||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||
@@ -144,9 +147,11 @@ export default function ChatPanel({
|
||||
// File processing using extracted hook
|
||||
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||
const [, setAccessCodeRequired] = useState(false)
|
||||
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
||||
|
||||
// Model configuration hook
|
||||
const modelConfig = useModelConfig()
|
||||
const [input, setInput] = useState("")
|
||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||
@@ -164,15 +169,14 @@ export default function ChatPanel({
|
||||
|
||||
// Check config on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/config")
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setAccessCodeRequired(data.accessCodeRequired)
|
||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
||||
setTpmLimit(data.tpmLimit || 0)
|
||||
})
|
||||
.catch(() => setAccessCodeRequired(false))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Quota management using extracted hook
|
||||
@@ -180,6 +184,7 @@ export default function ChatPanel({
|
||||
dailyRequestLimit,
|
||||
dailyTokenLimit,
|
||||
tpmLimit,
|
||||
onConfigModel: () => setShowModelConfigDialog(true),
|
||||
})
|
||||
|
||||
// Generate a unique session ID for Langfuse tracing (restore from localStorage if available)
|
||||
@@ -203,11 +208,10 @@ export default function ChatPanel({
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
|
||||
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Ref to track consecutive auto-retry count (reset on user action)
|
||||
const autoRetryCountRef = useRef(0)
|
||||
// Ref to track continuation retry count (for truncation handling)
|
||||
const continuationRetryCountRef = useRef(0)
|
||||
|
||||
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
|
||||
// When partialXmlRef.current.length > 0, we're in continuation mode
|
||||
@@ -226,475 +230,195 @@ export default function ChatPanel({
|
||||
> | null>(null)
|
||||
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
addToolOutput,
|
||||
stop,
|
||||
status,
|
||||
error,
|
||||
setMessages,
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/chat",
|
||||
}),
|
||||
async onToolCall({ toolCall }) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
||||
)
|
||||
}
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
})
|
||||
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// DEBUG: Log raw input to diagnose false truncation detection
|
||||
console.log(
|
||||
"[display_diagram] XML ending (last 100 chars):",
|
||||
xml.slice(-100),
|
||||
)
|
||||
console.log("[display_diagram] XML length:", xml.length)
|
||||
|
||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||
const isTruncated = !isMxCellXmlComplete(xml)
|
||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||
|
||||
Your output ended with:
|
||||
\`\`\`
|
||||
${partialEnding}
|
||||
\`\`\`
|
||||
|
||||
NEXT STEP: Call append_diagram with the continuation XML.
|
||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||
- Start from EXACTLY where you stopped
|
||||
- Complete all remaining mxCell elements`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Complete XML received - use it directly
|
||||
// (continuation is now handled via append_diagram tool)
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[display_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-error",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${finalXml}
|
||||
\`\`\``,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
const { operations } = toolCall.input as {
|
||||
operations: Array<{
|
||||
type: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}>
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
const { messages, sendMessage, addToolOutput, status, error, setMessages } =
|
||||
useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: getApiEndpoint("/api/chat"),
|
||||
}),
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutput)
|
||||
},
|
||||
onError: (error) => {
|
||||
// Handle server-side quota limit (429 response)
|
||||
// AI SDK puts the full response body in error.message for non-OK responses
|
||||
try {
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
currentXml = originalXml
|
||||
} else {
|
||||
// Fallback: use chartXML from ref if streaming didn't capture original
|
||||
const cachedXML = chartXMLRef.current
|
||||
if (cachedXML) {
|
||||
currentXml = cachedXML
|
||||
} else {
|
||||
// Last resort: export from iframe
|
||||
currentXml = await onFetchChart(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { applyDiagramOperations } = await import(
|
||||
"@/lib/utils"
|
||||
)
|
||||
const { result: editedXml, errors } =
|
||||
applyDiagramOperations(currentXml, operations)
|
||||
|
||||
// Check for operation errors
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map(
|
||||
(e) =>
|
||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Some operations failed:\n${errorMessages}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please check the cell IDs and retry.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
const data = JSON.parse(error.message)
|
||||
if (data.type === "request") {
|
||||
quotaManager.showQuotaLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(editedXml)
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit produced invalid XML: ${validationError}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please fix the operations to avoid structural issues.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (data.type === "token") {
|
||||
quotaManager.showTokenLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
onExport()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit failed: ${errorMessage}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (data.type === "tpm") {
|
||||
quotaManager.showTPMLimitToast(data.limit)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, fall through to string matching for backwards compatibility
|
||||
}
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
const isFreshStart =
|
||||
trimmed.startsWith("<mxGraphModel") ||
|
||||
trimmed.startsWith("<root") ||
|
||||
trimmed.startsWith("<mxfile") ||
|
||||
trimmed.startsWith('<mxCell id="0"') ||
|
||||
trimmed.startsWith('<mxCell id="1"')
|
||||
|
||||
if (isFreshStart) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||
|
||||
Continue from EXACTLY where the partial ended:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Start your continuation with the NEXT character after where it stopped.`,
|
||||
})
|
||||
// Fallback to string matching
|
||||
if (error.message.includes("Daily request limit")) {
|
||||
quotaManager.showQuotaLimitToast()
|
||||
return
|
||||
}
|
||||
if (error.message.includes("Daily token limit")) {
|
||||
quotaManager.showTokenLimitToast()
|
||||
return
|
||||
}
|
||||
if (
|
||||
error.message.includes("Rate limit exceeded") ||
|
||||
error.message.includes("tokens per minute")
|
||||
) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
return
|
||||
}
|
||||
|
||||
// Append to accumulated XML
|
||||
partialXmlRef.current += xml
|
||||
|
||||
// Check if XML is now complete (last mxCell is complete)
|
||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||
|
||||
if (isComplete) {
|
||||
// Wrap and display the complete diagram
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Validation error after assembly: ${validationError}
|
||||
|
||||
Assembled XML:
|
||||
\`\`\`xml
|
||||
${finalXml.substring(0, 2000)}...
|
||||
\`\`\`
|
||||
|
||||
Please use display_diagram with corrected XML.`,
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
// Debug: Log messages structure when error occurs
|
||||
console.log("[onError] messages count:", messages.length)
|
||||
messages.forEach((msg, idx) => {
|
||||
console.log(`[onError] Message ${idx}:`, {
|
||||
role: msg.role,
|
||||
partsCount: msg.parts?.length,
|
||||
})
|
||||
} else {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram assembly complete and displayed successfully.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Still incomplete - signal to continue
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||
|
||||
Current ending:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Continue from EXACTLY where you stopped.`,
|
||||
if (msg.parts) {
|
||||
msg.parts.forEach((part: any, partIdx: number) => {
|
||||
console.log(
|
||||
`[onError] Part ${partIdx}:`,
|
||||
JSON.stringify({
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputKeys:
|
||||
part.input &&
|
||||
typeof part.input === "object"
|
||||
? Object.keys(part.input)
|
||||
: null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
// Debug: Log messages structure when error occurs
|
||||
console.log("[onError] messages count:", messages.length)
|
||||
messages.forEach((msg, idx) => {
|
||||
console.log(`[onError] Message ${idx}:`, {
|
||||
role: msg.role,
|
||||
partsCount: msg.parts?.length,
|
||||
})
|
||||
if (msg.parts) {
|
||||
msg.parts.forEach((part: any, partIdx: number) => {
|
||||
console.log(
|
||||
`[onError] Part ${partIdx}:`,
|
||||
JSON.stringify({
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputKeys:
|
||||
part.input &&
|
||||
typeof part.input === "object"
|
||||
? Object.keys(part.input)
|
||||
: null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Translate technical errors into user-friendly messages
|
||||
// The server now handles detailed error messages, so we can display them directly.
|
||||
// But we still handle connection/network errors that happen before reaching the server.
|
||||
let friendlyMessage = error.message
|
||||
|
||||
// Simple check for network errors if message is generic
|
||||
if (friendlyMessage === "Failed to fetch") {
|
||||
friendlyMessage =
|
||||
"Network error. Please check your connection."
|
||||
}
|
||||
|
||||
// Truncated tool input error (model output limit too low)
|
||||
if (friendlyMessage.includes("toolUse.input is invalid")) {
|
||||
friendlyMessage =
|
||||
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
|
||||
}
|
||||
|
||||
// Translate image not supported error
|
||||
if (
|
||||
friendlyMessage.includes("image content block") ||
|
||||
friendlyMessage.toLowerCase().includes("image_url")
|
||||
) {
|
||||
friendlyMessage = "This model doesn't support image input."
|
||||
}
|
||||
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: friendlyMessage,
|
||||
parts: [
|
||||
{ type: "text" as const, text: friendlyMessage },
|
||||
],
|
||||
}
|
||||
return [...currentMessages, errorMessage]
|
||||
})
|
||||
}
|
||||
|
||||
// Translate technical errors into user-friendly messages
|
||||
// The server now handles detailed error messages, so we can display them directly.
|
||||
// But we still handle connection/network errors that happen before reaching the server.
|
||||
let friendlyMessage = error.message
|
||||
|
||||
// Simple check for network errors if message is generic
|
||||
if (friendlyMessage === "Failed to fetch") {
|
||||
friendlyMessage = "Network error. Please check your connection."
|
||||
}
|
||||
|
||||
// Truncated tool input error (model output limit too low)
|
||||
if (friendlyMessage.includes("toolUse.input is invalid")) {
|
||||
friendlyMessage =
|
||||
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
|
||||
}
|
||||
|
||||
// Translate image not supported error
|
||||
if (friendlyMessage.includes("image content block")) {
|
||||
friendlyMessage = "This model doesn't support image input."
|
||||
}
|
||||
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: friendlyMessage,
|
||||
parts: [{ type: "text" as const, text: friendlyMessage }],
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings dialog to help user fix it
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
return [...currentMessages, errorMessage]
|
||||
})
|
||||
},
|
||||
onFinish: ({ message }) => {
|
||||
// Track actual token usage from server metadata
|
||||
const metadata = message?.metadata as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings button and open dialog to help user fix it
|
||||
setAccessCodeRequired(true)
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
},
|
||||
onFinish: ({ message }) => {
|
||||
// Track actual token usage from server metadata
|
||||
const metadata = message?.metadata as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
// DEBUG: Log finish reason to diagnose truncation
|
||||
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
||||
},
|
||||
sendAutomaticallyWhen: ({ messages }) => {
|
||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||
|
||||
// DEBUG: Log finish reason to diagnose truncation
|
||||
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
||||
console.log("[onFinish] metadata:", metadata)
|
||||
const shouldRetry = hasToolErrors(
|
||||
messages as unknown as ChatMessage[],
|
||||
)
|
||||
|
||||
if (metadata) {
|
||||
// Use Number.isFinite to guard against NaN (typeof NaN === 'number' is true)
|
||||
const inputTokens = Number.isFinite(metadata.inputTokens)
|
||||
? (metadata.inputTokens as number)
|
||||
: 0
|
||||
const outputTokens = Number.isFinite(metadata.outputTokens)
|
||||
? (metadata.outputTokens as number)
|
||||
: 0
|
||||
const actualTokens = inputTokens + outputTokens
|
||||
if (actualTokens > 0) {
|
||||
quotaManager.incrementTokenCount(actualTokens)
|
||||
quotaManager.incrementTPMCount(actualTokens)
|
||||
}
|
||||
}
|
||||
},
|
||||
sendAutomaticallyWhen: ({ messages }) => {
|
||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||
|
||||
const shouldRetry = hasToolErrors(
|
||||
messages as unknown as ChatMessage[],
|
||||
)
|
||||
|
||||
if (!shouldRetry) {
|
||||
// No error, reset retry count and clear state
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// Continuation mode: unlimited retries (truncation continuation, not real errors)
|
||||
// Server limits to 5 steps via stepCountIs(5)
|
||||
if (isInContinuationMode) {
|
||||
// Don't count against retry limit for continuation
|
||||
// Quota checks still apply below
|
||||
} else {
|
||||
// Regular error: check retry count limit
|
||||
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
||||
toast.error(
|
||||
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`,
|
||||
)
|
||||
if (!shouldRetry) {
|
||||
// No error, reset retry count and clear state
|
||||
autoRetryCountRef.current = 0
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
// Increment retry count for actual errors
|
||||
autoRetryCountRef.current++
|
||||
}
|
||||
|
||||
// Check quota limits before auto-retry
|
||||
const tokenLimitCheck = quotaManager.checkTokenLimit()
|
||||
if (!tokenLimitCheck.allowed) {
|
||||
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
// Continuation mode: limited retries for truncation handling
|
||||
if (isInContinuationMode) {
|
||||
if (
|
||||
continuationRetryCountRef.current >=
|
||||
MAX_CONTINUATION_RETRY_COUNT
|
||||
) {
|
||||
toast.error(
|
||||
formatMessage(dict.errors.continuationRetryLimit, {
|
||||
max: MAX_CONTINUATION_RETRY_COUNT,
|
||||
}),
|
||||
)
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
continuationRetryCountRef.current++
|
||||
} else {
|
||||
// Regular error: check retry count limit
|
||||
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
||||
toast.error(
|
||||
formatMessage(dict.errors.retryLimit, {
|
||||
max: MAX_AUTO_RETRY_COUNT,
|
||||
}),
|
||||
)
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
// Increment retry count for actual errors
|
||||
autoRetryCountRef.current++
|
||||
}
|
||||
|
||||
const tpmCheck = quotaManager.checkTPMLimit()
|
||||
if (!tpmCheck.allowed) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
// Update stopRef so onToolCall can access it
|
||||
stopRef.current = stop
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
// Ref to track latest messages for unload persistence
|
||||
const messagesRef = useRef(messages)
|
||||
@@ -732,7 +456,7 @@ Continue from EXACTLY where you stopped.`,
|
||||
// On complete failure, clear storage to allow recovery
|
||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
||||
toast.error("Session data was corrupted. Starting fresh.")
|
||||
toast.error(dict.errors.sessionCorrupted)
|
||||
}
|
||||
}, [setMessages])
|
||||
|
||||
@@ -905,9 +629,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||
saveXmlSnapshots()
|
||||
|
||||
// Check all quota limits
|
||||
if (!checkAllQuotaLimits()) return
|
||||
|
||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
@@ -936,12 +657,10 @@ Continue from EXACTLY where you stopped.`,
|
||||
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
|
||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
|
||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
toast.success("Started a fresh chat")
|
||||
toast.success(dict.dialogs.clearSuccess)
|
||||
} catch (error) {
|
||||
console.error("Failed to clear localStorage:", error)
|
||||
toast.warning(
|
||||
"Chat cleared but browser storage could not be updated",
|
||||
)
|
||||
toast.warning(dict.errors.storageUpdateFailed)
|
||||
}
|
||||
|
||||
setShowNewChatDialog(false)
|
||||
@@ -985,30 +704,7 @@ Continue from EXACTLY where you stopped.`,
|
||||
saveXmlSnapshots()
|
||||
}
|
||||
|
||||
// Check all quota limits (daily requests, tokens, TPM)
|
||||
const checkAllQuotaLimits = (): boolean => {
|
||||
const limitCheck = quotaManager.checkDailyLimit()
|
||||
if (!limitCheck.allowed) {
|
||||
quotaManager.showQuotaLimitToast()
|
||||
return false
|
||||
}
|
||||
|
||||
const tokenLimitCheck = quotaManager.checkTokenLimit()
|
||||
if (!tokenLimitCheck.allowed) {
|
||||
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
|
||||
return false
|
||||
}
|
||||
|
||||
const tpmCheck = quotaManager.checkTPMLimit()
|
||||
if (!tpmCheck.allowed) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Send chat message with headers and increment quota
|
||||
// Send chat message with headers
|
||||
const sendChatMessage = (
|
||||
parts: any,
|
||||
xml: string,
|
||||
@@ -1017,9 +713,10 @@ Continue from EXACTLY where you stopped.`,
|
||||
) => {
|
||||
// Reset all retry/continuation state on user-initiated message
|
||||
autoRetryCountRef.current = 0
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
|
||||
const config = getAIConfig()
|
||||
const config = getSelectedAIConfig()
|
||||
|
||||
sendMessage(
|
||||
{ parts },
|
||||
@@ -1036,6 +733,20 @@ Continue from EXACTLY where you stopped.`,
|
||||
"x-ai-api-key": config.aiApiKey,
|
||||
}),
|
||||
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
||||
// AWS Bedrock credentials
|
||||
...(config.awsAccessKeyId && {
|
||||
"x-aws-access-key-id": config.awsAccessKeyId,
|
||||
}),
|
||||
...(config.awsSecretAccessKey && {
|
||||
"x-aws-secret-access-key":
|
||||
config.awsSecretAccessKey,
|
||||
}),
|
||||
...(config.awsRegion && {
|
||||
"x-aws-region": config.awsRegion,
|
||||
}),
|
||||
...(config.awsSessionToken && {
|
||||
"x-aws-session-token": config.awsSessionToken,
|
||||
}),
|
||||
}),
|
||||
...(minimalStyle && {
|
||||
"x-minimal-style": "true",
|
||||
@@ -1043,7 +754,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
quotaManager.incrementRequestCount()
|
||||
}
|
||||
|
||||
// Process files and append content to user text (handles PDF, text, and optionally images)
|
||||
@@ -1131,13 +841,8 @@ Continue from EXACTLY where you stopped.`,
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Check all quota limits
|
||||
if (!checkAllQuotaLimits()) return
|
||||
|
||||
// Now send the message after state is guaranteed to be updated
|
||||
sendChatMessage(userParts, savedXml, previousXml, sessionId)
|
||||
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
}
|
||||
|
||||
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
||||
@@ -1179,12 +884,8 @@ Continue from EXACTLY where you stopped.`,
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Check all quota limits
|
||||
if (!checkAllQuotaLimits()) return
|
||||
|
||||
// Now send the edited message after state is guaranteed to be updated
|
||||
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
}
|
||||
|
||||
// Collapsed view (desktop only)
|
||||
@@ -1192,7 +893,7 @@ Continue from EXACTLY where you stopped.`,
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Show chat panel (Ctrl+B)"
|
||||
tooltipContent={dict.nav.showPanel}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleVisibility}
|
||||
@@ -1204,10 +905,9 @@ Continue from EXACTLY where you stopped.`,
|
||||
className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
transform: "rotate(180deg)",
|
||||
}}
|
||||
>
|
||||
AI Chat
|
||||
{dict.nav.aiChat}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1236,7 +936,11 @@ Continue from EXACTLY where you stopped.`,
|
||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/favicon.ico"
|
||||
src={
|
||||
darkMode
|
||||
? "/favicon-white.svg"
|
||||
: "/favicon.ico"
|
||||
}
|
||||
alt="Next AI Drawio"
|
||||
width={isMobile ? 24 : 28}
|
||||
height={isMobile ? 24 : 28}
|
||||
@@ -1248,32 +952,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
Next AI Drawio
|
||||
</h1>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<Link
|
||||
href="/about"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<Link
|
||||
href="/about"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Due to high usage, I have changed the model to minimax-m2 and added some usage limits. See About page for details."
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-amber-500 hover:text-amber-600"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 justify-end overflow-visible">
|
||||
<ButtonWithTooltip
|
||||
@@ -1287,17 +965,7 @@ Continue from EXACTLY where you stopped.`,
|
||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
||||
/>
|
||||
</ButtonWithTooltip>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<FaGithub
|
||||
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={dict.nav.settings}
|
||||
variant="ghost"
|
||||
@@ -1310,7 +978,6 @@ Continue from EXACTLY where you stopped.`,
|
||||
/>
|
||||
</ButtonWithTooltip>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
{!isMobile && (
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={dict.nav.hidePanel}
|
||||
@@ -1342,6 +1009,17 @@ Continue from EXACTLY where you stopped.`,
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Dev XML Streaming Simulator - only in development */}
|
||||
{DEBUG && (
|
||||
<DevXmlSimulator
|
||||
setMessages={setMessages}
|
||||
onDisplayChart={onDisplayChart}
|
||||
onShowQuotaToast={() =>
|
||||
quotaManager.showQuotaLimitToast(50, 50)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<footer
|
||||
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
||||
@@ -1355,12 +1033,13 @@ Continue from EXACTLY where you stopped.`,
|
||||
files={files}
|
||||
onFileChange={handleFileChange}
|
||||
pdfData={pdfData}
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
sessionId={sessionId}
|
||||
error={error}
|
||||
minimalStyle={minimalStyle}
|
||||
onMinimalStyleChange={setMinimalStyle}
|
||||
models={modelConfig.models}
|
||||
selectedModelId={modelConfig.selectedModelId}
|
||||
onModelSelect={modelConfig.setSelectedModelId}
|
||||
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||
/>
|
||||
</footer>
|
||||
|
||||
@@ -1372,6 +1051,14 @@ Continue from EXACTLY where you stopped.`,
|
||||
onToggleDrawioUi={onToggleDrawioUi}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={onToggleDarkMode}
|
||||
minimalStyle={minimalStyle}
|
||||
onMinimalStyleChange={setMinimalStyle}
|
||||
/>
|
||||
|
||||
<ModelConfigDialog
|
||||
open={showModelConfigDialog}
|
||||
onOpenChange={setShowModelConfigDialog}
|
||||
modelConfig={modelConfig}
|
||||
/>
|
||||
|
||||
<ResetWarningModal
|
||||
|
||||
363
components/dev-xml-simulator.tsx
Normal file
363
components/dev-xml-simulator.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
const DEV_XML_PRESETS: Record<string, string> = {
|
||||
"Simple Box": `<mxCell id="2" value="Hello World" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Two Boxes with Arrow": `<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
Flowchart: `<mxCell id="2" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="40" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Process A" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="120" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="Decision" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="220" width="100" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="Process B" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="230" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="340" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" style="endArrow=classic;html=1;" edge="1" parent="1" source="3" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="Yes" style="endArrow=classic;html=1;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="No" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="4" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Truncated (Error Test)": `<mxCell id="2" value="This cell is truncated" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Incomplete" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor`,
|
||||
"HTML Escape + Cell Truncate": `<mxCell id="2" value="<b>Chain-of-Thought Prompting</b><br/><font size='12'>Eliciting Reasoning in Large Language Models</font>" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="720" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="<b>Problem: LLM Reasoning Limitations</b><br/>• Scaling parameters alone insufficient for logical tasks<br/>• Arithmetic, commonsense, symbolic reasoning challenges<br/>• Standard prompting fails on multi-step problems" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="<b>Traditional Approaches</b><br/>1. <b>Finetuning:</b> Expensive, task-specific<br/>2. <b>Standard Few-Shot:</b> Input→Output pairs<br/> (No explanation of reasoning)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="<b>CoT Methodology</b><br/>• Add reasoning steps to few-shot examples<br/>• Natural language intermediate steps<br/>• No parameter updates needed<br/>• Model learns to generate own thought process" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="260" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="<b>Example Comparison</b><br/><b>Standard:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: 11.<br/><br/><b>CoT:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="260" width="340" height="140" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="<b>Experimental Models</b><br/>• GPT-3 (175B)<br/>• LaMDA (137B)<br/>• PaLM (540B)<br/>• UL2 (20B)<br/>• Codex" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="380" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="<b>Reasoning Domains Tested</b><br/>1. <b>Arithmetic:</b> GSM8K, SVAMP, ASDiv, AQuA, MAWPS<br/>2. <b>Commonsense:</b> CSQA, StrategyQA, Date Understanding, Sports Understanding<br/>3. <b>Symbolic:</b> Last Letter Concatenation, Coin Flip" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="420" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="<b>Key Results: Arithmetic</b><br/>• PaLM 540B + CoT: <b>56.9%</b> on GSM8K<br/> (vs 17.9% standard)<br/>• Surpassed finetuned GPT-3 (55%)<br/>• With calculator: <b>58.6%</b>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="<b>Key Results: Commonsense</b><br/>• StrategyQA: <b>75.6%</b><br/> (vs 69.4% SOTA)<br/>• Sports Understanding: <b>95.4%</b><br/> (vs 84% human)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="<b>Key Results: Symbolic</b><br/>• OOD Generalization<br/>• Coin Flip: Trained on 2 flips<br/> Works on 3-4 flips with CoT<br/>• Standard prompting fails" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="12" value="<b>Emergent Ability of Scale</b><br/>• Small models (<10B): No benefit, often harmful<br/>• Large models (100B+): Reasoning emerges<br/>• CoT gains increase dramatically with scale" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" value="<b>Ablation Studies</b><br/>1. Equation only: Worse than CoT<br/>2. Variable compute (...): No improvement<br/>3. Answer first, then reasoning: Same as baseline<br/>→ Content matters, not just extra tokens" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="14" value="<b>Error Analysis</b><br/>• Semantic understanding errors<br/>• One-step missing errors<br/>• Calculation errors<br/>• Larger models reduce semantic/missing-step errors" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="<b>Conclusion</b><br/>• CoT unlocks reasoning potential<br/>• Simple paradigm: "show your work"<br/>• Emergent capability of large models<br/>• No specialized architecture needed" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="3" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="5" target="7">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="6" target="8">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.25;entryY=0;" edge="1" parent="1" source="7" target="9">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="7" target="10">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.75;entryY=0;" edge="1" parent="1" source="7" target="11">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="9" target="12">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="10" target="13">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="11" target="14">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="26" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="12" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="13" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="14" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
}
|
||||
|
||||
interface DevXmlSimulatorProps {
|
||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||
onDisplayChart: (xml: string) => void
|
||||
onShowQuotaToast?: () => void
|
||||
}
|
||||
|
||||
export function DevXmlSimulator({
|
||||
setMessages,
|
||||
onDisplayChart,
|
||||
onShowQuotaToast,
|
||||
}: DevXmlSimulatorProps) {
|
||||
const dict = useDictionary()
|
||||
const [devXml, setDevXml] = useState("")
|
||||
const [isSimulating, setIsSimulating] = useState(false)
|
||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||
const [devChunkSize, setDevChunkSize] = useState(10)
|
||||
const devStopRef = useRef(false)
|
||||
const devXmlInitializedRef = useRef(false)
|
||||
|
||||
// Restore dev XML from localStorage on mount (after hydration)
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("dev-xml-simulator")
|
||||
if (saved) setDevXml(saved)
|
||||
devXmlInitializedRef.current = true
|
||||
}, [])
|
||||
|
||||
// Save dev XML to localStorage (only after initial load)
|
||||
useEffect(() => {
|
||||
if (devXmlInitializedRef.current) {
|
||||
localStorage.setItem("dev-xml-simulator", devXml)
|
||||
}
|
||||
}, [devXml])
|
||||
|
||||
const handleDevSimulate = async () => {
|
||||
if (!devXml.trim() || isSimulating) return
|
||||
|
||||
setIsSimulating(true)
|
||||
devStopRef.current = false
|
||||
const toolCallId = `dev-sim-${Date.now()}`
|
||||
const xml = devXml.trim()
|
||||
|
||||
// Add user message and initial assistant message with empty XML
|
||||
const userMsg = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: dict.dev.simulatingMessage,
|
||||
},
|
||||
],
|
||||
}
|
||||
const assistantMsg = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "tool-display_diagram" as const,
|
||||
toolCallId,
|
||||
state: "input-streaming" as const,
|
||||
input: { xml: "" },
|
||||
},
|
||||
],
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg] as any)
|
||||
|
||||
// Stream characters progressively
|
||||
for (let i = 0; i < xml.length; i += devChunkSize) {
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = xml.slice(0, i + devChunkSize)
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].input = { xml: chunk }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, devIntervalMs))
|
||||
}
|
||||
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Finalize: set state to output-available
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].state = "output-available"
|
||||
lastMsg.parts[0].output = dict.dev.successMessage
|
||||
lastMsg.parts[0].input = { xml }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
// Display the final diagram
|
||||
const fullXml = wrapWithMxFile(xml)
|
||||
onDisplayChart(fullXml)
|
||||
|
||||
setIsSimulating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||
<details>
|
||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||
{dict.dev.title}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.preset}
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
setDevXml(DEV_XML_PRESETS[e.target.value])
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-xs p-1 border rounded bg-background"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
{dict.dev.selectPreset}
|
||||
</option>
|
||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDevXml("")}
|
||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||
>
|
||||
{dict.dev.clear}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={devXml}
|
||||
onChange={(e) => setDevXml(e.target.value)}
|
||||
placeholder={dict.dev.placeholder}
|
||||
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-2 flex-1">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.interval}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="200"
|
||||
step="1"
|
||||
value={devIntervalMs}
|
||||
onChange={(e) =>
|
||||
setDevIntervalMs(Number(e.target.value))
|
||||
}
|
||||
className="flex-1 h-1 accent-orange-500"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-12">
|
||||
{devIntervalMs}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.chars}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={devChunkSize}
|
||||
onChange={(e) =>
|
||||
setDevChunkSize(
|
||||
Math.max(1, Number(e.target.value)),
|
||||
)
|
||||
}
|
||||
className="w-14 text-xs p-1 border rounded bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDevSimulate}
|
||||
disabled={isSimulating || !devXml.trim()}
|
||||
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
|
||||
? dict.dev.streaming
|
||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
</button>
|
||||
{isSimulating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
devStopRef.current = true
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
{dict.dev.stop}
|
||||
</button>
|
||||
)}
|
||||
{onShowQuotaToast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowQuotaToast}
|
||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||
>
|
||||
{dict.dev.testQuotaToast}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Globe } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useRef, useState } from "react"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
en: "EN",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
}
|
||||
|
||||
function LanguageToggleInner({ className = "" }: { className?: string }) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() || "/"
|
||||
const search = useSearchParams()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const seg = pathname.split("/").filter(Boolean)
|
||||
const first = seg[0]
|
||||
if (first && i18n.locales.includes(first as Locale))
|
||||
setValue(first as Locale)
|
||||
else setValue(i18n.defaultLocale)
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (!ref.current) return
|
||||
if (!ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", onDoc)
|
||||
return () => document.removeEventListener("mousedown", onDoc)
|
||||
}, [open])
|
||||
|
||||
const changeLocale = (lang: string) => {
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
} else {
|
||||
parts.splice(1, 0, lang)
|
||||
}
|
||||
const newPath = parts.join("/") || "/"
|
||||
const searchStr = search?.toString() ? `?${search.toString()}` : ""
|
||||
setOpen(false)
|
||||
router.push(newPath + searchStr)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex ${className}`} ref={ref}>
|
||||
<button
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((s) => !s)}
|
||||
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<Globe className="w-5 h-5" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
|
||||
<div className="grid gap-0 divide-y divide-border/30">
|
||||
{i18n.locales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => changeLocale(loc)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
{LABELS[loc] ?? loc}
|
||||
</span>
|
||||
{value === loc && (
|
||||
<span className="text-xs opacity-70">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LanguageToggle({
|
||||
className = "",
|
||||
}: {
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground opacity-50"
|
||||
disabled
|
||||
>
|
||||
<Globe className="w-5 h-5" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<LanguageToggleInner className={className} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
1622
components/model-config-dialog.tsx
Normal file
1622
components/model-config-dialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
303
components/model-selector.tsx
Normal file
303
components/model-selector.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Server,
|
||||
Settings2,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelector as ModelSelectorRoot,
|
||||
ModelSelectorSeparator,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onSelect: (modelId: string | undefined) => void
|
||||
onConfigure: () => void
|
||||
disabled?: boolean
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
|
||||
// Map our provider names to models.dev logo names
|
||||
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
google: "google",
|
||||
azure: "azure",
|
||||
bedrock: "amazon-bedrock",
|
||||
openrouter: "openrouter",
|
||||
deepseek: "deepseek",
|
||||
siliconflow: "siliconflow",
|
||||
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
||||
gateway: "vercel",
|
||||
edgeone: "tencent-cloud",
|
||||
doubao: "bytedance",
|
||||
}
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
function groupModelsByProvider(
|
||||
models: FlattenedModel[],
|
||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ provider: string; models: FlattenedModel[] }
|
||||
>()
|
||||
for (const model of models) {
|
||||
const key = model.providerLabel
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.models.push(model)
|
||||
} else {
|
||||
groups.set(key, { provider: model.provider, models: [model] })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
disabled = false,
|
||||
showUnvalidatedModels = false,
|
||||
}: ModelSelectorProps) {
|
||||
const dict = useDictionary()
|
||||
const [open, setOpen] = useState(false)
|
||||
// Filter models based on showUnvalidatedModels setting
|
||||
const displayModels = useMemo(() => {
|
||||
if (showUnvalidatedModels) {
|
||||
return models
|
||||
}
|
||||
return models.filter((m) => m.validated === true)
|
||||
}, [models, showUnvalidatedModels])
|
||||
const groupedModels = useMemo(
|
||||
() => groupModelsByProvider(displayModels),
|
||||
[displayModels],
|
||||
)
|
||||
|
||||
// Find selected model for display
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((m) => m.id === selectedModelId),
|
||||
[models, selectedModelId],
|
||||
)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === "__configure__") {
|
||||
onConfigure()
|
||||
} else if (value === "__server_default__") {
|
||||
onSelect(undefined)
|
||||
} else {
|
||||
onSelect(value)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const tooltipContent = selectedModel
|
||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const [showLabel, setShowLabel] = useState(true)
|
||||
|
||||
// Threshold (px) under which we hide the label (tweak as needed)
|
||||
const HIDE_THRESHOLD = 240
|
||||
const SHOW_THRESHOLD = 260
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current
|
||||
if (!el) return
|
||||
|
||||
const target = el.parentElement ?? el
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width
|
||||
setShowLabel((prev) => {
|
||||
// if currently showing and width dropped below hide threshold -> hide
|
||||
if (prev && width <= HIDE_THRESHOLD) return false
|
||||
// if currently hidden and width rose above show threshold -> show
|
||||
if (!prev && width >= SHOW_THRESHOLD) return true
|
||||
// otherwise keep previous state (hysteresis)
|
||||
return prev
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
ro.observe(target)
|
||||
|
||||
const initialWidth = target.getBoundingClientRect().width
|
||||
setShowLabel(initialWidth >= SHOW_THRESHOLD)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="inline-block">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={tooltipContent}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
>
|
||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="text-xs truncate">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
) : (
|
||||
// Keep an sr-only label for screen readers when hidden
|
||||
<span className="sr-only">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
</ModelSelectorTrigger>
|
||||
|
||||
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 && models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
|
||||
{/* Server Default Option */}
|
||||
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.serverDefault}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
|
||||
{/* Configured Models by Provider */}
|
||||
{Array.from(groupedModels.entries()).map(
|
||||
([
|
||||
providerLabel,
|
||||
{ provider, models: providerModels },
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={providerLabel}
|
||||
heading={providerLabel}
|
||||
>
|
||||
{providerModels.map((model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.modelId}
|
||||
onSelect={() =>
|
||||
handleSelect(model.id)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId === model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] || provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{model.modelId}
|
||||
</ModelSelectorName>
|
||||
{model.validated !== true && (
|
||||
<span
|
||||
title={
|
||||
dict.modelConfig
|
||||
.unvalidatedModelWarning
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Configure Option */}
|
||||
<ModelSelectorSeparator />
|
||||
<ModelSelectorGroup>
|
||||
<ModelSelectorItem
|
||||
value="__configure__"
|
||||
onSelect={handleSelect}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.configureModels}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
{/* Info text */}
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||
{showUnvalidatedModels
|
||||
? dict.modelConfig.allModelsShown
|
||||
: dict.modelConfig.onlyVerifiedShown}
|
||||
</div>
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Coffee, X } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Coffee, Settings, X } from "lucide-react"
|
||||
import type React from "react"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
@@ -12,6 +11,7 @@ interface QuotaLimitToastProps {
|
||||
used: number
|
||||
limit: number
|
||||
onDismiss: () => void
|
||||
onConfigModel?: () => void
|
||||
}
|
||||
|
||||
export function QuotaLimitToast({
|
||||
@@ -19,6 +19,7 @@ export function QuotaLimitToast({
|
||||
used,
|
||||
limit,
|
||||
onDismiss,
|
||||
onConfigModel,
|
||||
}: QuotaLimitToastProps) {
|
||||
const dict = useDictionary()
|
||||
const isTokenLimit = type === "token"
|
||||
@@ -75,16 +76,36 @@ export function QuotaLimitToast({
|
||||
? dict.quota.messageToken
|
||||
: dict.quota.messageApi}
|
||||
</p>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatMessage(dict.quota.doubaoSponsorship, {
|
||||
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
|
||||
<p>{dict.quota.reset}</p>
|
||||
</div>{" "}
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onConfigModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConfigModel()
|
||||
onDismiss()
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{dict.quota.configModel}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
{dict.quota.selfHost}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,6 +22,39 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
|
||||
// Reusable setting item component for consistent layout
|
||||
function SettingItem({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground max-w-[260px]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
@@ -30,15 +64,13 @@ interface SettingsDialogProps {
|
||||
onToggleDrawioUi: () => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
minimalStyle?: boolean
|
||||
onMinimalStyleChange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
|
||||
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
|
||||
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
|
||||
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
|
||||
|
||||
function getStoredAccessCodeRequired(): boolean | null {
|
||||
if (typeof window === "undefined") return null
|
||||
@@ -47,7 +79,7 @@ function getStoredAccessCodeRequired(): boolean | null {
|
||||
return stored === "true"
|
||||
}
|
||||
|
||||
export function SettingsDialog({
|
||||
function SettingsContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCloseProtectionChange,
|
||||
@@ -55,8 +87,13 @@ export function SettingsDialog({
|
||||
onToggleDrawioUi,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
minimalStyle = false,
|
||||
onMinimalStyleChange = () => {},
|
||||
}: SettingsDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() || "/"
|
||||
const search = useSearchParams()
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [closeProtection, setCloseProtection] = useState(true)
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
@@ -64,16 +101,13 @@ export function SettingsDialog({
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => getStoredAccessCodeRequired() ?? false,
|
||||
)
|
||||
const [provider, setProvider] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("")
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [modelId, setModelId] = useState("")
|
||||
const [currentLang, setCurrentLang] = useState("en")
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch if not cached in localStorage
|
||||
if (getStoredAccessCodeRequired() !== null) return
|
||||
|
||||
fetch("/api/config")
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
@@ -92,6 +126,17 @@ export function SettingsDialog({
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Detect current language from pathname
|
||||
useEffect(() => {
|
||||
const seg = pathname.split("/").filter(Boolean)
|
||||
const first = seg[0]
|
||||
if (first && i18n.locales.includes(first as Locale)) {
|
||||
setCurrentLang(first)
|
||||
} else {
|
||||
setCurrentLang(i18n.defaultLocale)
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const storedCode =
|
||||
@@ -104,16 +149,25 @@ export function SettingsDialog({
|
||||
// Default to true if not set
|
||||
setCloseProtection(storedCloseProtection !== "false")
|
||||
|
||||
// Load AI provider settings
|
||||
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
|
||||
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
|
||||
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
|
||||
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
|
||||
|
||||
setError("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const changeLanguage = (lang: string) => {
|
||||
// Save locale to localStorage for persistence across restarts
|
||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
} else {
|
||||
parts.splice(1, 0, lang)
|
||||
}
|
||||
const newPath = parts.join("/") || "/"
|
||||
const searchStr = search?.toString() ? `?${search.toString()}` : ""
|
||||
router.push(newPath + searchStr)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessCodeRequired) return
|
||||
|
||||
@@ -121,12 +175,15 @@ export function SettingsDialog({
|
||||
setIsVerifying(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/verify-access-code", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-access-code": accessCode.trim(),
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/verify-access-code"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-access-code": accessCode.trim(),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@@ -152,20 +209,32 @@ export function SettingsDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.settings.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{dict.settings.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{/* Access Code (conditional) */}
|
||||
{accessCodeRequired && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access-code">
|
||||
{dict.settings.accessCode}
|
||||
</Label>
|
||||
<div className="py-4 first:pt-0 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="access-code"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.settings.accessCode}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.accessCodeDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
@@ -179,222 +248,60 @@ export function SettingsDialog({
|
||||
dict.settings.accessCodePlaceholder
|
||||
}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
className="h-9 px-4 rounded-xl"
|
||||
>
|
||||
{isVerifying ? "..." : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.accessCodeDescription}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-[0.8rem] text-destructive">
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>{dict.settings.aiProvider}</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.aiProviderDescription}
|
||||
</p>
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-provider">
|
||||
{dict.settings.provider}
|
||||
</Label>
|
||||
<Select
|
||||
value={provider || "default"}
|
||||
onValueChange={(value) => {
|
||||
const actualValue =
|
||||
value === "default" ? "" : value
|
||||
setProvider(actualValue)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_PROVIDER_KEY,
|
||||
actualValue,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ai-provider">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
dict.settings.useServerDefault
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
{dict.settings.useServerDefault}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai">
|
||||
{dict.providers.openai}
|
||||
</SelectItem>
|
||||
<SelectItem value="anthropic">
|
||||
{dict.providers.anthropic}
|
||||
</SelectItem>
|
||||
<SelectItem value="google">
|
||||
{dict.providers.google}
|
||||
</SelectItem>
|
||||
<SelectItem value="azure">
|
||||
{dict.providers.azure}
|
||||
</SelectItem>
|
||||
<SelectItem value="openrouter">
|
||||
{dict.providers.openrouter}
|
||||
</SelectItem>
|
||||
<SelectItem value="deepseek">
|
||||
{dict.providers.deepseek}
|
||||
</SelectItem>
|
||||
<SelectItem value="siliconflow">
|
||||
{dict.providers.siliconflow}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{provider && provider !== "default" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">
|
||||
{dict.settings.modelId}
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
value={modelId}
|
||||
onChange={(e) => {
|
||||
setModelId(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_MODEL_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder={
|
||||
provider === "openai"
|
||||
? "e.g., gpt-4o"
|
||||
: provider === "anthropic"
|
||||
? "e.g., claude-sonnet-4-5"
|
||||
: provider === "google"
|
||||
? "e.g., gemini-2.0-flash-exp"
|
||||
: provider ===
|
||||
"deepseek"
|
||||
? "e.g., deepseek-chat"
|
||||
: dict.settings
|
||||
.modelId
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-api-key">
|
||||
{dict.settings.apiKey}
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_API_KEY_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder={
|
||||
dict.settings.apiKeyPlaceholder
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.overrides}{" "}
|
||||
{provider === "openai"
|
||||
? "OPENAI_API_KEY"
|
||||
: provider === "anthropic"
|
||||
? "ANTHROPIC_API_KEY"
|
||||
: provider === "google"
|
||||
? "GOOGLE_GENERATIVE_AI_API_KEY"
|
||||
: provider === "azure"
|
||||
? "AZURE_API_KEY"
|
||||
: provider ===
|
||||
"openrouter"
|
||||
? "OPENROUTER_API_KEY"
|
||||
: provider ===
|
||||
"deepseek"
|
||||
? "DEEPSEEK_API_KEY"
|
||||
: provider ===
|
||||
"siliconflow"
|
||||
? "SILICONFLOW_API_KEY"
|
||||
: "server API key"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-base-url">
|
||||
{dict.settings.baseUrl}
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setBaseUrl(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_BASE_URL_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder={
|
||||
provider === "anthropic"
|
||||
? "https://api.anthropic.com/v1"
|
||||
: provider === "siliconflow"
|
||||
? "https://api.siliconflow.com/v1"
|
||||
: dict.settings
|
||||
.customEndpoint
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_PROVIDER_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_BASE_URL_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_API_KEY_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_MODEL_KEY,
|
||||
)
|
||||
setProvider("")
|
||||
setBaseUrl("")
|
||||
setApiKey("")
|
||||
setModelId("")
|
||||
}}
|
||||
>
|
||||
{dict.settings.clearSettings}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="theme-toggle">
|
||||
{dict.settings.theme}
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.themeDescription}
|
||||
</p>
|
||||
</div>
|
||||
{/* Language */}
|
||||
<SettingItem
|
||||
label={dict.settings.language}
|
||||
description={dict.settings.languageDescription}
|
||||
>
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Theme */}
|
||||
<SettingItem
|
||||
label={dict.settings.theme}
|
||||
description={dict.settings.themeDescription}
|
||||
>
|
||||
<Button
|
||||
id="theme-toggle"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleDarkMode}
|
||||
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
@@ -402,42 +309,35 @@ export function SettingsDialog({
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="drawio-ui">
|
||||
{dict.settings.drawioStyle}
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.drawioStyleDescription}{" "}
|
||||
{drawioUi === "min"
|
||||
? dict.settings.minimal
|
||||
: dict.settings.sketch}
|
||||
</p>
|
||||
</div>
|
||||
{/* Draw.io Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.drawioStyle}
|
||||
description={`${dict.settings.drawioStyleDescription} ${
|
||||
drawioUi === "min"
|
||||
? dict.settings.minimal
|
||||
: dict.settings.sketch
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
id="drawio-ui"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleDrawioUi}
|
||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
||||
>
|
||||
{dict.settings.switchTo}{" "}
|
||||
{drawioUi === "min"
|
||||
? dict.settings.sketch
|
||||
: dict.settings.minimal}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="close-protection">
|
||||
{dict.settings.closeProtection}
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.closeProtectionDescription}
|
||||
</p>
|
||||
</div>
|
||||
{/* Close Protection */}
|
||||
<SettingItem
|
||||
label={dict.settings.closeProtection}
|
||||
description={dict.settings.closeProtectionDescription}
|
||||
>
|
||||
<Switch
|
||||
id="close-protection"
|
||||
checked={closeProtection}
|
||||
@@ -450,14 +350,81 @@ export function SettingsDialog({
|
||||
onCloseProtectionChange?.(checked)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Diagram Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramStyle}
|
||||
description={dict.settings.diagramStyleDescription}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="minimal-style"
|
||||
checked={minimalStyle}
|
||||
onCheckedChange={onMinimalStyleChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{minimalStyle
|
||||
? dict.chat.minimalStyle
|
||||
: dict.chat.styledMode}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border/50">
|
||||
<p className="text-[0.75rem] text-muted-foreground text-center">
|
||||
Version {process.env.APP_VERSION}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{process.env.APP_VERSION}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Github className="h-3 w-3" />
|
||||
GitHub
|
||||
</a>
|
||||
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
||||
"true" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<a
|
||||
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
{dict.nav.about}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<DialogContent className="sm:max-w-lg p-0">
|
||||
<div className="h-80 flex items-center justify-center">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
}
|
||||
>
|
||||
<SettingsContent {...props} />
|
||||
</Suspense>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
157
components/ui/alert-dialog.tsx
Normal file
157
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
191
components/ui/command.tsx
Normal file
191
components/ui/command.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
onMouseEnter={(e) => {
|
||||
// Ensure hover updates selection for visual feedback
|
||||
const item = e.currentTarget
|
||||
item.setAttribute("data-selected", "true")
|
||||
// Deselect siblings
|
||||
const siblings = item.parentElement?.querySelectorAll("[cmdk-item]")
|
||||
siblings?.forEach((sibling) => {
|
||||
if (sibling !== item) {
|
||||
sibling.setAttribute("data-selected", "false")
|
||||
}
|
||||
})
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -38,7 +38,10 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -57,13 +60,32 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
// Base styles
|
||||
"fixed top-[50%] left-[50%] z-50 w-full",
|
||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"grid gap-4 p-6",
|
||||
// Refined visual treatment
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||
// Entry/exit animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
|
||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||
"duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<DialogPrimitive.Close className={cn(
|
||||
"absolute top-4 right-4 rounded-xl p-1.5",
|
||||
"text-muted-foreground/60 hover:text-foreground",
|
||||
"hover:bg-interactive-hover",
|
||||
"transition-all duration-150",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"disabled:pointer-events-none",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
|
||||
)}>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -102,7 +124,10 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
className={cn(
|
||||
"text-xl font-semibold tracking-tight leading-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -115,7 +140,10 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,9 +8,30 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
// Base styles
|
||||
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
|
||||
"border border-border-subtle bg-surface-1",
|
||||
"text-sm text-foreground",
|
||||
// Placeholder
|
||||
"placeholder:text-muted-foreground/60",
|
||||
// Selection
|
||||
"selection:bg-primary selection:text-primary-foreground",
|
||||
// Transitions
|
||||
"transition-all duration-150 ease-out",
|
||||
// Hover state
|
||||
"hover:border-border-default",
|
||||
// Focus state - refined ring
|
||||
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
|
||||
// File input
|
||||
"file:text-foreground file:inline-flex file:h-7 file:border-0",
|
||||
"file:bg-transparent file:text-sm file:font-medium",
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Invalid state
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
"dark:aria-invalid:ring-destructive/40",
|
||||
// Dark mode background
|
||||
"dark:bg-surface-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
48
components/ui/popover.tsx
Normal file
48
components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -5,6 +5,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
||||
|
||||
interface DiagramContextType {
|
||||
@@ -329,7 +330,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
sessionId?: string,
|
||||
) => {
|
||||
try {
|
||||
await fetch("/api/log-save", {
|
||||
await fetch(getApiEndpoint("/api/log-save"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, format, sessionId }),
|
||||
|
||||
@@ -7,6 +7,11 @@ services:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
||||
# Uncomment below for subdirectory deployment
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
environment:
|
||||
# For subdirectory deployment, uncomment and set your path:
|
||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||
depends_on: [drawio]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**AI驱动的图表创建工具 - 对话、绘制、可视化**
|
||||
|
||||
[English](../README.md) | 中文 | [日本語](./README_JA.md)
|
||||
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
[](https://react.dev/)
|
||||
[](https://github.com/sponsors/DayuanJiang)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
</div>
|
||||
|
||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||
|
||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
## 目录
|
||||
@@ -27,14 +29,18 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [示例](#示例)
|
||||
- [功能特性](#功能特性)
|
||||
- [MCP服务器(预览)](#mcp服务器预览)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [快速开始](#快速开始)
|
||||
- [在线试用](#在线试用)
|
||||
- [使用Docker运行(推荐)](#使用docker运行推荐)
|
||||
- [桌面应用](#桌面应用)
|
||||
- [使用Docker运行](#使用docker运行)
|
||||
- [安装](#安装)
|
||||
- [部署](#部署)
|
||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
||||
- [部署到Vercel(推荐)](#部署到vercel推荐)
|
||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
||||
- [多提供商支持](#多提供商支持)
|
||||
- [工作原理](#工作原理)
|
||||
- [项目结构](#项目结构)
|
||||
- [支持与联系](#支持与联系)
|
||||
- [Star历史](#star历史)
|
||||
|
||||
@@ -48,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>动画Transformer连接器</strong><br />
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCP架构图</strong><br />
|
||||
<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 width="50%" valign="top">
|
||||
<strong>AWS架构图</strong><br />
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azure架构图</strong><br />
|
||||
<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 width="50%" valign="top">
|
||||
<strong>猫咪素描</strong><br />
|
||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||
<img src="../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -91,7 +97,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
## MCP服务器(预览)
|
||||
|
||||
> **预览功能**:此功能为实验性功能,可能会有变化。
|
||||
> **预览功能**:此功能为实验性功能,可能不稳定。
|
||||
|
||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||
|
||||
@@ -117,7 +123,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等客户端配置。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -125,41 +131,19 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
|
||||
无需安装!直接在我们的演示站点试用:
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
> 注意:由于访问量较大,演示站点目前使用 minimax-m2 模型。如需获得最佳效果,建议使用 Claude Sonnet 4.5 或 Claude Opus 4.5 自行部署。
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
|
||||
|
||||
### 使用Docker运行(推荐)
|
||||
### 桌面应用
|
||||
|
||||
如果您只想在本地运行,最好的方式是使用Docker。
|
||||
从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用:
|
||||
|
||||
首先,如果您还没有安装Docker,请先安装:[获取Docker](https://docs.docker.com/get-docker/)
|
||||
支持的平台:Windows、macOS、Linux。
|
||||
|
||||
然后运行:
|
||||
### 使用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提供商配置替换环境变量。可用选项请参阅[多提供商支持](#多提供商支持)。
|
||||
|
||||
> **离线部署:** 如果 `embed.diagrams.net` 被屏蔽,请参阅 [离线部署指南](./offline-deployment.md) 了解配置选项。
|
||||
[查看 Docker 指南](./docker.md)
|
||||
|
||||
### 安装
|
||||
|
||||
@@ -168,56 +152,49 @@ docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:l
|
||||
```bash
|
||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||
cd next-ai-draw-io
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. 配置您的AI提供商:
|
||||
|
||||
在根目录创建 `.env.local` 文件:
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
编辑 `.env.local` 并配置您选择的提供商:
|
||||
|
||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||
- 添加您的提供商所需的API密钥
|
||||
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
|
||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
||||
|
||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||
|
||||
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
|
||||
|
||||
4. 运行开发服务器:
|
||||
2. 运行开发服务器:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. 在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。
|
||||
|
||||
## 部署
|
||||
|
||||
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。
|
||||
### 部署到腾讯云EdgeOne Pages
|
||||
|
||||
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。
|
||||
|
||||
直接点击此按钮一键部署:
|
||||
[](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(推荐)
|
||||
|
||||
[](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)了解更多详情。
|
||||
|
||||
或者您可以通过此按钮部署:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
### 部署到Cloudflare Workers
|
||||
|
||||
请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
|
||||
[查看 Cloudflare 部署指南](./cloudflare-deploy.md)
|
||||
|
||||
|
||||
## 多提供商支持
|
||||
|
||||
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- AWS Bedrock(默认)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -227,14 +204,16 @@ npm run dev
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
|
||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
||||
|
||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
||||
|
||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
|
||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
||||
|
||||
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||
|
||||
|
||||
## 工作原理
|
||||
@@ -247,27 +226,11 @@ npm run dev
|
||||
|
||||
图表以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/ # 静态资源包括示例图片
|
||||
```
|
||||
|
||||
## 支持与联系
|
||||
|
||||
**特别感谢[字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
||||
|
||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||
|
||||
如需支持或咨询,请在GitHub仓库上提交issue或联系维护者:
|
||||
236
docs/cn/ai-providers.md
Normal file
236
docs/cn/ai-providers.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
可选的自定义 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
|
||||
```
|
||||
|
||||
## 模型能力要求
|
||||
|
||||
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(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 提供对众多模型的访问
|
||||
267
docs/cn/cloudflare-deploy.md
Normal file
267
docs/cn/cloudflare-deploy.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 部署到 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**。
|
||||
29
docs/cn/docker.md
Normal file
29
docs/cn/docker.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 使用 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) 了解配置选项。
|
||||
38
docs/cn/offline-deployment.md
Normal file
38
docs/cn/offline-deployment.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 离线部署
|
||||
|
||||
通过自托管 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`),因为浏览器无法解析它们。
|
||||
@@ -11,6 +11,15 @@ This guide explains how to configure different AI model providers for next-ai-dr
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Doubao (ByteDance Volcengine)
|
||||
|
||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
AI_MODEL=doubao-seed-1-8-251215 # or other Doubao model
|
||||
```
|
||||
|
||||
### Google Gemini
|
||||
|
||||
```bash
|
||||
@@ -76,6 +85,19 @@ Optional custom endpoint (defaults to the recommended domain):
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -179,7 +201,7 @@ If you only configure **one** provider's API key, the system will automatically
|
||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama, gateway
|
||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||
```
|
||||
|
||||
## Model Capability Requirements
|
||||
267
docs/en/cloudflare-deploy.md
Normal file
267
docs/en/cloudflare-deploy.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Deploy on Cloudflare Workers
|
||||
|
||||
This project can be deployed as a **Cloudflare Worker** using the **OpenNext adapter**, giving you:
|
||||
|
||||
- Global edge deployment
|
||||
- Very low latency
|
||||
- Free `workers.dev` hosting
|
||||
- Full Next.js ISR support via R2 (optional)
|
||||
|
||||
> **Important Windows Note:** OpenNext and Wrangler are **not fully reliable on native Windows**. Recommended options:
|
||||
>
|
||||
> - Use **GitHub Codespaces** (works perfectly)
|
||||
> - OR use **WSL (Linux)**
|
||||
>
|
||||
> Pure Windows builds may fail due to WASM file path issues.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A **Cloudflare account** (free tier works for basic deployment)
|
||||
2. **Node.js 18+**
|
||||
3. **Wrangler CLI** installed (dev dependency is fine):
|
||||
|
||||
```bash
|
||||
npm install -D wrangler
|
||||
```
|
||||
|
||||
4. Cloudflare login:
|
||||
|
||||
```bash
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
> **Note:** A payment method is only required if you want to enable R2 for ISR caching. Basic Workers deployment is free.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Configure environment variables
|
||||
|
||||
Cloudflare uses a different file for local testing.
|
||||
|
||||
### 1) Create `.dev.vars` (for Cloudflare local + deploy)
|
||||
|
||||
```bash
|
||||
cp env.example .dev.vars
|
||||
```
|
||||
|
||||
Fill in your API keys and configuration.
|
||||
|
||||
### 2) Make sure `.env.local` also exists (for regular Next.js dev)
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
Fill in the same values there.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Choose your deployment type
|
||||
|
||||
### Option A: Deploy WITHOUT R2 (Simple, Free)
|
||||
|
||||
If you don't need ISR caching, you can deploy without R2:
|
||||
|
||||
**1. Use simple `open-next.config.ts`:**
|
||||
|
||||
```ts
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||
|
||||
export default defineCloudflareConfig({})
|
||||
```
|
||||
|
||||
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "next-ai-draw-io-worker",
|
||||
"compatibility_date": "2025-12-08",
|
||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "next-ai-draw-io-worker"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Skip to **Step 4**.
|
||||
|
||||
---
|
||||
|
||||
### Option B: Deploy WITH R2 (Full ISR Support)
|
||||
|
||||
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
|
||||
|
||||
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
||||
|
||||
- Go to **Storage & Databases → R2**
|
||||
- Click **Create bucket**
|
||||
- Name it: `next-inc-cache`
|
||||
|
||||
**2. Configure `open-next.config.ts`:**
|
||||
|
||||
```ts
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
incrementalCache: r2IncrementalCache,
|
||||
})
|
||||
```
|
||||
|
||||
**3. Configure `wrangler.jsonc` (with R2):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "next-ai-draw-io-worker",
|
||||
"compatibility_date": "2025-12-08",
|
||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "next-inc-cache"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "next-ai-draw-io-worker"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Register a workers.dev subdomain (first-time only)
|
||||
|
||||
Before your first deployment, you need a workers.dev subdomain.
|
||||
|
||||
**Option 1: Via Cloudflare Dashboard (Recommended)**
|
||||
|
||||
Visit: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
|
||||
|
||||
**Option 2: During deploy**
|
||||
|
||||
When you run `npm run deploy`, Wrangler may prompt:
|
||||
|
||||
```
|
||||
Would you like to register a workers.dev subdomain? (Y/n)
|
||||
```
|
||||
|
||||
Type `Y` and choose a subdomain name.
|
||||
|
||||
> **Note:** In CI/CD or non-interactive environments, the prompt won't appear. Register via the dashboard first.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Deploy to Cloudflare
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
What the script does:
|
||||
|
||||
- Builds the Next.js app
|
||||
- Converts it to a Cloudflare Worker via OpenNext
|
||||
- Uploads static assets
|
||||
- Publishes the Worker
|
||||
|
||||
Your app will be available at:
|
||||
|
||||
```
|
||||
https://<worker-name>.<your-subdomain>.workers.dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common issues & fixes
|
||||
|
||||
### `You need to register a workers.dev subdomain`
|
||||
|
||||
**Cause:** No workers.dev subdomain registered for your account.
|
||||
|
||||
**Fix:** Go to https://dash.cloudflare.com → Workers & Pages → Set up a subdomain.
|
||||
|
||||
---
|
||||
|
||||
### `Please enable R2 through the Cloudflare Dashboard`
|
||||
|
||||
**Cause:** R2 is configured in wrangler.jsonc but not enabled on your account.
|
||||
|
||||
**Fix:** Either enable R2 (requires payment method) or use Option A (deploy without R2).
|
||||
|
||||
---
|
||||
|
||||
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
|
||||
|
||||
**Cause:** `r2_buckets` is missing from `wrangler.jsonc`.
|
||||
|
||||
**Fix:** Add the `r2_buckets` section or switch to Option A (without R2).
|
||||
|
||||
---
|
||||
|
||||
### `Can't set compatibility date in the future`
|
||||
|
||||
**Cause:** `compatibility_date` in wrangler config is set to a future date.
|
||||
|
||||
**Fix:** Change `compatibility_date` to today or an earlier date.
|
||||
|
||||
---
|
||||
|
||||
### Windows error: `resvg.wasm?module` (ENOENT)
|
||||
|
||||
**Cause:** Windows filenames cannot include `?`, but a wasm asset uses `?module` in its filename.
|
||||
|
||||
**Fix:** Build/deploy on Linux (WSL, Codespaces, or CI).
|
||||
|
||||
---
|
||||
|
||||
## Optional: Preview locally
|
||||
|
||||
Preview the Worker locally before deploying:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Feature | Without R2 | With R2 |
|
||||
|---------|------------|---------|
|
||||
| Cost | Free | Requires payment method |
|
||||
| ISR Caching | No | Yes |
|
||||
| Static Pages | Yes | Yes |
|
||||
| API Routes | Yes | Yes |
|
||||
| Setup Complexity | Simple | Moderate |
|
||||
|
||||
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
|
||||
29
docs/en/docker.md
Normal file
29
docs/en/docker.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
| Scenario | URL Value |
|
||||
|----------|-----------|
|
||||
| Localhost | `http://localhost:8080` |
|
||||
| Remote/Server | `http://YOUR_SERVER_IP:8080` or `https://drawio.your-domain.com` |
|
||||
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
|
||||
|
||||
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
||||
|
||||
[English](../README.md) | [中文](./README_CN.md) | 日本語
|
||||
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
[](https://react.dev/)
|
||||
[](https://github.com/sponsors/DayuanJiang)
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
</div>
|
||||
|
||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||
|
||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
## 目次
|
||||
@@ -27,14 +29,18 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [例](#例)
|
||||
- [機能](#機能)
|
||||
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [はじめに](#はじめに)
|
||||
- [オンラインで試す](#オンラインで試す)
|
||||
- [Dockerで実行(推奨)](#dockerで実行推奨)
|
||||
- [デスクトップアプリケーション](#デスクトップアプリケーション)
|
||||
- [Dockerで実行](#dockerで実行)
|
||||
- [インストール](#インストール)
|
||||
- [デプロイ](#デプロイ)
|
||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
||||
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨)
|
||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||
- [仕組み](#仕組み)
|
||||
- [プロジェクト構造](#プロジェクト構造)
|
||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||
- [スター履歴](#スター履歴)
|
||||
|
||||
@@ -48,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCPアーキテクチャ図</strong><br />
|
||||
<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 width="50%" valign="top">
|
||||
<strong>AWSアーキテクチャ図</strong><br />
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azureアーキテクチャ図</strong><br />
|
||||
<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 width="50%" valign="top">
|
||||
<strong>猫のスケッチ</strong><br />
|
||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||
<img src="../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -91,7 +97,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
## MCPサーバー(プレビュー)
|
||||
|
||||
> **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
|
||||
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
||||
|
||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||
|
||||
@@ -117,7 +123,7 @@ Claudeにダイアグラムの作成を依頼:
|
||||
|
||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||
|
||||
詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||
|
||||
## はじめに
|
||||
|
||||
@@ -125,41 +131,19 @@ Claudeにダイアグラムの作成を依頼:
|
||||
|
||||
インストール不要!デモサイトで直接お試しください:
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
> 注意:アクセス数が多いため、デモサイトでは現在 minimax-m2 モデルを使用しています。最高の結果を得るには、Claude Sonnet 4.5 または Claude Opus 4.5 でのセルフホスティングをお勧めします。
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||
|
||||
### Dockerで実行(推奨)
|
||||
### デスクトップアプリケーション
|
||||
|
||||
ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
|
||||
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください:
|
||||
|
||||
まず、Dockerをインストールしていない場合はインストールしてください:[Dockerを入手](https://docs.docker.com/get-docker/)
|
||||
対応プラットフォーム:Windows、macOS、Linux。
|
||||
|
||||
次に実行:
|
||||
### 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プロバイダー設定に置き換えてください。利用可能なオプションについては[マルチプロバイダーサポート](#マルチプロバイダーサポート)を参照してください。
|
||||
|
||||
> **オフラインデプロイ:** `embed.diagrams.net` がブロックされている場合は、[オフラインデプロイガイド](./offline-deployment.md) で設定オプションをご確認ください。
|
||||
[Docker ガイドを参照](./docker.md)
|
||||
|
||||
### インストール
|
||||
|
||||
@@ -168,56 +152,50 @@ docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:l
|
||||
```bash
|
||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||
cd next-ai-draw-io
|
||||
```
|
||||
|
||||
2. 依存関係をインストール:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. AIプロバイダーを設定:
|
||||
|
||||
ルートディレクトリに`.env.local`ファイルを作成:
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
`.env.local`を編集して選択したプロバイダーを設定:
|
||||
|
||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- `AI_MODEL`を使用する特定のモデルに設定
|
||||
- プロバイダーに必要なAPIキーを追加
|
||||
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
|
||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
||||
|
||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||
|
||||
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
||||
|
||||
4. 開発サーバーを起動:
|
||||
2. 開発サーバーを起動:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. ブラウザで[http://localhost:3000](http://localhost:3000)を開いてアプリケーションを確認。
|
||||
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。
|
||||
|
||||
## デプロイ
|
||||
|
||||
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
|
||||
### EdgeOne Pagesへのデプロイ
|
||||
|
||||
[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。
|
||||
|
||||
このボタンでデプロイ:
|
||||
|
||||
[](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へのデプロイ(推奨)
|
||||
|
||||
[](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)をご覧ください。
|
||||
|
||||
または、このボタンでデプロイできます:
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
### Cloudflare Workersへのデプロイ
|
||||
|
||||
ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
||||
[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md)
|
||||
|
||||
|
||||
## マルチプロバイダーサポート
|
||||
|
||||
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- AWS Bedrock(デフォルト)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -227,14 +205,16 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
|
||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
||||
|
||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
||||
|
||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
|
||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
||||
|
||||
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
|
||||
|
||||
## 仕組み
|
||||
@@ -247,27 +227,11 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
ダイアグラムは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/ # サンプル画像を含む静的アセット
|
||||
```
|
||||
|
||||
## サポート&お問い合わせ
|
||||
|
||||
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
||||
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||
|
||||
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください:
|
||||
236
docs/ja/ai-providers.md
Normal file
236
docs/ja/ai-providers.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## モデル性能要件
|
||||
|
||||
このタスクは、厳密なフォーマット制約(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 で多数のモデルへのアクセスを提供します
|
||||
267
docs/ja/cloudflare-deploy.md
Normal file
267
docs/ja/cloudflare-deploy.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 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 を有効にする(支払い方法が必要)か、オプション 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 あり** を選んでください。
|
||||
29
docs/ja/docker.md
Normal file
29
docs/ja/docker.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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)を参照してください。
|
||||
38
docs/ja/offline-deployment.md
Normal file
38
docs/ja/offline-deployment.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# オフラインデプロイ
|
||||
|
||||
`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 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.{shape};fillColor=#ED7100;strokeColor=#ffffff;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;" vertex="1" parent="1">
|
||||
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
||||
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
270
edge-functions/api/edgeai/chat/completions.ts
Normal file
270
edge-functions/api/edgeai/chat/completions.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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 }: 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
5
edgeone.json
Normal file
5
edgeone.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"nodeFunctionsConfig": {
|
||||
"maxDuration": 120
|
||||
}
|
||||
}
|
||||
96
electron/electron-builder.yml
Normal file
96
electron/electron-builder.yml
Normal file
@@ -0,0 +1,96 @@
|
||||
appId: com.nextaidrawio.app
|
||||
productName: Next AI Draw.io
|
||||
copyright: Copyright © 2024 Next AI Draw.io
|
||||
electronVersion: 39.2.7
|
||||
|
||||
directories:
|
||||
output: release
|
||||
buildResources: resources
|
||||
|
||||
afterPack: ./scripts/afterPack.cjs
|
||||
|
||||
files:
|
||||
- dist-electron/**/*
|
||||
- "!node_modules"
|
||||
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
|
||||
extraResources:
|
||||
# Copy prepared standalone directory (includes node_modules)
|
||||
- from: electron-standalone/
|
||||
to: standalone/
|
||||
# Copy icon for runtime use (Windows/Linux)
|
||||
- from: resources/icon.png
|
||||
to: icon.png
|
||||
|
||||
# macOS configuration
|
||||
mac:
|
||||
category: public.app-category.productivity
|
||||
icon: resources/icon.png
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
entitlements: resources/entitlements.mac.plist
|
||||
entitlementsInherit: resources/entitlements.mac.plist
|
||||
|
||||
dmg:
|
||||
contents:
|
||||
- x: 130
|
||||
y: 220
|
||||
- x: 410
|
||||
y: 220
|
||||
type: link
|
||||
path: /Applications
|
||||
window:
|
||||
width: 540
|
||||
height: 380
|
||||
|
||||
# Windows configuration
|
||||
win:
|
||||
icon: resources/icon.png
|
||||
target:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: portable
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
deleteAppDataOnUninstall: false
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
|
||||
# Linux configuration
|
||||
linux:
|
||||
icon: resources/icon.png
|
||||
category: Office
|
||||
maintainer: Next AI Draw.io <nextaidrawio@users.noreply.github.com>
|
||||
target:
|
||||
- target: AppImage
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: deb
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
|
||||
# Publish configuration (optional)
|
||||
publish:
|
||||
provider: github
|
||||
releaseType: release
|
||||
74
electron/electron.d.ts
vendored
Normal file
74
electron/electron.d.ts
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Type declarations for Electron API exposed via preload script
|
||||
*/
|
||||
|
||||
/** Configuration preset interface */
|
||||
interface ConfigPreset {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
config: {
|
||||
AI_PROVIDER?: string
|
||||
AI_MODEL?: string
|
||||
AI_API_KEY?: string
|
||||
AI_BASE_URL?: string
|
||||
TEMPERATURE?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of applying a preset */
|
||||
interface ApplyPresetResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Main window Electron API */
|
||||
electronAPI?: {
|
||||
/** Current platform (darwin, win32, linux) */
|
||||
platform: NodeJS.Platform
|
||||
/** Whether running in Electron environment */
|
||||
isElectron: boolean
|
||||
/** Get application version */
|
||||
getVersion: () => Promise<string>
|
||||
/** Minimize the window */
|
||||
minimize: () => void
|
||||
/** Maximize/restore the window */
|
||||
maximize: () => void
|
||||
/** Close the window */
|
||||
close: () => void
|
||||
/** Open file dialog and return file path */
|
||||
openFile: () => Promise<string | null>
|
||||
/** Save data to file via save dialog */
|
||||
saveFile: (data: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
settingsAPI?: {
|
||||
/** Get all configuration presets */
|
||||
getPresets: () => Promise<ConfigPreset[]>
|
||||
/** Get current preset ID */
|
||||
getCurrentPresetId: () => Promise<string | null>
|
||||
/** Get current preset */
|
||||
getCurrentPreset: () => Promise<ConfigPreset | null>
|
||||
/** Save (create or update) a preset */
|
||||
savePreset: (preset: {
|
||||
id?: string
|
||||
name: string
|
||||
config: Record<string, string | undefined>
|
||||
}) => Promise<ConfigPreset>
|
||||
/** Delete a preset */
|
||||
deletePreset: (id: string) => Promise<boolean>
|
||||
/** Apply a preset (sets environment variables and restarts server) */
|
||||
applyPreset: (id: string) => Promise<ApplyPresetResult>
|
||||
/** Close settings window */
|
||||
close: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ConfigPreset, ApplyPresetResult }
|
||||
241
electron/main/app-menu.ts
Normal file
241
electron/main/app-menu.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
Menu,
|
||||
type MenuItemConstructorOptions,
|
||||
shell,
|
||||
} from "electron"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
getAllPresets,
|
||||
getCurrentPresetId,
|
||||
setCurrentPreset,
|
||||
} from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { showSettingsWindow } from "./settings-window"
|
||||
|
||||
/**
|
||||
* Build and set the application menu
|
||||
*/
|
||||
export function buildAppMenu(): void {
|
||||
const template = getMenuTemplate()
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the menu (call this when presets change)
|
||||
*/
|
||||
export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the menu template
|
||||
*/
|
||||
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
const template: MenuItemConstructorOptions[] = []
|
||||
|
||||
// macOS app menu
|
||||
if (isMac) {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Settings...",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// File menu
|
||||
template.push({
|
||||
label: "File",
|
||||
submenu: [
|
||||
...(isMac
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: "Settings",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
]),
|
||||
isMac ? { role: "close" } : { role: "quit" },
|
||||
],
|
||||
})
|
||||
|
||||
// Edit menu
|
||||
template.push({
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
role: "pasteAndMatchStyle",
|
||||
} as MenuItemConstructorOptions,
|
||||
{ role: "delete" } as MenuItemConstructorOptions,
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||
]
|
||||
: [
|
||||
{ role: "delete" } as MenuItemConstructorOptions,
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
// View menu
|
||||
template.push({
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
})
|
||||
|
||||
// Configuration menu with presets
|
||||
template.push(buildConfigMenu())
|
||||
|
||||
// Window menu
|
||||
template.push({
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
{ role: "front" } as MenuItemConstructorOptions,
|
||||
]
|
||||
: [{ role: "close" } as MenuItemConstructorOptions]),
|
||||
],
|
||||
})
|
||||
|
||||
// Help menu
|
||||
template.push({
|
||||
label: "Help",
|
||||
submenu: [
|
||||
{
|
||||
label: "Documentation",
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
"https://github.com/dayuanjiang/next-ai-draw-io",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Report Issue",
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Configuration menu with presets
|
||||
*/
|
||||
function buildConfigMenu(): MenuItemConstructorOptions {
|
||||
const presets = getAllPresets()
|
||||
const currentPresetId = getCurrentPresetId()
|
||||
|
||||
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
|
||||
label: preset.name,
|
||||
type: "radio",
|
||||
checked: preset.id === currentPresetId,
|
||||
click: async () => {
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(preset.id)
|
||||
|
||||
if (env) {
|
||||
try {
|
||||
await restartNextServer()
|
||||
rebuildAppMenu() // Rebuild menu to update checkmarks
|
||||
} catch (error) {
|
||||
console.error("Failed to restart server:", error)
|
||||
|
||||
// Revert to previous preset on failure
|
||||
if (previousPresetId) {
|
||||
applyPresetToEnv(previousPresetId)
|
||||
} else {
|
||||
setCurrentPreset(null)
|
||||
}
|
||||
|
||||
// Rebuild menu to restore previous checkmark state
|
||||
rebuildAppMenu()
|
||||
|
||||
// Show error dialog to notify user
|
||||
dialog.showErrorBox(
|
||||
"Configuration Error",
|
||||
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
label: "Configuration",
|
||||
submenu: [
|
||||
...(presetItems.length > 0
|
||||
? [
|
||||
{ label: "Switch Preset", enabled: false },
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
...presetItems,
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label:
|
||||
presetItems.length > 0
|
||||
? "Manage Presets..."
|
||||
: "Add Configuration Preset...",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
460
electron/main/config-manager.ts
Normal file
460
electron/main/config-manager.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app, safeStorage } from "electron"
|
||||
|
||||
/**
|
||||
* Fields that contain sensitive data and should be encrypted
|
||||
*/
|
||||
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
|
||||
|
||||
/**
|
||||
* Prefix to identify encrypted values
|
||||
*/
|
||||
const ENCRYPTED_PREFIX = "encrypted:"
|
||||
|
||||
/**
|
||||
* Check if safeStorage encryption is available
|
||||
*/
|
||||
function isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Track if we've already warned about plaintext storage
|
||||
*/
|
||||
let hasWarnedAboutPlaintext = false
|
||||
|
||||
/**
|
||||
* Encrypt a sensitive value using safeStorage
|
||||
* Warns if encryption is not available (API key stored in plaintext)
|
||||
*/
|
||||
function encryptValue(value: string): string {
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (!isEncryptionAvailable()) {
|
||||
if (!hasWarnedAboutPlaintext) {
|
||||
console.warn(
|
||||
"⚠️ SECURITY WARNING: safeStorage not available. " +
|
||||
"API keys will be stored in PLAINTEXT. " +
|
||||
"On Linux, install gnome-keyring or similar for secure storage.",
|
||||
)
|
||||
hasWarnedAboutPlaintext = true
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(value)
|
||||
return ENCRYPTED_PREFIX + encrypted.toString("base64")
|
||||
} catch (error) {
|
||||
console.error("Encryption failed:", error)
|
||||
// Fail secure: don't store if encryption fails
|
||||
throw new Error(
|
||||
"Failed to encrypt API key. Cannot securely store credentials.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a sensitive value using safeStorage
|
||||
* Returns the original value if it's not encrypted or decryption fails
|
||||
*/
|
||||
function decryptValue(value: string): string {
|
||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
return value
|
||||
}
|
||||
if (!isEncryptionAvailable()) {
|
||||
console.warn(
|
||||
"Cannot decrypt value: safeStorage encryption is not available",
|
||||
)
|
||||
return value
|
||||
}
|
||||
try {
|
||||
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
|
||||
const buffer = Buffer.from(base64Data, "base64")
|
||||
return safeStorage.decryptString(buffer)
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt value:", error)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive fields in a config object
|
||||
*/
|
||||
function encryptConfig(
|
||||
config: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
const encrypted = { ...config }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (encrypted[field]) {
|
||||
encrypted[field] = encryptValue(encrypted[field] as string)
|
||||
}
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields in a config object
|
||||
*/
|
||||
function decryptConfig(
|
||||
config: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
const decrypted = { ...config }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (decrypted[field]) {
|
||||
decrypted[field] = decryptValue(decrypted[field] as string)
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration preset interface
|
||||
*/
|
||||
export interface ConfigPreset {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
config: {
|
||||
AI_PROVIDER?: string
|
||||
AI_MODEL?: string
|
||||
AI_API_KEY?: string
|
||||
AI_BASE_URL?: string
|
||||
TEMPERATURE?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration file structure
|
||||
*/
|
||||
interface ConfigPresetsFile {
|
||||
version: 1
|
||||
currentPresetId: string | null
|
||||
presets: ConfigPreset[]
|
||||
}
|
||||
|
||||
const CONFIG_FILE_NAME = "config-presets.json"
|
||||
|
||||
/**
|
||||
* Get the path to the config file
|
||||
*/
|
||||
function getConfigFilePath(): string {
|
||||
const userDataPath = app.getPath("userData")
|
||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load presets from the config file
|
||||
* Decrypts sensitive fields automatically
|
||||
*/
|
||||
export function loadPresets(): ConfigPresetsFile {
|
||||
const configPath = getConfigFilePath()
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const data = JSON.parse(content) as ConfigPresetsFile
|
||||
|
||||
// Decrypt sensitive fields in each preset
|
||||
data.presets = data.presets.map((preset) => ({
|
||||
...preset,
|
||||
config: decryptConfig(preset.config) as ConfigPreset["config"],
|
||||
}))
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to load config presets:", error)
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save presets to the config file
|
||||
* Encrypts sensitive fields automatically
|
||||
*/
|
||||
export function savePresets(data: ConfigPresetsFile): void {
|
||||
const configPath = getConfigFilePath()
|
||||
const userDataPath = app.getPath("userData")
|
||||
|
||||
// Ensure the directory exists
|
||||
if (!existsSync(userDataPath)) {
|
||||
mkdirSync(userDataPath, { recursive: true })
|
||||
}
|
||||
|
||||
// Encrypt sensitive fields before saving
|
||||
const dataToSave: ConfigPresetsFile = {
|
||||
...data,
|
||||
presets: data.presets.map((preset) => ({
|
||||
...preset,
|
||||
config: encryptConfig(preset.config) as ConfigPreset["config"],
|
||||
})),
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||
} catch (error) {
|
||||
console.error("Failed to save config presets:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all presets
|
||||
*/
|
||||
export function getAllPresets(): ConfigPreset[] {
|
||||
const data = loadPresets()
|
||||
return data.presets
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset ID
|
||||
*/
|
||||
export function getCurrentPresetId(): string | null {
|
||||
const data = loadPresets()
|
||||
return data.currentPresetId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset
|
||||
*/
|
||||
export function getCurrentPreset(): ConfigPreset | null {
|
||||
const data = loadPresets()
|
||||
if (!data.currentPresetId) {
|
||||
return null
|
||||
}
|
||||
return data.presets.find((p) => p.id === data.currentPresetId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new preset
|
||||
*/
|
||||
export function createPreset(
|
||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
|
||||
): ConfigPreset {
|
||||
const data = loadPresets()
|
||||
const now = Date.now()
|
||||
|
||||
const newPreset: ConfigPreset = {
|
||||
id: randomUUID(),
|
||||
name: preset.name,
|
||||
config: preset.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
data.presets.push(newPreset)
|
||||
savePresets(data)
|
||||
|
||||
return newPreset
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing preset
|
||||
*/
|
||||
export function updatePreset(
|
||||
id: string,
|
||||
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
|
||||
): ConfigPreset | null {
|
||||
const data = loadPresets()
|
||||
const index = data.presets.findIndex((p) => p.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedPreset: ConfigPreset = {
|
||||
...data.presets[index],
|
||||
...updates,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
data.presets[index] = updatedPreset
|
||||
savePresets(data)
|
||||
|
||||
return updatedPreset
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a preset
|
||||
*/
|
||||
export function deletePreset(id: string): boolean {
|
||||
const data = loadPresets()
|
||||
const index = data.presets.findIndex((p) => p.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
data.presets.splice(index, 1)
|
||||
|
||||
// Clear current preset if it was deleted
|
||||
if (data.currentPresetId === id) {
|
||||
data.currentPresetId = null
|
||||
}
|
||||
|
||||
savePresets(data)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current preset
|
||||
*/
|
||||
export function setCurrentPreset(id: string | null): boolean {
|
||||
const data = loadPresets()
|
||||
|
||||
if (id !== null) {
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
if (!preset) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
|
||||
*/
|
||||
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
||||
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
|
||||
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
|
||||
google: {
|
||||
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
baseUrl: "GOOGLE_BASE_URL",
|
||||
},
|
||||
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
|
||||
openrouter: {
|
||||
apiKey: "OPENROUTER_API_KEY",
|
||||
baseUrl: "OPENROUTER_BASE_URL",
|
||||
},
|
||||
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
|
||||
siliconflow: {
|
||||
apiKey: "SILICONFLOW_API_KEY",
|
||||
baseUrl: "SILICONFLOW_BASE_URL",
|
||||
},
|
||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||
// bedrock and ollama don't use API keys in the same way
|
||||
bedrock: { apiKey: "", baseUrl: "" },
|
||||
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preset environment variables to the current process
|
||||
* Returns the environment variables that were applied
|
||||
*/
|
||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||
const data = loadPresets()
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
|
||||
if (!preset) {
|
||||
return null
|
||||
}
|
||||
|
||||
const appliedEnv: Record<string, string> = {}
|
||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||
|
||||
for (const [key, value] of Object.entries(preset.config)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
// Map generic AI_API_KEY to provider-specific key
|
||||
if (
|
||||
key === "AI_API_KEY" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
||||
if (providerApiKey) {
|
||||
process.env[providerApiKey] = value
|
||||
appliedEnv[providerApiKey] = value
|
||||
}
|
||||
}
|
||||
// Map generic AI_BASE_URL to provider-specific key
|
||||
else if (
|
||||
key === "AI_BASE_URL" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
||||
if (providerBaseUrl) {
|
||||
process.env[providerBaseUrl] = value
|
||||
appliedEnv[providerBaseUrl] = value
|
||||
}
|
||||
}
|
||||
// Apply other env vars directly
|
||||
else {
|
||||
process.env[key] = value
|
||||
appliedEnv[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set as current preset
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
|
||||
return appliedEnv
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables from current preset
|
||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
||||
*/
|
||||
export function getCurrentPresetEnv(): Record<string, string> {
|
||||
const preset = getCurrentPreset()
|
||||
if (!preset) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||
|
||||
for (const [key, value] of Object.entries(preset.config)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
// Map generic AI_API_KEY to provider-specific key
|
||||
if (
|
||||
key === "AI_API_KEY" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
||||
if (providerApiKey) {
|
||||
env[providerApiKey] = value
|
||||
}
|
||||
}
|
||||
// Map generic AI_BASE_URL to provider-specific key
|
||||
else if (
|
||||
key === "AI_BASE_URL" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
||||
if (providerBaseUrl) {
|
||||
env[providerBaseUrl] = value
|
||||
}
|
||||
}
|
||||
// Apply other env vars directly
|
||||
else {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
67
electron/main/env-loader.ts
Normal file
67
electron/main/env-loader.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
|
||||
/**
|
||||
* Load environment variables from .env file
|
||||
* Searches multiple locations in priority order
|
||||
*/
|
||||
export function loadEnvFile(): void {
|
||||
const possiblePaths = [
|
||||
// Next to the executable (for portable installations)
|
||||
path.join(path.dirname(app.getPath("exe")), ".env"),
|
||||
// User data directory (persists across updates)
|
||||
path.join(app.getPath("userData"), ".env"),
|
||||
// Development: project root
|
||||
path.join(app.getAppPath(), ".env.local"),
|
||||
path.join(app.getAppPath(), ".env"),
|
||||
]
|
||||
|
||||
for (const envPath of possiblePaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
console.log(`Loading environment from: ${envPath}`)
|
||||
loadEnvFromFile(envPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log("No .env file found, using system environment variables")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and load environment variables from a file
|
||||
*/
|
||||
function loadEnvFromFile(filePath: string): void {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
const lines = content.split("\n")
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
|
||||
const equalIndex = trimmed.indexOf("=")
|
||||
if (equalIndex === -1) continue
|
||||
|
||||
const key = trimmed.slice(0, equalIndex).trim()
|
||||
let value = trimmed.slice(equalIndex + 1).trim()
|
||||
|
||||
// Remove surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
// Don't override existing environment variables
|
||||
if (!(key in process.env)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load env file ${filePath}:`, error)
|
||||
}
|
||||
}
|
||||
105
electron/main/index.ts
Normal file
105
electron/main/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { getCurrentPresetEnv } from "./config-manager"
|
||||
import { loadEnvFile } from "./env-loader"
|
||||
import { registerIpcHandlers } from "./ipc-handlers"
|
||||
import { startNextServer, stopNextServer } from "./next-server"
|
||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||
import { createWindow, getMainWindow } from "./window-manager"
|
||||
|
||||
// Single instance lock
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
const mainWindow = getMainWindow()
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Load environment variables from .env files
|
||||
loadEnvFile()
|
||||
|
||||
// Apply saved preset environment variables (overrides .env)
|
||||
const presetEnv = getCurrentPresetEnv()
|
||||
for (const [key, value] of Object.entries(presetEnv)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
let serverUrl: string | null = null
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers()
|
||||
registerSettingsWindowHandlers()
|
||||
|
||||
// Build application menu
|
||||
buildAppMenu()
|
||||
|
||||
try {
|
||||
if (isDev) {
|
||||
// Development: use the dev server URL
|
||||
serverUrl =
|
||||
process.env.ELECTRON_DEV_URL || "http://localhost:6002"
|
||||
console.log(`Development mode: connecting to ${serverUrl}`)
|
||||
} else {
|
||||
// Production: start Next.js standalone server
|
||||
serverUrl = await startNextServer()
|
||||
}
|
||||
|
||||
// Create main window
|
||||
createWindow(serverUrl)
|
||||
} catch (error) {
|
||||
console.error("Failed to start application:", error)
|
||||
dialog.showErrorBox(
|
||||
"Startup Error",
|
||||
`Failed to start the application: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
app.quit()
|
||||
}
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
if (serverUrl) {
|
||||
createWindow(serverUrl)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
stopNextServer()
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
stopNextServer()
|
||||
})
|
||||
|
||||
// Open external links in default browser
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
// Allow diagrams.net iframe
|
||||
if (
|
||||
url.includes("diagrams.net") ||
|
||||
url.includes("draw.io") ||
|
||||
url.startsWith("http://localhost")
|
||||
) {
|
||||
return { action: "allow" }
|
||||
}
|
||||
// Open other links in external browser
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
shell.openExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
})
|
||||
}
|
||||
212
electron/main/ipc-handlers.ts
Normal file
212
electron/main/ipc-handlers.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
type ConfigPreset,
|
||||
createPreset,
|
||||
deletePreset,
|
||||
getAllPresets,
|
||||
getCurrentPreset,
|
||||
getCurrentPresetId,
|
||||
setCurrentPreset,
|
||||
updatePreset,
|
||||
} from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
|
||||
/**
|
||||
* Allowed configuration keys for presets
|
||||
* This whitelist prevents arbitrary environment variable injection
|
||||
*/
|
||||
const ALLOWED_CONFIG_KEYS = new Set([
|
||||
"AI_PROVIDER",
|
||||
"AI_MODEL",
|
||||
"AI_API_KEY",
|
||||
"AI_BASE_URL",
|
||||
"TEMPERATURE",
|
||||
])
|
||||
|
||||
/**
|
||||
* Sanitize preset config to only include allowed keys
|
||||
*/
|
||||
function sanitizePresetConfig(
|
||||
config: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
const sanitized: Record<string, string | undefined> = {}
|
||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
||||
if (key in config && typeof config[key] === "string") {
|
||||
sanitized[key] = config[key]
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(): void {
|
||||
// ==================== App Info ====================
|
||||
|
||||
ipcMain.handle("get-version", () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
// ==================== Window Controls ====================
|
||||
|
||||
ipcMain.on("window-minimize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.minimize()
|
||||
})
|
||||
|
||||
ipcMain.on("window-maximize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win?.isMaximized()) {
|
||||
win.unmaximize()
|
||||
} else {
|
||||
win?.maximize()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on("window-close", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.close()
|
||||
})
|
||||
|
||||
// ==================== File Dialogs ====================
|
||||
|
||||
ipcMain.handle("dialog-open-file", async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return null
|
||||
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
const fs = await import("node:fs/promises")
|
||||
try {
|
||||
const content = await fs.readFile(result.filePaths[0], "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
console.error("Failed to read file:", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog-save-file", async (event, data: string) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return false
|
||||
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [
|
||||
{ name: "Draw.io Files", extensions: ["drawio"] },
|
||||
{ name: "XML Files", extensions: ["xml"] },
|
||||
],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fs = await import("node:fs/promises")
|
||||
try {
|
||||
await fs.writeFile(result.filePath, data, "utf-8")
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to save file:", error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Config Presets ====================
|
||||
|
||||
ipcMain.handle("config-presets:get-all", () => {
|
||||
return getAllPresets()
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:get-current", () => {
|
||||
return getCurrentPreset()
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:get-current-id", () => {
|
||||
return getCurrentPresetId()
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"config-presets:save",
|
||||
(
|
||||
_event,
|
||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||
id?: string
|
||||
},
|
||||
) => {
|
||||
// Validate preset name
|
||||
if (typeof preset.name !== "string" || !preset.name.trim()) {
|
||||
throw new Error("Invalid preset name")
|
||||
}
|
||||
|
||||
// Sanitize config to only allow whitelisted keys
|
||||
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
|
||||
|
||||
if (preset.id) {
|
||||
// Update existing preset
|
||||
return updatePreset(preset.id, {
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
}
|
||||
// Create new preset
|
||||
return createPreset({
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("config-presets:delete", (_event, id: string) => {
|
||||
return deletePreset(id)
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
|
||||
const env = applyPresetToEnv(id)
|
||||
if (!env) {
|
||||
return { success: false, error: "Preset not found" }
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
|
||||
if (isDev) {
|
||||
// In development mode, the config file change will trigger
|
||||
// the file watcher in electron-dev.mjs to restart Next.js
|
||||
// We just need to save the preset (already done in applyPresetToEnv)
|
||||
return { success: true, env, devMode: true }
|
||||
}
|
||||
|
||||
// Production mode: restart the Next.js server to apply new environment variables
|
||||
try {
|
||||
await restartNextServer()
|
||||
return { success: true, env }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to restart server",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"config-presets:set-current",
|
||||
(_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
},
|
||||
)
|
||||
}
|
||||
161
electron/main/next-server.ts
Normal file
161
electron/main/next-server.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app, type UtilityProcess, utilityProcess } from "electron"
|
||||
import {
|
||||
findAvailablePort,
|
||||
getAllocatedPort,
|
||||
getServerUrl,
|
||||
isPortAvailable,
|
||||
} from "./port-manager"
|
||||
|
||||
let serverProcess: UtilityProcess | null = null
|
||||
|
||||
/**
|
||||
* Get the path to the standalone server resources
|
||||
* In packaged app: resources/standalone
|
||||
* In development: .next/standalone
|
||||
*/
|
||||
function getResourcePath(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "standalone")
|
||||
}
|
||||
return path.join(app.getAppPath(), ".next", "standalone")
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the server to be ready by polling the health endpoint
|
||||
*/
|
||||
async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok || response.status < 500) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error(`Server startup timeout after ${timeout}ms`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Next.js standalone server using Electron's utilityProcess
|
||||
* This API is designed for running Node.js code in the background
|
||||
*/
|
||||
export async function startNextServer(): Promise<string> {
|
||||
const resourcePath = getResourcePath()
|
||||
const serverPath = path.join(resourcePath, "server.js")
|
||||
|
||||
console.log(`Starting Next.js server from: ${resourcePath}`)
|
||||
console.log(`Server script path: ${serverPath}`)
|
||||
|
||||
// Verify server script exists before attempting to start
|
||||
if (!existsSync(serverPath)) {
|
||||
throw new Error(
|
||||
`Server script not found at ${serverPath}. ` +
|
||||
"Please ensure the app was built correctly with 'npm run build'.",
|
||||
)
|
||||
}
|
||||
|
||||
// Find an available port (random in production, fixed in development)
|
||||
const port = await findAvailablePort()
|
||||
console.log(`Using port: ${port}`)
|
||||
|
||||
// Set up environment variables
|
||||
const env: Record<string, string> = {
|
||||
NODE_ENV: "production",
|
||||
PORT: String(port),
|
||||
HOSTNAME: "localhost",
|
||||
}
|
||||
|
||||
// Set cache directory to a writable location (user's app data folder)
|
||||
// This is necessary because the packaged app might be on a read-only volume
|
||||
if (app.isPackaged) {
|
||||
const cacheDir = path.join(app.getPath("userData"), "cache")
|
||||
env.NEXT_CACHE_DIR = cacheDir
|
||||
}
|
||||
|
||||
// Copy existing environment variables
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !env[key]) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Use Electron's utilityProcess API for running Node.js in background
|
||||
// This is the recommended way to run Node.js code in Electron
|
||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||
cwd: resourcePath,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
})
|
||||
|
||||
serverProcess.stdout?.on("data", (data) => {
|
||||
console.log(`[Next.js] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
serverProcess.stderr?.on("data", (data) => {
|
||||
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
console.log(`Next.js server exited with code ${code}`)
|
||||
serverProcess = null
|
||||
})
|
||||
|
||||
const url = getServerUrl()
|
||||
await waitForServer(url)
|
||||
console.log(`Next.js server started at ${url}`)
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Next.js server process
|
||||
*/
|
||||
export function stopNextServer(): void {
|
||||
if (serverProcess) {
|
||||
console.log("Stopping Next.js server...")
|
||||
serverProcess.kill()
|
||||
serverProcess = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the server to fully stop
|
||||
*/
|
||||
async function waitForServerStop(timeout = 5000): Promise<void> {
|
||||
const port = getAllocatedPort()
|
||||
if (port === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
const available = await isPortAvailable(port)
|
||||
if (available) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
console.warn("Server stop timeout, port may still be in use")
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the Next.js server with new environment variables
|
||||
*/
|
||||
export async function restartNextServer(): Promise<string> {
|
||||
console.log("Restarting Next.js server...")
|
||||
|
||||
// Stop the current server
|
||||
stopNextServer()
|
||||
|
||||
// Wait for the port to be released
|
||||
await waitForServerStop()
|
||||
|
||||
// Start the server again
|
||||
return startNextServer()
|
||||
}
|
||||
117
electron/main/port-manager.ts
Normal file
117
electron/main/port-manager.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import net from "node:net"
|
||||
import { app } from "electron"
|
||||
|
||||
/**
|
||||
* Port configuration
|
||||
* Using fixed ports to preserve localStorage across restarts
|
||||
* (localStorage is origin-specific, so changing ports loses all saved data)
|
||||
*/
|
||||
const PORT_CONFIG = {
|
||||
// Development mode uses fixed port for hot reload compatibility
|
||||
development: 6002,
|
||||
// Production mode uses fixed port (61337) to preserve localStorage
|
||||
// Falls back to sequential ports if unavailable
|
||||
production: 61337,
|
||||
// Maximum attempts to find an available port (fallback)
|
||||
maxAttempts: 100,
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently allocated port (cached after first allocation)
|
||||
*/
|
||||
let allocatedPort: number | null = null
|
||||
|
||||
/**
|
||||
* Check if a specific port is available
|
||||
*/
|
||||
export function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", () => resolve(false))
|
||||
server.once("listening", () => {
|
||||
server.close()
|
||||
resolve(true)
|
||||
})
|
||||
server.listen(port, "127.0.0.1")
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses fixed port (61337) to preserve localStorage
|
||||
* - Falls back to sequential ports if preferred port is unavailable
|
||||
*
|
||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||
* @returns Promise<number> The available port
|
||||
* @throws Error if no available port found after max attempts
|
||||
*/
|
||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
const isDev = !app.isPackaged
|
||||
const preferredPort = isDev
|
||||
? PORT_CONFIG.development
|
||||
: PORT_CONFIG.production
|
||||
|
||||
// Try to reuse cached port if requested and available
|
||||
if (reuseExisting && allocatedPort !== null) {
|
||||
const available = await isPortAvailable(allocatedPort)
|
||||
if (available) {
|
||||
return allocatedPort
|
||||
}
|
||||
console.warn(
|
||||
`Previously allocated port ${allocatedPort} is no longer available`,
|
||||
)
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// Try preferred port first
|
||||
if (await isPortAvailable(preferredPort)) {
|
||||
allocatedPort = preferredPort
|
||||
return preferredPort
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Preferred port ${preferredPort} is in use, finding alternative...`,
|
||||
)
|
||||
|
||||
// Fallback: try sequential ports starting from preferred + 1
|
||||
for (let attempt = 1; attempt <= PORT_CONFIG.maxAttempts; attempt++) {
|
||||
const port = preferredPort + attempt
|
||||
if (await isPortAvailable(port)) {
|
||||
allocatedPort = port
|
||||
console.log(`Allocated fallback port: ${port}`)
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently allocated port
|
||||
* Returns null if no port has been allocated yet
|
||||
*/
|
||||
export function getAllocatedPort(): number | null {
|
||||
return allocatedPort
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the allocated port (useful for testing or restart scenarios)
|
||||
*/
|
||||
export function resetAllocatedPort(): void {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server URL with the allocated port
|
||||
*/
|
||||
export function getServerUrl(): string {
|
||||
if (allocatedPort === null) {
|
||||
throw new Error(
|
||||
"No port allocated yet. Call findAvailablePort() first.",
|
||||
)
|
||||
}
|
||||
return `http://localhost:${allocatedPort}`
|
||||
}
|
||||
78
electron/main/settings-window.ts
Normal file
78
electron/main/settings-window.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import path from "node:path"
|
||||
import { app, BrowserWindow, ipcMain } from "electron"
|
||||
|
||||
let settingsWindow: BrowserWindow | null = null
|
||||
|
||||
/**
|
||||
* Create and show the settings window
|
||||
*/
|
||||
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
|
||||
// If settings window already exists, focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Determine path to settings preload script
|
||||
// In compiled output: dist-electron/preload/settings.js
|
||||
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
|
||||
|
||||
// Determine path to settings HTML
|
||||
// In packaged app: app.asar/dist-electron/settings/index.html
|
||||
// In development: electron/settings/index.html
|
||||
const settingsHtmlPath = app.isPackaged
|
||||
? path.join(__dirname, "..", "settings", "index.html")
|
||||
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
|
||||
|
||||
settingsWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 700,
|
||||
minWidth: 500,
|
||||
minHeight: 500,
|
||||
parent: parentWindow,
|
||||
modal: false,
|
||||
show: false,
|
||||
title: "Settings - Next AI Draw.io",
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
settingsWindow.loadFile(settingsHtmlPath)
|
||||
|
||||
settingsWindow.once("ready-to-show", () => {
|
||||
settingsWindow?.show()
|
||||
})
|
||||
|
||||
settingsWindow.on("closed", () => {
|
||||
settingsWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the settings window if it exists
|
||||
*/
|
||||
export function closeSettingsWindow(): void {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.close()
|
||||
settingsWindow = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if settings window is open
|
||||
*/
|
||||
export function isSettingsWindowOpen(): boolean {
|
||||
return settingsWindow !== null && !settingsWindow.isDestroyed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings window IPC handlers
|
||||
*/
|
||||
export function registerSettingsWindowHandlers(): void {
|
||||
ipcMain.on("settings:close", () => {
|
||||
closeSettingsWindow()
|
||||
})
|
||||
}
|
||||
84
electron/main/window-manager.ts
Normal file
84
electron/main/window-manager.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import path from "node:path"
|
||||
import { app, BrowserWindow, screen } from "electron"
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
/**
|
||||
* Get the icon path based on platform
|
||||
* Note: electron-builder converts icon.png during packaging,
|
||||
* but at runtime we use PNG directly - Electron handles it
|
||||
*/
|
||||
function getIconPath(): string | undefined {
|
||||
// macOS doesn't need explicit icon - it's embedded in the app bundle
|
||||
if (process.platform === "darwin" && app.isPackaged) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const iconName = "icon.png"
|
||||
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, iconName)
|
||||
}
|
||||
|
||||
// Development: use icon.png from resources
|
||||
return path.join(__dirname, "../../resources/icon.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
export function createWindow(serverUrl: string): BrowserWindow {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: Math.min(1400, Math.floor(width * 0.9)),
|
||||
height: Math.min(900, Math.floor(height * 0.9)),
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Next AI Draw.io",
|
||||
icon: getIconPath(),
|
||||
show: false, // Don't show until ready
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Load the Next.js application
|
||||
mainWindow.loadURL(serverUrl)
|
||||
|
||||
// Show window when ready to prevent flashing
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
// Open DevTools in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
// Handle page title updates
|
||||
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
||||
if (title && !title.includes("localhost")) {
|
||||
mainWindow?.setTitle(title)
|
||||
} else {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main window instance
|
||||
*/
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow
|
||||
}
|
||||
24
electron/preload/index.ts
Normal file
24
electron/preload/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
|
||||
/**
|
||||
* Expose safe APIs to the renderer process
|
||||
*/
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// Platform information
|
||||
platform: process.platform,
|
||||
|
||||
// Check if running in Electron
|
||||
isElectron: true,
|
||||
|
||||
// Application version
|
||||
getVersion: () => ipcRenderer.invoke("get-version"),
|
||||
|
||||
// Window controls (optional, for custom title bar)
|
||||
minimize: () => ipcRenderer.send("window-minimize"),
|
||||
maximize: () => ipcRenderer.send("window-maximize"),
|
||||
close: () => ipcRenderer.send("window-close"),
|
||||
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||
})
|
||||
35
electron/preload/settings.ts
Normal file
35
electron/preload/settings.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Preload script for settings window
|
||||
* Exposes APIs for managing configuration presets
|
||||
*/
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
|
||||
// Expose settings API to the renderer process
|
||||
contextBridge.exposeInMainWorld("settingsAPI", {
|
||||
// Get all presets
|
||||
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
|
||||
|
||||
// Get current preset ID
|
||||
getCurrentPresetId: () =>
|
||||
ipcRenderer.invoke("config-presets:get-current-id"),
|
||||
|
||||
// Get current preset
|
||||
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
|
||||
|
||||
// Save (create or update) a preset
|
||||
savePreset: (preset: {
|
||||
id?: string
|
||||
name: string
|
||||
config: Record<string, string | undefined>
|
||||
}) => ipcRenderer.invoke("config-presets:save", preset),
|
||||
|
||||
// Delete a preset
|
||||
deletePreset: (id: string) =>
|
||||
ipcRenderer.invoke("config-presets:delete", id),
|
||||
|
||||
// Apply a preset (sets environment variables and restarts server)
|
||||
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
|
||||
|
||||
// Close settings window
|
||||
close: () => ipcRenderer.send("settings:close"),
|
||||
})
|
||||
116
electron/settings/index.html
Normal file
116
electron/settings/index.html
Normal file
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
|
||||
<title>Settings - Next AI Draw.io</title>
|
||||
<link rel="stylesheet" href="./settings.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="deprecation-notice">
|
||||
<strong>⚠️ Deprecation Notice</strong>
|
||||
<p>This settings panel will be removed in a future update.</p>
|
||||
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
|
||||
</div>
|
||||
|
||||
<h1>Configuration Presets</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Presets</h2>
|
||||
<div id="preset-list" class="preset-list">
|
||||
<!-- Presets will be loaded here -->
|
||||
</div>
|
||||
<button id="add-preset-btn" class="btn btn-primary">
|
||||
+ Add New Preset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Preset Modal -->
|
||||
<div id="preset-modal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Add Preset</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="preset-form">
|
||||
<input type="hidden" id="preset-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="preset-name">Preset Name *</label>
|
||||
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ai-provider">AI Provider</label>
|
||||
<select id="ai-provider">
|
||||
<option value="">-- Select Provider --</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="google">Google AI (Gemini)</option>
|
||||
<option value="azure">Azure OpenAI</option>
|
||||
<option value="bedrock">AWS Bedrock</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="siliconflow">SiliconFlow</option>
|
||||
<option value="ollama">Ollama (Local)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ai-model">Model ID</label>
|
||||
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
|
||||
<div class="hint">The model identifier to use with the selected provider</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ai-api-key">API Key</label>
|
||||
<input type="password" id="ai-api-key" placeholder="Your API key">
|
||||
<div class="hint">This will be stored locally on your device</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ai-base-url">Base URL (Optional)</label>
|
||||
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
|
||||
<div class="hint">Custom API endpoint URL</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="temperature">Temperature (Optional)</label>
|
||||
<input type="text" id="temperature" placeholder="0.7">
|
||||
<div class="hint">Controls randomness (0.0 - 2.0)</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="delete-modal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Delete Preset</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
|
||||
<p class="delete-warning">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="./settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
344
electron/settings/settings.css
Normal file
344
electron/settings/settings.css
Normal file
@@ -0,0 +1,344 @@
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-hover: #e8e8e8;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--border-color: #e0e0e0;
|
||||
--accent-color: #0066cc;
|
||||
--accent-hover: #0052a3;
|
||||
--danger-color: #dc3545;
|
||||
--success-color: #28a745;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-hover: #3d3d3d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #404040;
|
||||
--accent-color: #4da6ff;
|
||||
--accent-hover: #66b3ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deprecation-notice {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.deprecation-notice strong {
|
||||
color: #856404;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.deprecation-notice p {
|
||||
color: #856404;
|
||||
font-size: 13px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.deprecation-notice {
|
||||
background-color: #332701;
|
||||
border-color: #665200;
|
||||
}
|
||||
|
||||
.deprecation-notice strong,
|
||||
.deprecation-notice p {
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.preset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preset-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.preset-card.active {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 1px var(--accent-color);
|
||||
}
|
||||
|
||||
.preset-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.preset-badge {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.preset-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.preset-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
|
||||
.form-group .hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Inline style replacements */
|
||||
.delete-warning {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
311
electron/settings/settings.js
Normal file
311
electron/settings/settings.js
Normal file
@@ -0,0 +1,311 @@
|
||||
// Settings page JavaScript
|
||||
// This file handles the UI interactions for the settings window
|
||||
|
||||
let presets = []
|
||||
let currentPresetId = null
|
||||
let editingPresetId = null
|
||||
let deletingPresetId = null
|
||||
|
||||
// DOM Elements
|
||||
const presetList = document.getElementById("preset-list")
|
||||
const addPresetBtn = document.getElementById("add-preset-btn")
|
||||
const presetModal = document.getElementById("preset-modal")
|
||||
const deleteModal = document.getElementById("delete-modal")
|
||||
const presetForm = document.getElementById("preset-form")
|
||||
const modalTitle = document.getElementById("modal-title")
|
||||
const toast = document.getElementById("toast")
|
||||
|
||||
// Form fields
|
||||
const presetIdField = document.getElementById("preset-id")
|
||||
const presetNameField = document.getElementById("preset-name")
|
||||
const aiProviderField = document.getElementById("ai-provider")
|
||||
const aiModelField = document.getElementById("ai-model")
|
||||
const aiApiKeyField = document.getElementById("ai-api-key")
|
||||
const aiBaseUrlField = document.getElementById("ai-base-url")
|
||||
const temperatureField = document.getElementById("temperature")
|
||||
|
||||
// Buttons
|
||||
const cancelBtn = document.getElementById("cancel-btn")
|
||||
const saveBtn = document.getElementById("save-btn")
|
||||
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
|
||||
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
|
||||
|
||||
// Initialize
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadPresets()
|
||||
setupEventListeners()
|
||||
})
|
||||
|
||||
// Load presets from main process
|
||||
async function loadPresets() {
|
||||
try {
|
||||
presets = await window.settingsAPI.getPresets()
|
||||
currentPresetId = await window.settingsAPI.getCurrentPresetId()
|
||||
renderPresets()
|
||||
} catch (error) {
|
||||
console.error("Failed to load presets:", error)
|
||||
showToast("Failed to load presets", "error")
|
||||
}
|
||||
}
|
||||
|
||||
// Render presets list
|
||||
function renderPresets() {
|
||||
if (presets.length === 0) {
|
||||
presetList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>No presets configured yet.</p>
|
||||
<p>Add a preset to quickly switch between different AI configurations.</p>
|
||||
</div>
|
||||
`
|
||||
return
|
||||
}
|
||||
|
||||
presetList.innerHTML = presets
|
||||
.map((preset) => {
|
||||
const isActive = preset.id === currentPresetId
|
||||
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
|
||||
|
||||
return `
|
||||
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
|
||||
<div class="preset-header">
|
||||
<span class="preset-name">${escapeHtml(preset.name)}</span>
|
||||
${isActive ? '<span class="preset-badge">Active</span>' : ""}
|
||||
</div>
|
||||
<div class="preset-info">
|
||||
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
|
||||
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
|
||||
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
|
||||
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
.join("")
|
||||
|
||||
// Add event listeners to buttons
|
||||
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation()
|
||||
applyPreset(btn.dataset.id)
|
||||
})
|
||||
})
|
||||
|
||||
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation()
|
||||
openEditModal(btn.dataset.id)
|
||||
})
|
||||
})
|
||||
|
||||
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation()
|
||||
openDeleteModal(btn.dataset.id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
addPresetBtn.addEventListener("click", () => openAddModal())
|
||||
cancelBtn.addEventListener("click", () => closeModal())
|
||||
saveBtn.addEventListener("click", () => savePreset())
|
||||
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
|
||||
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
|
||||
|
||||
// Close modal on overlay click
|
||||
presetModal.addEventListener("click", (e) => {
|
||||
if (e.target === presetModal) closeModal()
|
||||
})
|
||||
deleteModal.addEventListener("click", (e) => {
|
||||
if (e.target === deleteModal) closeDeleteModal()
|
||||
})
|
||||
|
||||
// Handle Enter key in form
|
||||
presetForm.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
savePreset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Open add modal
|
||||
function openAddModal() {
|
||||
editingPresetId = null
|
||||
modalTitle.textContent = "Add Preset"
|
||||
presetForm.reset()
|
||||
presetIdField.value = ""
|
||||
presetModal.classList.add("show")
|
||||
presetNameField.focus()
|
||||
}
|
||||
|
||||
// Open edit modal
|
||||
function openEditModal(id) {
|
||||
const preset = presets.find((p) => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
editingPresetId = id
|
||||
modalTitle.textContent = "Edit Preset"
|
||||
|
||||
presetIdField.value = preset.id
|
||||
presetNameField.value = preset.name
|
||||
aiProviderField.value = preset.config.AI_PROVIDER || ""
|
||||
aiModelField.value = preset.config.AI_MODEL || ""
|
||||
aiApiKeyField.value = preset.config.AI_API_KEY || ""
|
||||
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
|
||||
temperatureField.value = preset.config.TEMPERATURE || ""
|
||||
|
||||
presetModal.classList.add("show")
|
||||
presetNameField.focus()
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal() {
|
||||
presetModal.classList.remove("show")
|
||||
editingPresetId = null
|
||||
}
|
||||
|
||||
// Open delete modal
|
||||
function openDeleteModal(id) {
|
||||
const preset = presets.find((p) => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
deletingPresetId = id
|
||||
document.getElementById("delete-preset-name").textContent = preset.name
|
||||
deleteModal.classList.add("show")
|
||||
}
|
||||
|
||||
// Close delete modal
|
||||
function closeDeleteModal() {
|
||||
deleteModal.classList.remove("show")
|
||||
deletingPresetId = null
|
||||
}
|
||||
|
||||
// Save preset
|
||||
async function savePreset() {
|
||||
const name = presetNameField.value.trim()
|
||||
if (!name) {
|
||||
showToast("Please enter a preset name", "error")
|
||||
presetNameField.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const preset = {
|
||||
id: editingPresetId || undefined,
|
||||
name: name,
|
||||
config: {
|
||||
AI_PROVIDER: aiProviderField.value || undefined,
|
||||
AI_MODEL: aiModelField.value.trim() || undefined,
|
||||
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
|
||||
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
|
||||
TEMPERATURE: temperatureField.value.trim() || undefined,
|
||||
},
|
||||
}
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(preset.config).forEach((key) => {
|
||||
if (preset.config[key] === undefined) {
|
||||
delete preset.config[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true
|
||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
||||
|
||||
await window.settingsAPI.savePreset(preset)
|
||||
await loadPresets()
|
||||
closeModal()
|
||||
showToast(
|
||||
editingPresetId ? "Preset updated" : "Preset created",
|
||||
"success",
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to save preset:", error)
|
||||
showToast("Failed to save preset", "error")
|
||||
} finally {
|
||||
saveBtn.disabled = false
|
||||
saveBtn.textContent = "Save"
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm delete
|
||||
async function confirmDelete() {
|
||||
if (!deletingPresetId) return
|
||||
|
||||
try {
|
||||
deleteConfirmBtn.disabled = true
|
||||
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
|
||||
|
||||
await window.settingsAPI.deletePreset(deletingPresetId)
|
||||
await loadPresets()
|
||||
closeDeleteModal()
|
||||
showToast("Preset deleted", "success")
|
||||
} catch (error) {
|
||||
console.error("Failed to delete preset:", error)
|
||||
showToast("Failed to delete preset", "error")
|
||||
} finally {
|
||||
deleteConfirmBtn.disabled = false
|
||||
deleteConfirmBtn.textContent = "Delete"
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preset
|
||||
async function applyPreset(id) {
|
||||
try {
|
||||
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.innerHTML = '<span class="loading"></span>'
|
||||
}
|
||||
|
||||
const result = await window.settingsAPI.applyPreset(id)
|
||||
if (result.success) {
|
||||
currentPresetId = id
|
||||
renderPresets()
|
||||
showToast("Preset applied, server restarting...", "success")
|
||||
} else {
|
||||
showToast(result.error || "Failed to apply preset", "error")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to apply preset:", error)
|
||||
showToast("Failed to apply preset", "error")
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider display label
|
||||
function getProviderLabel(provider) {
|
||||
const labels = {
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
google: "Google AI",
|
||||
azure: "Azure OpenAI",
|
||||
bedrock: "AWS Bedrock",
|
||||
openrouter: "OpenRouter",
|
||||
deepseek: "DeepSeek",
|
||||
siliconflow: "SiliconFlow",
|
||||
ollama: "Ollama",
|
||||
}
|
||||
return labels[provider] || provider
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = "") {
|
||||
toast.textContent = message
|
||||
toast.className = "toast show" + (type ? ` ${type}` : "")
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show")
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div")
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
18
electron/tsconfig.json
Normal file
18
electron/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
14
env.example
14
env.example
@@ -68,6 +68,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# SILICONFLOW_API_KEY=sk-...
|
||||
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
|
||||
|
||||
# SGLang Configuration (OpenAI-compatible)
|
||||
# SGLANG_API_KEY=your-sglang-api-key
|
||||
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
||||
|
||||
# ByteDance Doubao Configuration (via Volcengine)
|
||||
# DOUBAO_API_KEY=your-doubao-api-key
|
||||
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
||||
|
||||
# Vercel AI Gateway Configuration
|
||||
# Get your API key from: https://vercel.com/ai-gateway
|
||||
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||
@@ -93,6 +101,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
||||
# Use this to point to a self-hosted draw.io instance
|
||||
|
||||
# Subdirectory Deployment (Optional)
|
||||
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
|
||||
# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)
|
||||
# Leave empty for root deployment (default)
|
||||
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
|
||||
# PDF Input Feature (Optional)
|
||||
# Enable PDF file upload to extract text and generate diagrams
|
||||
# Enabled by default. Set to "false" to disable.
|
||||
|
||||
383
hooks/use-diagram-tool-handlers.ts
Normal file
383
hooks/use-diagram-tool-handlers.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
interface ToolCall {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
input: unknown
|
||||
}
|
||||
|
||||
type AddToolOutputSuccess = {
|
||||
tool: string
|
||||
toolCallId: string
|
||||
state?: "output-available"
|
||||
output: string
|
||||
errorText?: undefined
|
||||
}
|
||||
|
||||
type AddToolOutputError = {
|
||||
tool: string
|
||||
toolCallId: string
|
||||
state: "output-error"
|
||||
output?: undefined
|
||||
errorText: string
|
||||
}
|
||||
|
||||
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
||||
|
||||
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||
|
||||
interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
onExport: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that creates the onToolCall handler for diagram-related tools.
|
||||
* Handles display_diagram, edit_diagram, and append_diagram tools.
|
||||
*
|
||||
* Note: addToolOutput is passed at call time (not hook init) because
|
||||
* it comes from useChat which creates a circular dependency.
|
||||
*/
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
}: UseDiagramToolHandlersParams) {
|
||||
const handleToolCall = async (
|
||||
{ toolCall }: { toolCall: ToolCall },
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// DEBUG: Log raw input to diagnose false truncation detection
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] XML ending (last 100 chars):",
|
||||
xml.slice(-100),
|
||||
)
|
||||
console.log("[display_diagram] XML length:", xml.length)
|
||||
}
|
||||
|
||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||
const isTruncated = !isMxCellXmlComplete(xml)
|
||||
if (DEBUG) {
|
||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||
}
|
||||
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||
|
||||
Your output ended with:
|
||||
\`\`\`
|
||||
${partialEnding}
|
||||
\`\`\`
|
||||
|
||||
NEXT STEP: Call append_diagram with the continuation XML.
|
||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||
- Start from EXACTLY where you stopped
|
||||
- Complete all remaining mxCell elements`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Complete XML received - use it directly
|
||||
// (continuation is now handled via append_diagram tool)
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-error",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${finalXml}
|
||||
\`\`\``,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { operations } = toolCall.input as {
|
||||
operations: DiagramOperation[]
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
try {
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
currentXml = originalXml
|
||||
} else {
|
||||
// Fallback: use chartXML from ref if streaming didn't capture original
|
||||
const cachedXML = chartXMLRef.current
|
||||
if (cachedXML) {
|
||||
currentXml = cachedXML
|
||||
} else {
|
||||
// Last resort: export from iframe
|
||||
currentXml = await onFetchChart(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { applyDiagramOperations } = await import("@/lib/utils")
|
||||
const { result: editedXml, errors } = applyDiagramOperations(
|
||||
currentXml,
|
||||
operations,
|
||||
)
|
||||
|
||||
// Check for operation errors
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map(
|
||||
(e) =>
|
||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Some operations failed:\n${errorMessages}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please check the cell IDs and retry.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(editedXml)
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit produced invalid XML: ${validationError}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please fix the operations to avoid structural issues.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
onExport()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit failed: ${errorMessage}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppendDiagram = (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
const isFreshStart =
|
||||
trimmed.startsWith("<mxGraphModel") ||
|
||||
trimmed.startsWith("<root") ||
|
||||
trimmed.startsWith("<mxfile") ||
|
||||
trimmed.startsWith('<mxCell id="0"') ||
|
||||
trimmed.startsWith('<mxCell id="1"')
|
||||
|
||||
if (isFreshStart) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||
|
||||
Continue from EXACTLY where the partial ended:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Start your continuation with the NEXT character after where it stopped.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Append to accumulated XML
|
||||
partialXmlRef.current += xml
|
||||
|
||||
// Check if XML is now complete (last mxCell is complete)
|
||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||
|
||||
if (isComplete) {
|
||||
// Wrap and display the complete diagram
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Validation error after assembly: ${validationError}
|
||||
|
||||
Assembled XML:
|
||||
\`\`\`xml
|
||||
${finalXml.substring(0, 2000)}...
|
||||
\`\`\`
|
||||
|
||||
Please use display_diagram with corrected XML.`,
|
||||
})
|
||||
} else {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram assembly complete and displayed successfully.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Still incomplete - signal to continue
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||
|
||||
Current ending:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Continue from EXACTLY where you stopped.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { handleToolCall }
|
||||
}
|
||||
384
hooks/use-model-config.ts
Normal file
384
hooks/use-model-config.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
createEmptyConfig,
|
||||
createModelConfig,
|
||||
createProviderConfig,
|
||||
type FlattenedModel,
|
||||
findModelById,
|
||||
flattenModels,
|
||||
type ModelConfig,
|
||||
type MultiModelConfig,
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
// Old storage keys for migration
|
||||
const OLD_KEYS = {
|
||||
aiProvider: "next-ai-draw-io-ai-provider",
|
||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||
aiModel: "next-ai-draw-io-ai-model",
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from old single-provider format to new multi-model format
|
||||
*/
|
||||
function migrateOldConfig(): MultiModelConfig | null {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
|
||||
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
|
||||
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
|
||||
|
||||
// No old config to migrate
|
||||
if (!oldProvider || !oldApiKey || !oldModel) return null
|
||||
|
||||
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
|
||||
|
||||
// Create new config from old format
|
||||
const provider = createProviderConfig(oldProvider as ProviderName)
|
||||
provider.apiKey = oldApiKey
|
||||
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
|
||||
|
||||
const model = createModelConfig(oldModel)
|
||||
provider.models.push(model)
|
||||
|
||||
const config: MultiModelConfig = {
|
||||
version: 1,
|
||||
providers: [provider],
|
||||
selectedModelId: model.id,
|
||||
}
|
||||
|
||||
// Clear old keys after migration
|
||||
localStorage.removeItem(OLD_KEYS.aiProvider)
|
||||
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
|
||||
localStorage.removeItem(OLD_KEYS.aiApiKey)
|
||||
localStorage.removeItem(OLD_KEYS.aiModel)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from localStorage
|
||||
*/
|
||||
function loadConfig(): MultiModelConfig {
|
||||
if (typeof window === "undefined") return createEmptyConfig()
|
||||
|
||||
// First, check if new format exists
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored) as MultiModelConfig
|
||||
} catch {
|
||||
console.error("Failed to parse model config")
|
||||
}
|
||||
}
|
||||
|
||||
// Try migration from old format
|
||||
const migrated = migrateOldConfig()
|
||||
if (migrated) {
|
||||
// Save migrated config
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.modelConfigs,
|
||||
JSON.stringify(migrated),
|
||||
)
|
||||
return migrated
|
||||
}
|
||||
|
||||
return createEmptyConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config to localStorage
|
||||
*/
|
||||
function saveConfig(config: MultiModelConfig): void {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||
}
|
||||
|
||||
export interface UseModelConfigReturn {
|
||||
// State
|
||||
config: MultiModelConfig
|
||||
isLoaded: boolean
|
||||
|
||||
// Getters
|
||||
models: FlattenedModel[]
|
||||
selectedModel: FlattenedModel | undefined
|
||||
selectedModelId: string | undefined
|
||||
showUnvalidatedModels: boolean
|
||||
|
||||
// Actions
|
||||
setSelectedModelId: (modelId: string | undefined) => void
|
||||
setShowUnvalidatedModels: (show: boolean) => void
|
||||
addProvider: (provider: ProviderName) => ProviderConfig
|
||||
updateProvider: (
|
||||
providerId: string,
|
||||
updates: Partial<ProviderConfig>,
|
||||
) => void
|
||||
deleteProvider: (providerId: string) => void
|
||||
addModel: (providerId: string, modelId: string) => ModelConfig
|
||||
updateModel: (
|
||||
providerId: string,
|
||||
modelConfigId: string,
|
||||
updates: Partial<ModelConfig>,
|
||||
) => void
|
||||
deleteModel: (providerId: string, modelConfigId: string) => void
|
||||
resetConfig: () => void
|
||||
}
|
||||
|
||||
export function useModelConfig(): UseModelConfigReturn {
|
||||
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
// Load config on mount
|
||||
useEffect(() => {
|
||||
const loaded = loadConfig()
|
||||
setConfig(loaded)
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Save config whenever it changes (after initial load)
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
saveConfig(config)
|
||||
}
|
||||
}, [config, isLoaded])
|
||||
|
||||
// Derived state
|
||||
const models = flattenModels(config)
|
||||
const selectedModel = config.selectedModelId
|
||||
? findModelById(config, config.selectedModelId)
|
||||
: undefined
|
||||
|
||||
// Actions
|
||||
const setSelectedModelId = useCallback((modelId: string | undefined) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
selectedModelId: modelId,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const setShowUnvalidatedModels = useCallback((show: boolean) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
showUnvalidatedModels: show,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const addProvider = useCallback(
|
||||
(provider: ProviderName): ProviderConfig => {
|
||||
const newProvider = createProviderConfig(provider)
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
providers: [...prev.providers, newProvider],
|
||||
}))
|
||||
return newProvider
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const updateProvider = useCallback(
|
||||
(providerId: string, updates: Partial<ProviderConfig>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
providers: prev.providers.map((p) =>
|
||||
p.id === providerId ? { ...p, ...updates } : p,
|
||||
),
|
||||
}))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const deleteProvider = useCallback((providerId: string) => {
|
||||
setConfig((prev) => {
|
||||
const provider = prev.providers.find((p) => p.id === providerId)
|
||||
const modelIds = provider?.models.map((m) => m.id) || []
|
||||
|
||||
// Clear selected model if it belongs to deleted provider
|
||||
const newSelectedId =
|
||||
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
||||
? undefined
|
||||
: prev.selectedModelId
|
||||
|
||||
return {
|
||||
...prev,
|
||||
providers: prev.providers.filter((p) => p.id !== providerId),
|
||||
selectedModelId: newSelectedId,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addModel = useCallback(
|
||||
(providerId: string, modelId: string): ModelConfig => {
|
||||
const newModel = createModelConfig(modelId)
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
providers: prev.providers.map((p) =>
|
||||
p.id === providerId
|
||||
? { ...p, models: [...p.models, newModel] }
|
||||
: p,
|
||||
),
|
||||
}))
|
||||
return newModel
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const updateModel = useCallback(
|
||||
(
|
||||
providerId: string,
|
||||
modelConfigId: string,
|
||||
updates: Partial<ModelConfig>,
|
||||
) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
providers: prev.providers.map((p) =>
|
||||
p.id === providerId
|
||||
? {
|
||||
...p,
|
||||
models: p.models.map((m) =>
|
||||
m.id === modelConfigId
|
||||
? { ...m, ...updates }
|
||||
: m,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
}))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const deleteModel = useCallback(
|
||||
(providerId: string, modelConfigId: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
providers: prev.providers.map((p) =>
|
||||
p.id === providerId
|
||||
? {
|
||||
...p,
|
||||
models: p.models.filter(
|
||||
(m) => m.id !== modelConfigId,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
// Clear selected model if it was deleted
|
||||
selectedModelId:
|
||||
prev.selectedModelId === modelConfigId
|
||||
? undefined
|
||||
: prev.selectedModelId,
|
||||
}))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const resetConfig = useCallback(() => {
|
||||
setConfig(createEmptyConfig())
|
||||
}, [])
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoaded,
|
||||
models,
|
||||
selectedModel,
|
||||
selectedModelId: config.selectedModelId,
|
||||
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
|
||||
setSelectedModelId,
|
||||
setShowUnvalidatedModels,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
addModel,
|
||||
updateModel,
|
||||
deleteModel,
|
||||
resetConfig,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AI config for the currently selected model.
|
||||
* Returns format compatible with existing getAIConfig() usage.
|
||||
*/
|
||||
export function getSelectedAIConfig(): {
|
||||
accessCode: string
|
||||
aiProvider: string
|
||||
aiBaseUrl: string
|
||||
aiApiKey: string
|
||||
aiModel: string
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: string
|
||||
awsSecretAccessKey: string
|
||||
awsRegion: string
|
||||
awsSessionToken: string
|
||||
} {
|
||||
const empty = {
|
||||
accessCode: "",
|
||||
aiProvider: "",
|
||||
aiBaseUrl: "",
|
||||
aiApiKey: "",
|
||||
aiModel: "",
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsRegion: "",
|
||||
awsSessionToken: "",
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return empty
|
||||
|
||||
// Get access code (separate from model config)
|
||||
const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || ""
|
||||
|
||||
// Load multi-model config
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (!stored) {
|
||||
// Fallback to old format for backward compatibility
|
||||
return {
|
||||
accessCode,
|
||||
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
|
||||
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
|
||||
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
|
||||
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
|
||||
// Old format didn't support AWS
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsRegion: "",
|
||||
awsSessionToken: "",
|
||||
}
|
||||
}
|
||||
|
||||
let config: MultiModelConfig
|
||||
try {
|
||||
config = JSON.parse(stored)
|
||||
} catch {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
// No selected model = use server default
|
||||
if (!config.selectedModelId) {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
// Find selected model
|
||||
const model = findModelById(config, config.selectedModelId)
|
||||
if (!model) {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
return {
|
||||
accessCode,
|
||||
aiProvider: model.provider,
|
||||
aiBaseUrl: model.baseUrl || "",
|
||||
aiApiKey: model.apiKey,
|
||||
aiModel: model.modelId,
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: model.awsAccessKeyId || "",
|
||||
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||
awsRegion: model.awsRegion || "",
|
||||
awsSessionToken: model.awsSessionToken || "",
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,14 @@ export function register() {
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
||||
// Whitelist approach: only export AI-related spans
|
||||
shouldExportSpan: ({ otelSpan }) => {
|
||||
const spanName = otelSpan.name
|
||||
// Skip Next.js HTTP infrastructure spans
|
||||
if (
|
||||
spanName.startsWith("POST /") ||
|
||||
spanName.startsWith("GET /") ||
|
||||
spanName.includes("BaseServer") ||
|
||||
spanName.includes("handleRequest")
|
||||
) {
|
||||
return false
|
||||
// Only export AI SDK spans (ai.*) and our explicit "chat" wrapper
|
||||
if (spanName === "chat" || spanName.startsWith("ai.")) {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
@@ -36,4 +31,5 @@ export function register() {
|
||||
|
||||
// Register globally so AI SDK's telemetry also uses this processor
|
||||
tracerProvider.register()
|
||||
console.log("[Langfuse] Instrumentation initialized successfully")
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ export type ProviderName =
|
||||
| "openrouter"
|
||||
| "deepseek"
|
||||
| "siliconflow"
|
||||
| "sglang"
|
||||
| "gateway"
|
||||
| "edgeone"
|
||||
| "doubao"
|
||||
|
||||
interface ModelConfig {
|
||||
model: any
|
||||
@@ -33,6 +36,13 @@ export interface ClientOverrides {
|
||||
baseUrl?: string | null
|
||||
apiKey?: string | null
|
||||
modelId?: string | null
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId?: string | null
|
||||
awsSecretAccessKey?: string | null
|
||||
awsRegion?: string | null
|
||||
awsSessionToken?: string | null
|
||||
// Custom headers (e.g., for EdgeOne cookie auth)
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
// Providers that can be used with client-provided API keys
|
||||
@@ -41,10 +51,14 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"anthropic",
|
||||
"google",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"openrouter",
|
||||
"deepseek",
|
||||
"siliconflow",
|
||||
"sglang",
|
||||
"gateway",
|
||||
"edgeone",
|
||||
"doubao",
|
||||
]
|
||||
|
||||
// Bedrock provider options for Anthropic beta features
|
||||
@@ -87,8 +101,8 @@ function parseIntSafe(
|
||||
* Supports various AI SDK providers with their unique configuration options
|
||||
*
|
||||
* Environment variables:
|
||||
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/gpt-5
|
||||
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (none/brief/detailed) - auto-enabled for o1/o3/gpt-5
|
||||
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5
|
||||
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5
|
||||
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
|
||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||
@@ -110,18 +124,19 @@ function buildProviderOptions(
|
||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
||||
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
||||
|
||||
// OpenAI reasoning models (o1, o3, gpt-5) need reasoningSummary to return thoughts
|
||||
// OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts
|
||||
if (
|
||||
modelId &&
|
||||
(modelId.includes("o1") ||
|
||||
modelId.includes("o3") ||
|
||||
modelId.includes("o4") ||
|
||||
modelId.includes("gpt-5"))
|
||||
) {
|
||||
options.openai = {
|
||||
// Auto-enable reasoning summary for reasoning models (default: detailed)
|
||||
// Auto-enable reasoning summary for reasoning models
|
||||
// Use 'auto' as default since not all models support 'detailed'
|
||||
reasoningSummary:
|
||||
(reasoningSummary as "none" | "brief" | "detailed") ||
|
||||
"detailed",
|
||||
(reasoningSummary as "auto" | "detailed") || "auto",
|
||||
}
|
||||
|
||||
// Optionally configure reasoning effort
|
||||
@@ -144,8 +159,7 @@ function buildProviderOptions(
|
||||
}
|
||||
if (reasoningSummary) {
|
||||
options.openai.reasoningSummary = reasoningSummary as
|
||||
| "none"
|
||||
| "brief"
|
||||
| "auto"
|
||||
| "detailed"
|
||||
}
|
||||
}
|
||||
@@ -337,7 +351,9 @@ function buildProviderOptions(
|
||||
case "deepseek":
|
||||
case "openrouter":
|
||||
case "siliconflow":
|
||||
case "gateway": {
|
||||
case "sglang":
|
||||
case "gateway":
|
||||
case "doubao": {
|
||||
// These providers don't have reasoning configs in AI SDK yet
|
||||
// Gateway passes through to underlying providers which handle their own configs
|
||||
break
|
||||
@@ -361,7 +377,10 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
siliconflow: "SILICONFLOW_API_KEY",
|
||||
sglang: "SGLANG_API_KEY",
|
||||
gateway: "AI_GATEWAY_API_KEY",
|
||||
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
||||
doubao: "DOUBAO_API_KEY",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,7 +445,7 @@ function validateProviderCredentials(provider: ProviderName): void {
|
||||
* Get the AI model based on environment variables
|
||||
*
|
||||
* Environment variables:
|
||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
* - 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
|
||||
*
|
||||
* Provider-specific env vars:
|
||||
@@ -442,12 +461,19 @@ function validateProviderCredentials(provider: ProviderName): void {
|
||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
||||
* - SGLANG_API_KEY: SGLang API key
|
||||
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
||||
*/
|
||||
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||
// If a custom baseUrl is provided, an API key MUST also be provided.
|
||||
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
||||
if (overrides?.baseUrl && !overrides?.apiKey) {
|
||||
// Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime)
|
||||
if (
|
||||
overrides?.baseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
overrides?.provider !== "edgeone"
|
||||
) {
|
||||
throw new Error(
|
||||
`API key is required when using a custom base URL. ` +
|
||||
`Please provide your own API key in Settings.`,
|
||||
@@ -510,6 +536,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||
`- AZURE_API_KEY for Azure\n` +
|
||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||
`- SGLANG_API_KEY for SGLang\n` +
|
||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||
)
|
||||
} else {
|
||||
@@ -537,12 +564,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
switch (provider) {
|
||||
case "bedrock": {
|
||||
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||
const bedrockProvider = createAmazonBedrock({
|
||||
region: process.env.AWS_REGION || "us-west-2",
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
||||
const hasClientCredentials =
|
||||
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||
const bedrockRegion =
|
||||
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
|
||||
|
||||
const bedrockProvider = hasClientCredentials
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
accessKeyId: overrides.awsAccessKeyId!,
|
||||
secretAccessKey: overrides.awsSecretAccessKey!,
|
||||
...(overrides?.awsSessionToken && {
|
||||
sessionToken: overrides.awsSessionToken,
|
||||
}),
|
||||
})
|
||||
: createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
@@ -562,12 +602,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
case "openai": {
|
||||
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
const customOpenAI = createOpenAI({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
})
|
||||
if (baseURL) {
|
||||
// Custom base URL = third-party proxy, use Chat Completions API
|
||||
// for compatibility (most proxies don't support /responses endpoint)
|
||||
const customOpenAI = createOpenAI({ apiKey, baseURL })
|
||||
model = customOpenAI.chat(modelId)
|
||||
} else if (overrides?.apiKey) {
|
||||
// Custom API key but official OpenAI endpoint, use Responses API
|
||||
// to support reasoning for gpt-5, o1, o3, o4 models
|
||||
const customOpenAI = createOpenAI({ apiKey })
|
||||
model = customOpenAI(modelId)
|
||||
} else {
|
||||
model = openai(modelId)
|
||||
}
|
||||
@@ -679,6 +723,112 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
break
|
||||
}
|
||||
|
||||
case "sglang": {
|
||||
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
||||
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
|
||||
|
||||
const sglangProvider = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
||||
fetch: async (url, options) => {
|
||||
const response = await fetch(url, options)
|
||||
if (!response.body) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Create a transform stream to fix the non-compliant sglang stream
|
||||
let buffer = ""
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
// Process all complete messages in the buffer
|
||||
let messageEndPos
|
||||
while (
|
||||
(messageEndPos = buffer.indexOf("\n\n")) !== -1
|
||||
) {
|
||||
const message = buffer.substring(
|
||||
0,
|
||||
messageEndPos,
|
||||
)
|
||||
buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n'
|
||||
|
||||
if (message.startsWith("data: ")) {
|
||||
const jsonStr = message.substring(6).trim()
|
||||
if (jsonStr === "[DONE]") {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
message + "\n\n",
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
const delta = data.choices?.[0]?.delta
|
||||
|
||||
if (delta) {
|
||||
// Fix 1: remove invalid empty role
|
||||
if (delta.role === "") {
|
||||
delete delta.role
|
||||
}
|
||||
// Fix 2: remove non-standard reasoning_content field
|
||||
if ("reasoning_content" in delta) {
|
||||
delete delta.reasoning_content
|
||||
}
|
||||
}
|
||||
|
||||
// Re-serialize and forward the corrected data with the correct SSE format
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`data: ${JSON.stringify(data)}\n\n`,
|
||||
),
|
||||
)
|
||||
} catch (e) {
|
||||
// If parsing fails, forward the original message to avoid breaking the stream.
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
message + "\n\n",
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (message.trim() !== "") {
|
||||
// Pass through other message types (e.g., 'event: ...')
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
message + "\n\n",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
// If there's anything left in the buffer, forward it.
|
||||
if (buffer.trim()) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(buffer),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const transformedBody =
|
||||
response.body.pipeThrough(transformStream)
|
||||
|
||||
// Return a new response with the transformed body
|
||||
return new Response(transformedBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
})
|
||||
},
|
||||
})
|
||||
model = sglangProvider.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "gateway": {
|
||||
// Vercel AI Gateway - unified access to multiple AI providers
|
||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||
@@ -700,9 +850,38 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
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": {
|
||||
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
|
||||
const baseURL =
|
||||
overrides?.baseUrl ||
|
||||
process.env.DOUBAO_BASE_URL ||
|
||||
"https://ark.cn-beijing.volces.com/api/v3"
|
||||
const doubaoProvider = createDeepSeek({
|
||||
apiKey,
|
||||
baseURL,
|
||||
})
|
||||
model = doubaoProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -727,3 +906,34 @@ export function supportsPromptCaching(modelId: string): boolean {
|
||||
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
|
||||
}
|
||||
|
||||
37
lib/base-path.ts
Normal file
37
lib/base-path.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Get the base path for API calls and static assets
|
||||
* This is used for subdirectory deployment support
|
||||
*
|
||||
* Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio"
|
||||
* For root deployment, this returns ""
|
||||
*
|
||||
* Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
|
||||
*/
|
||||
export function getBasePath(): string {
|
||||
// Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""
|
||||
if (basePath && !basePath.startsWith("/")) {
|
||||
console.warn("NEXT_PUBLIC_BASE_PATH should start with /")
|
||||
}
|
||||
return basePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full API endpoint URL
|
||||
* @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config")
|
||||
* @returns Full API path with base path prefix
|
||||
*/
|
||||
export function getApiEndpoint(endpoint: string): string {
|
||||
const basePath = getBasePath()
|
||||
return `${basePath}${endpoint}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full static asset URL
|
||||
* @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt")
|
||||
* @returns Full asset path with base path prefix
|
||||
*/
|
||||
export function getAssetUrl(assetPath: string): string {
|
||||
const basePath = getBasePath()
|
||||
return `${basePath}${assetPath}`
|
||||
}
|
||||
255
lib/dynamo-quota-manager.ts
Normal file
255
lib/dynamo-quota-manager.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
ConditionalCheckFailedException,
|
||||
DynamoDBClient,
|
||||
GetItemCommand,
|
||||
UpdateItemCommand,
|
||||
} from "@aws-sdk/client-dynamodb"
|
||||
|
||||
// Quota tracking is OPT-IN: only enabled if DYNAMODB_QUOTA_TABLE is explicitly set
|
||||
// OSS users who don't need quota tracking can simply not set this env var
|
||||
const TABLE = process.env.DYNAMODB_QUOTA_TABLE
|
||||
const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1"
|
||||
// Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset)
|
||||
// Defaults to UTC if not set
|
||||
let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC"
|
||||
|
||||
// Validate timezone at module load
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format(
|
||||
new Date(),
|
||||
)
|
||||
} catch {
|
||||
console.warn(
|
||||
`[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`,
|
||||
)
|
||||
QUOTA_TIMEZONE = "UTC"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date string in the configured timezone (YYYY-MM-DD format)
|
||||
* This is used as the Sort Key (SK) for per-day tracking
|
||||
*/
|
||||
function getTodayInTimezone(): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: QUOTA_TIMEZONE,
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
// Only create client if quota is enabled
|
||||
const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null
|
||||
|
||||
/**
|
||||
* Check if server-side quota tracking is enabled.
|
||||
* Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set.
|
||||
*/
|
||||
export function isQuotaEnabled(): boolean {
|
||||
return !!TABLE
|
||||
}
|
||||
|
||||
interface QuotaLimits {
|
||||
requests: number // Daily request limit
|
||||
tokens: number // Daily token limit
|
||||
tpm: number // Tokens per minute
|
||||
}
|
||||
|
||||
interface QuotaCheckResult {
|
||||
allowed: boolean
|
||||
error?: string
|
||||
type?: "request" | "token" | "tpm"
|
||||
used?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all quotas and increment request count atomically.
|
||||
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
||||
* Each day automatically gets a new item - no explicit reset needed.
|
||||
*/
|
||||
export async function checkAndIncrementRequest(
|
||||
ip: string,
|
||||
limits: QuotaLimits,
|
||||
): Promise<QuotaCheckResult> {
|
||||
// Skip if quota tracking not enabled
|
||||
if (!client || !TABLE) {
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
const pk = ip // User identifier (base64 IP)
|
||||
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
|
||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||
|
||||
try {
|
||||
// Single atomic update - handles creation AND increment
|
||||
// New day automatically creates new item (different SK)
|
||||
// Note: lastMinute/tpmCount are managed by recordTokenUsage only
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: {
|
||||
PK: { S: pk },
|
||||
SK: { S: sk },
|
||||
},
|
||||
UpdateExpression: "ADD reqCount :one",
|
||||
// Check all limits before allowing increment
|
||||
// TPM check: allow if new minute OR under limit
|
||||
ConditionExpression: `
|
||||
(attribute_not_exists(reqCount) OR reqCount < :reqLimit) AND
|
||||
(attribute_not_exists(tokenCount) OR tokenCount < :tokenLimit) AND
|
||||
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
|
||||
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||
`,
|
||||
ExpressionAttributeValues: {
|
||||
":one": { N: "1" },
|
||||
":minute": { S: currentMinute },
|
||||
":reqLimit": { N: String(limits.requests || 999999) },
|
||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||
":tpmLimit": { N: String(limits.tpm || 999999) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return { allowed: true }
|
||||
} catch (e: any) {
|
||||
// Condition failed - need to determine which limit was exceeded
|
||||
if (e instanceof ConditionalCheckFailedException) {
|
||||
// Get current counts to determine which limit was hit
|
||||
try {
|
||||
const getResult = await client.send(
|
||||
new GetItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: {
|
||||
PK: { S: pk },
|
||||
SK: { S: sk },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const item = getResult.Item
|
||||
const storedMinute = item?.lastMinute?.S
|
||||
|
||||
const reqCount = Number(item?.reqCount?.N || 0)
|
||||
const tokenCount = Number(item?.tokenCount?.N || 0)
|
||||
const tpmCount =
|
||||
storedMinute !== currentMinute
|
||||
? 0
|
||||
: Number(item?.tpmCount?.N || 0)
|
||||
|
||||
// Determine which limit was exceeded
|
||||
if (limits.requests > 0 && reqCount >= limits.requests) {
|
||||
return {
|
||||
allowed: false,
|
||||
type: "request",
|
||||
error: "Daily request limit exceeded",
|
||||
used: reqCount,
|
||||
limit: limits.requests,
|
||||
}
|
||||
}
|
||||
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
|
||||
return {
|
||||
allowed: false,
|
||||
type: "token",
|
||||
error: "Daily token limit exceeded",
|
||||
used: tokenCount,
|
||||
limit: limits.tokens,
|
||||
}
|
||||
}
|
||||
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
|
||||
return {
|
||||
allowed: false,
|
||||
type: "tpm",
|
||||
error: "Rate limit exceeded (tokens per minute)",
|
||||
used: tpmCount,
|
||||
limit: limits.tpm,
|
||||
}
|
||||
}
|
||||
|
||||
// Condition failed but no limit clearly exceeded - race condition edge case
|
||||
// Fail safe by allowing (could be a TPM reset race)
|
||||
console.warn(
|
||||
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
||||
)
|
||||
return { allowed: true }
|
||||
} catch (getError: any) {
|
||||
console.error(
|
||||
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
|
||||
)
|
||||
return { allowed: true } // Fail open
|
||||
}
|
||||
}
|
||||
|
||||
// Other DynamoDB errors - fail open
|
||||
console.error(
|
||||
`[quota] DynamoDB error (fail-open), IP prefix: ${ip.slice(0, 8)}..., error: ${e.message}`,
|
||||
)
|
||||
return { allowed: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage after response completes.
|
||||
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
||||
* Handles minute boundaries atomically to prevent race conditions.
|
||||
*/
|
||||
export async function recordTokenUsage(
|
||||
ip: string,
|
||||
tokens: number,
|
||||
): Promise<void> {
|
||||
// Skip if quota tracking not enabled
|
||||
if (!client || !TABLE) return
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) return
|
||||
|
||||
const pk = ip // User identifier (base64 IP)
|
||||
const sk = getTodayInTimezone() // Date as sort key (YYYY-MM-DD)
|
||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||
|
||||
try {
|
||||
// Try to update for same minute OR new item (most common cases)
|
||||
// Handles: 1) new item (no lastMinute), 2) same minute (lastMinute matches)
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: {
|
||||
PK: { S: pk },
|
||||
SK: { S: sk },
|
||||
},
|
||||
UpdateExpression:
|
||||
"SET lastMinute = if_not_exists(lastMinute, :minute) ADD tokenCount :tokens, tpmCount :tokens",
|
||||
ConditionExpression:
|
||||
"attribute_not_exists(lastMinute) OR lastMinute = :minute",
|
||||
ExpressionAttributeValues: {
|
||||
":minute": { S: currentMinute },
|
||||
":tokens": { N: String(tokens) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (e: any) {
|
||||
if (e instanceof ConditionalCheckFailedException) {
|
||||
// Different minute - reset TPM count and set new minute
|
||||
try {
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: {
|
||||
PK: { S: pk },
|
||||
SK: { S: sk },
|
||||
},
|
||||
UpdateExpression:
|
||||
"SET lastMinute = :minute, tpmCount = :tokens ADD tokenCount :tokens",
|
||||
ExpressionAttributeValues: {
|
||||
":minute": { S: currentMinute },
|
||||
":tokens": { N: String(tokens) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (retryError: any) {
|
||||
console.error(
|
||||
`[quota] Failed to record tokens (retry), IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${retryError.message}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`[quota] Failed to record tokens, IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${e.message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"about": "About",
|
||||
"editor": "Editor",
|
||||
"newChat": "Start fresh chat",
|
||||
"github": "GitHub",
|
||||
"settings": "Settings",
|
||||
"hidePanel": "Hide chat panel (Ctrl+B)",
|
||||
"showPanel": "Show chat panel (Ctrl+B)",
|
||||
@@ -45,6 +46,7 @@
|
||||
"copyResponse": "Copy response",
|
||||
"copied": "Copied!",
|
||||
"failedToCopy": "Failed to copy",
|
||||
"failedToCopyDetail": "Failed to copy message. Please copy manually or check clipboard permissions.",
|
||||
"goodResponse": "Good response",
|
||||
"badResponse": "Bad response",
|
||||
"clickToEdit": "Click to edit",
|
||||
@@ -87,6 +89,8 @@
|
||||
"overrides": "Overrides",
|
||||
"clearSettings": "Clear Settings",
|
||||
"useServerDefault": "Use Server Default",
|
||||
"language": "Language",
|
||||
"languageDescription": "Choose your interface language.",
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
|
||||
"drawioStyle": "DrawIO Style",
|
||||
@@ -95,7 +99,13 @@
|
||||
"minimal": "Minimal",
|
||||
"sketch": "Sketch",
|
||||
"closeProtection": "Close Protection",
|
||||
"closeProtectionDescription": "Show confirmation when leaving the page."
|
||||
"closeProtectionDescription": "Show confirmation when leaving the page.",
|
||||
"diagramStyle": "Diagram Style",
|
||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||
"diagramActions": "Diagram Actions",
|
||||
"diagramActionsDescription": "Manage diagram history and exports",
|
||||
"history": "History",
|
||||
"download": "Download"
|
||||
},
|
||||
"save": {
|
||||
"title": "Save Diagram",
|
||||
@@ -132,6 +142,7 @@
|
||||
"invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.",
|
||||
"networkError": "Network error. Please check your connection.",
|
||||
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
|
||||
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
|
||||
"validationFailed": "Diagram validation failed. Please try regenerating.",
|
||||
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
|
||||
"failedToProcess": "Failed to process diagram. Please try regenerating.",
|
||||
@@ -140,17 +151,22 @@
|
||||
"failedToRestore": "Failed to restore from localStorage",
|
||||
"failedToPersist": "Failed to persist state before unload",
|
||||
"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": {
|
||||
"dailyLimit": "Daily Quota Reached",
|
||||
"tokenLimit": "Daily Token Limit Reached",
|
||||
"tpmLimit": "Rate Limit",
|
||||
"tpmMessage": "Too many requests. Please wait a moment.",
|
||||
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
|
||||
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
|
||||
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
||||
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
||||
"reset": "Your limit resets tomorrow. Thanks for understanding!",
|
||||
"reset": "Your limit resets tomorrow. Thanks for understanding.",
|
||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
|
||||
"configModel": "Use Your API Key",
|
||||
"selfHost": "Self-host",
|
||||
"sponsor": "Sponsor",
|
||||
"learnMore": "Learn more →",
|
||||
@@ -174,11 +190,89 @@
|
||||
"thoughtFor": "Thought for {duration} 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": {
|
||||
"modelChange": "Model Change & Usage Limits",
|
||||
"walletCrying": "(Or: Why My Wallet is Crying)",
|
||||
"seekingSponsorship": "Call for Sponsorship",
|
||||
"contactMe": "Contact Me",
|
||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AI Model Configuration",
|
||||
"description": "Configure multiple AI providers and models",
|
||||
"configure": "Configure",
|
||||
"addProvider": "Add Provider",
|
||||
"addModel": "Add Model",
|
||||
"modelId": "Model ID",
|
||||
"modelLabel": "Display Label",
|
||||
"streaming": "Enable Streaming",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteModel": "Delete Model",
|
||||
"noModels": "No models configured. Add a model to get started.",
|
||||
"selectProvider": "Select a provider or add a new one",
|
||||
"configureMultiple": "Configure multiple AI providers and switch between them easily",
|
||||
"apiKeyStored": "API keys are stored locally in your browser",
|
||||
"test": "Test",
|
||||
"validationError": "Validation failed",
|
||||
"addModelFirst": "Add at least one model to validate",
|
||||
"providers": "Providers",
|
||||
"addProviderHint": "Add a provider to get started",
|
||||
"verified": "Verified",
|
||||
"configuration": "Configuration",
|
||||
"displayName": "Display Name",
|
||||
"awsAccessKeyId": "AWS Access Key ID",
|
||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||
"awsRegion": "AWS Region",
|
||||
"selectRegion": "Select region",
|
||||
"apiKey": "API Key",
|
||||
"enterApiKey": "Enter your API key",
|
||||
"enterSecretKey": "Enter your secret access key",
|
||||
"baseUrl": "Base URL",
|
||||
"optional": "(optional)",
|
||||
"customEndpoint": "Custom endpoint URL",
|
||||
"models": "Models",
|
||||
"customModelId": "Custom model ID...",
|
||||
"allAdded": "All added",
|
||||
"suggested": "Suggested",
|
||||
"noModelsConfigured": "No models configured",
|
||||
"modelIdEmpty": "Model ID cannot be empty",
|
||||
"modelIdExists": "This model ID already exists",
|
||||
"configureProviders": "Configure AI Providers",
|
||||
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
|
||||
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
|
||||
"typeToConfirm": "Type \"{name}\" to confirm",
|
||||
"typeProviderName": "Type provider name...",
|
||||
"modelsConfiguredCount": "{count} model(s) configured",
|
||||
"validationFailedCount": "{count} model(s) failed validation",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"clickToChange": "(click to change)",
|
||||
"usingServerDefault": "Using server default model",
|
||||
"selectModel": "Select Model",
|
||||
"searchModels": "Search models...",
|
||||
"noVerifiedModels": "No verified models. Test your models first.",
|
||||
"noModelsFound": "No models found.",
|
||||
"default": "Default",
|
||||
"serverDefault": "Server Default",
|
||||
"configureModels": "Configure Models...",
|
||||
"onlyVerifiedShown": "Only verified models are shown",
|
||||
"showUnvalidatedModels": "Show unvalidated models",
|
||||
"allModelsShown": "All models are shown (including unvalidated)",
|
||||
"unvalidatedModelWarning": "This model has not been validated"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"about": "概要",
|
||||
"editor": "エディタ",
|
||||
"newChat": "新しいチャットを開始",
|
||||
"github": "GitHub",
|
||||
"settings": "設定",
|
||||
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
||||
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
||||
@@ -45,6 +46,7 @@
|
||||
"copyResponse": "応答をコピー",
|
||||
"copied": "コピーしました!",
|
||||
"failedToCopy": "コピーに失敗しました",
|
||||
"failedToCopyDetail": "メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。",
|
||||
"goodResponse": "良い応答",
|
||||
"badResponse": "悪い応答",
|
||||
"clickToEdit": "クリックして編集",
|
||||
@@ -87,6 +89,8 @@
|
||||
"overrides": "上書き",
|
||||
"clearSettings": "設定をクリア",
|
||||
"useServerDefault": "サーバーデフォルトを使用",
|
||||
"language": "言語",
|
||||
"languageDescription": "インターフェース言語を選択します。",
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||
"drawioStyle": "DrawIO スタイル",
|
||||
@@ -95,7 +99,13 @@
|
||||
"minimal": "ミニマル",
|
||||
"sketch": "スケッチ",
|
||||
"closeProtection": "ページ離脱確認",
|
||||
"closeProtectionDescription": "ページを離れる際に確認を表示します。"
|
||||
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
|
||||
"diagramStyle": "ダイアグラムスタイル",
|
||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||
"diagramActions": "ダイアグラム操作",
|
||||
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
||||
"history": "履歴",
|
||||
"download": "ダウンロード"
|
||||
},
|
||||
"save": {
|
||||
"title": "ダイアグラムを保存",
|
||||
@@ -132,6 +142,7 @@
|
||||
"invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。",
|
||||
"networkError": "ネットワークエラー。接続を確認してください。",
|
||||
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
|
||||
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
|
||||
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
|
||||
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
|
||||
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
|
||||
@@ -140,17 +151,22 @@
|
||||
"failedToRestore": "localStorage からの復元に失敗しました",
|
||||
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
|
||||
"failedToExport": "チャートデータの取得エラー",
|
||||
"failedToLoadExample": "例の画像の読み込みエラー"
|
||||
"failedToLoadExample": "例の画像の読み込みエラー",
|
||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "1日の割当量に達しました",
|
||||
"tokenLimit": "1日のトークン制限に達しました",
|
||||
"tpmLimit": "レート制限",
|
||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
||||
"messageToken": "おっと — このデモの1日のトークン制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
||||
"reset": "制限は明日リセットされます。ご理解ありがとうございます!",
|
||||
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
|
||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
|
||||
"configModel": "APIキーを使用",
|
||||
"selfHost": "セルフホスト",
|
||||
"sponsor": "スポンサー",
|
||||
"learnMore": "詳細 →",
|
||||
@@ -174,11 +190,89 @@
|
||||
"thoughtFor": "{duration} 秒考えました",
|
||||
"thoughtBrief": "数秒考えました"
|
||||
},
|
||||
"dev": {
|
||||
"title": "開発:XMLストリーミングシミュレーター",
|
||||
"preset": "プリセット:",
|
||||
"selectPreset": "プリセットを選択...",
|
||||
"clear": "クリア",
|
||||
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
|
||||
"interval": "間隔:",
|
||||
"chars": "文字:",
|
||||
"streaming": "ストリーミング中...",
|
||||
"simulate": "シミュレート",
|
||||
"stop": "停止",
|
||||
"testQuotaToast": "クォータトーストをテスト",
|
||||
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
|
||||
"successMessage": "ダイアグラムの表示に成功しました。"
|
||||
},
|
||||
"about": {
|
||||
"modelChange": "モデル変更と利用制限について",
|
||||
"walletCrying": "(別名:お財布が悲鳴を上げています)",
|
||||
"seekingSponsorship": "スポンサー募集",
|
||||
"contactMe": "お問い合わせ",
|
||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AIモデル設定",
|
||||
"description": "複数のAIプロバイダーとモデルを設定",
|
||||
"configure": "設定",
|
||||
"addProvider": "プロバイダーを追加",
|
||||
"addModel": "モデルを追加",
|
||||
"modelId": "モデルID",
|
||||
"modelLabel": "表示名",
|
||||
"streaming": "ストリーミングを有効",
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
"deleteModel": "モデルを削除",
|
||||
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
||||
"selectProvider": "プロバイダーを選択または追加してください",
|
||||
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
|
||||
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
|
||||
"test": "テスト",
|
||||
"validationError": "検証に失敗しました",
|
||||
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
|
||||
"providers": "プロバイダー",
|
||||
"addProviderHint": "プロバイダーを追加して開始",
|
||||
"verified": "検証済み",
|
||||
"configuration": "設定",
|
||||
"displayName": "表示名",
|
||||
"awsAccessKeyId": "AWS アクセスキー ID",
|
||||
"awsSecretAccessKey": "AWS シークレットアクセスキー",
|
||||
"awsRegion": "AWS リージョン",
|
||||
"selectRegion": "リージョンを選択",
|
||||
"apiKey": "API キー",
|
||||
"enterApiKey": "API キーを入力",
|
||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||
"baseUrl": "ベース URL",
|
||||
"optional": "(オプション)",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"models": "モデル",
|
||||
"customModelId": "カスタムモデル ID...",
|
||||
"allAdded": "すべて追加済み",
|
||||
"suggested": "おすすめ",
|
||||
"noModelsConfigured": "モデルが設定されていません",
|
||||
"modelIdEmpty": "モデル ID は空にできません",
|
||||
"modelIdExists": "このモデル ID は既に存在します",
|
||||
"configureProviders": "AI プロバイダーを設定",
|
||||
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
|
||||
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
||||
"typeToConfirm": "確認のため「{name}」と入力",
|
||||
"typeProviderName": "プロバイダー名を入力...",
|
||||
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
|
||||
"validationFailedCount": "{count} 個のモデルの検証に失敗",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"clickToChange": "(クリックして変更)",
|
||||
"usingServerDefault": "サーバーデフォルトモデルを使用中",
|
||||
"selectModel": "モデルを選択",
|
||||
"searchModels": "モデルを検索...",
|
||||
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
|
||||
"noModelsFound": "モデルが見つかりません。",
|
||||
"default": "デフォルト",
|
||||
"serverDefault": "サーバーデフォルト",
|
||||
"configureModels": "モデルを設定...",
|
||||
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||
"unvalidatedModelWarning": "このモデルは検証されていません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"about": "关于",
|
||||
"editor": "编辑器",
|
||||
"newChat": "开始新对话",
|
||||
"github": "GitHub",
|
||||
"settings": "设置",
|
||||
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
||||
"showPanel": "显示聊天面板 (Ctrl+B)",
|
||||
@@ -45,6 +46,7 @@
|
||||
"copyResponse": "复制响应",
|
||||
"copied": "已复制!",
|
||||
"failedToCopy": "复制失败",
|
||||
"failedToCopyDetail": "复制消息失败。请手动复制或检查剪贴板权限。",
|
||||
"goodResponse": "有帮助",
|
||||
"badResponse": "无帮助",
|
||||
"clickToEdit": "点击编辑",
|
||||
@@ -87,6 +89,8 @@
|
||||
"overrides": "覆盖",
|
||||
"clearSettings": "清除设置",
|
||||
"useServerDefault": "使用服务器默认值",
|
||||
"language": "语言",
|
||||
"languageDescription": "选择界面语言。",
|
||||
"theme": "主题",
|
||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||
"drawioStyle": "DrawIO 样式",
|
||||
@@ -95,7 +99,13 @@
|
||||
"minimal": "简约",
|
||||
"sketch": "草图",
|
||||
"closeProtection": "关闭确认",
|
||||
"closeProtectionDescription": "离开页面时显示确认。"
|
||||
"closeProtectionDescription": "离开页面时显示确认。",
|
||||
"diagramStyle": "图表样式",
|
||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||
"diagramActions": "图表操作",
|
||||
"diagramActionsDescription": "管理图表历史记录和导出",
|
||||
"history": "历史记录",
|
||||
"download": "下载"
|
||||
},
|
||||
"save": {
|
||||
"title": "保存图表",
|
||||
@@ -132,6 +142,7 @@
|
||||
"invalidAccessCode": "无效或缺少访问码。请在设置中配置。",
|
||||
"networkError": "网络错误。请检查您的连接。",
|
||||
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
|
||||
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
|
||||
"validationFailed": "图表验证失败。请尝试重新生成。",
|
||||
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
|
||||
"failedToProcess": "无法处理图表。请尝试重新生成。",
|
||||
@@ -140,17 +151,22 @@
|
||||
"failedToRestore": "无法从 localStorage 恢复",
|
||||
"failedToPersist": "卸载前无法持久化状态",
|
||||
"failedToExport": "获取图表数据时出错",
|
||||
"failedToLoadExample": "加载示例图片时出错"
|
||||
"failedToLoadExample": "加载示例图片时出错",
|
||||
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已达每日配额",
|
||||
"tokenLimit": "已达每日令牌限制",
|
||||
"tpmLimit": "速率限制",
|
||||
"tpmMessage": "请求过多。请稍等片刻。",
|
||||
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
||||
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
||||
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
||||
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||
"reset": "您的限制将在明天重置。感谢您的理解!",
|
||||
"reset": "您的限制将在明天重置。感谢您的理解。",
|
||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
||||
"configModel": "使用您的密钥",
|
||||
"selfHost": "自托管",
|
||||
"sponsor": "赞助",
|
||||
"learnMore": "了解更多 →",
|
||||
@@ -174,11 +190,89 @@
|
||||
"thoughtFor": "思考了 {duration} 秒",
|
||||
"thoughtBrief": "思考了几秒钟"
|
||||
},
|
||||
"dev": {
|
||||
"title": "开发:XML 流式模拟器",
|
||||
"preset": "预设:",
|
||||
"selectPreset": "选择预设...",
|
||||
"clear": "清除",
|
||||
"placeholder": "在此粘贴 mxCell XML 或选择预设...",
|
||||
"interval": "间隔:",
|
||||
"chars": "字符:",
|
||||
"streaming": "流式传输中...",
|
||||
"simulate": "模拟",
|
||||
"stop": "停止",
|
||||
"testQuotaToast": "测试配额提示",
|
||||
"simulatingMessage": "[开发] 模拟 XML 流式传输",
|
||||
"successMessage": "成功显示图表。"
|
||||
},
|
||||
"about": {
|
||||
"modelChange": "模型变更与用量限制",
|
||||
"walletCrying": "(别名:我的钱包顶不住了)",
|
||||
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
||||
"contactMe": "联系我",
|
||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
||||
},
|
||||
"modelConfig": {
|
||||
"title": "AI 模型配置",
|
||||
"description": "配置多个 AI 提供商和模型",
|
||||
"configure": "配置",
|
||||
"addProvider": "添加提供商",
|
||||
"addModel": "添加模型",
|
||||
"modelId": "模型 ID",
|
||||
"modelLabel": "显示名称",
|
||||
"streaming": "启用流式输出",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteModel": "删除模型",
|
||||
"noModels": "尚未配置模型。添加模型以开始使用。",
|
||||
"selectProvider": "选择一个提供商或添加新的",
|
||||
"configureMultiple": "配置多个 AI 提供商并轻松切换",
|
||||
"apiKeyStored": "API 密钥存储在您的浏览器本地",
|
||||
"test": "测试",
|
||||
"validationError": "验证失败",
|
||||
"addModelFirst": "请先添加至少一个模型以进行验证",
|
||||
"providers": "提供商",
|
||||
"addProviderHint": "添加提供商即可开始使用",
|
||||
"verified": "已验证",
|
||||
"configuration": "配置",
|
||||
"displayName": "显示名称",
|
||||
"awsAccessKeyId": "AWS 访问密钥 ID",
|
||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||
"awsRegion": "AWS 区域",
|
||||
"selectRegion": "选择区域",
|
||||
"apiKey": "API 密钥",
|
||||
"enterApiKey": "输入您的 API 密钥",
|
||||
"enterSecretKey": "输入您的 Secret Key",
|
||||
"baseUrl": "基础 URL",
|
||||
"optional": "(可选)",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
"models": "模型",
|
||||
"customModelId": "自定义模型 ID...",
|
||||
"allAdded": "已全部添加",
|
||||
"suggested": "推荐",
|
||||
"noModelsConfigured": "尚未配置模型",
|
||||
"modelIdEmpty": "模型 ID 不能为空",
|
||||
"modelIdExists": "此模型 ID 已存在",
|
||||
"configureProviders": "配置 AI 提供商",
|
||||
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
|
||||
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
|
||||
"typeToConfirm": "输入 \"{name}\" 以确认",
|
||||
"typeProviderName": "输入提供商名称...",
|
||||
"modelsConfiguredCount": "已配置 {count} 个模型",
|
||||
"validationFailedCount": "{count} 个模型验证失败",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"clickToChange": "(点击更改)",
|
||||
"usingServerDefault": "使用服务器默认模型",
|
||||
"selectModel": "选择模型",
|
||||
"searchModels": "搜索模型...",
|
||||
"noVerifiedModels": "没有已验证的模型。请先测试您的模型。",
|
||||
"noModelsFound": "未找到模型。",
|
||||
"default": "默认",
|
||||
"serverDefault": "服务器默认",
|
||||
"configureModels": "配置模型...",
|
||||
"onlyVerifiedShown": "仅显示已验证的模型",
|
||||
"showUnvalidatedModels": "显示未验证的模型",
|
||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||
"unvalidatedModelWarning": "此模型尚未验证"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ export function getLangfuseClient(): LangfuseClient | null {
|
||||
return langfuseClient
|
||||
}
|
||||
|
||||
// Check if Langfuse is configured
|
||||
// Check if Langfuse is configured (both keys required)
|
||||
export function isLangfuseEnabled(): boolean {
|
||||
return !!process.env.LANGFUSE_PUBLIC_KEY
|
||||
return !!(
|
||||
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
|
||||
)
|
||||
}
|
||||
|
||||
// Update trace with input data at the start of request
|
||||
@@ -43,34 +45,16 @@ export function setTraceInput(params: {
|
||||
}
|
||||
|
||||
// Update trace with output and end the span
|
||||
export function setTraceOutput(
|
||||
output: string,
|
||||
usage?: { promptTokens?: number; completionTokens?: number },
|
||||
) {
|
||||
// Note: AI SDK 6 telemetry automatically reports token usage on its spans,
|
||||
// so we only need to set the output text and close our wrapper span
|
||||
export function setTraceOutput(output: string) {
|
||||
if (!isLangfuseEnabled()) return
|
||||
|
||||
updateActiveTrace({ output })
|
||||
|
||||
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
|
||||
const activeSpan = api.trace.getActiveSpan()
|
||||
if (activeSpan) {
|
||||
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
|
||||
if (usage?.promptTokens) {
|
||||
activeSpan.setAttribute("ai.usage.promptTokens", usage.promptTokens)
|
||||
activeSpan.setAttribute(
|
||||
"gen_ai.usage.input_tokens",
|
||||
usage.promptTokens,
|
||||
)
|
||||
}
|
||||
if (usage?.completionTokens) {
|
||||
activeSpan.setAttribute(
|
||||
"ai.usage.completionTokens",
|
||||
usage.completionTokens,
|
||||
)
|
||||
activeSpan.setAttribute(
|
||||
"gen_ai.usage.output_tokens",
|
||||
usage.completionTokens,
|
||||
)
|
||||
}
|
||||
activeSpan.end()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ export const STORAGE_KEYS = {
|
||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||
aiModel: "next-ai-draw-io-ai-model",
|
||||
|
||||
// Multi-model configuration
|
||||
modelConfigs: "next-ai-draw-io-model-configs",
|
||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||
} as const
|
||||
|
||||
@@ -99,9 +99,9 @@ When using edit_diagram tool:
|
||||
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
||||
- For delete: only cell_id is needed
|
||||
- Find the cell_id from "Current diagram XML" in system context
|
||||
- Example update: {"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
- Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]}
|
||||
- Example add: {"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||
|
||||
@@ -276,15 +276,15 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
||||
**Operations:**
|
||||
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
||||
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||
- **delete**: Remove a cell. Only cell_id is needed.
|
||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||
|
||||
**Input Format:**
|
||||
\`\`\`json
|
||||
{
|
||||
"operations": [
|
||||
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
||||
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
||||
{"type": "delete", "cell_id": "5"}
|
||||
{"operation": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
||||
{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
||||
{"operation": "delete", "cell_id": "5"}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
@@ -293,17 +293,17 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
||||
|
||||
Change label:
|
||||
\`\`\`json
|
||||
{"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
\`\`\`
|
||||
|
||||
Add new shape:
|
||||
\`\`\`json
|
||||
{"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
{"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 cell:
|
||||
Delete container (children & edges auto-deleted):
|
||||
\`\`\`json
|
||||
{"operations": [{"type": "delete", "cell_id": "5"}]}
|
||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}
|
||||
\`\`\`
|
||||
|
||||
**Error Recovery:**
|
||||
|
||||
306
lib/types/model-config.ts
Normal file
306
lib/types/model-config.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// Types for multi-provider model configuration
|
||||
|
||||
export type ProviderName =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "azure"
|
||||
| "bedrock"
|
||||
| "openrouter"
|
||||
| "deepseek"
|
||||
| "siliconflow"
|
||||
| "sglang"
|
||||
| "gateway"
|
||||
| "edgeone"
|
||||
| "doubao"
|
||||
|
||||
// Individual model configuration
|
||||
export interface ModelConfig {
|
||||
id: string // UUID for this model
|
||||
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
|
||||
validated?: boolean // Has this model been validated
|
||||
validationError?: string // Error message if validation failed
|
||||
}
|
||||
|
||||
// Provider configuration
|
||||
export interface ProviderConfig {
|
||||
id: string // UUID for this provider config
|
||||
provider: ProviderName
|
||||
name?: string // Custom display name (e.g., "OpenAI Production")
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
// AWS Bedrock specific fields
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
awsSessionToken?: string // Optional, for temporary credentials
|
||||
models: ModelConfig[]
|
||||
validated?: boolean // Has API key been validated
|
||||
}
|
||||
|
||||
// The complete multi-model configuration
|
||||
export interface MultiModelConfig {
|
||||
version: 1
|
||||
providers: ProviderConfig[]
|
||||
selectedModelId?: string // Currently selected model's UUID
|
||||
showUnvalidatedModels?: boolean // Show models that haven't been validated
|
||||
}
|
||||
|
||||
// Flattened model for dropdown display
|
||||
export interface FlattenedModel {
|
||||
id: string // Model config UUID
|
||||
modelId: string // Actual model ID
|
||||
provider: ProviderName
|
||||
providerLabel: string // Provider display name
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
// AWS Bedrock specific fields
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
awsSessionToken?: string
|
||||
validated?: boolean // Has this model been validated
|
||||
}
|
||||
|
||||
// Provider metadata
|
||||
export const PROVIDER_INFO: Record<
|
||||
ProviderName,
|
||||
{ label: string; defaultBaseUrl?: string }
|
||||
> = {
|
||||
openai: { label: "OpenAI" },
|
||||
anthropic: {
|
||||
label: "Anthropic",
|
||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||
},
|
||||
google: { label: "Google" },
|
||||
azure: { label: "Azure OpenAI" },
|
||||
bedrock: { label: "Amazon Bedrock" },
|
||||
openrouter: { label: "OpenRouter" },
|
||||
deepseek: { label: "DeepSeek" },
|
||||
siliconflow: {
|
||||
label: "SiliconFlow",
|
||||
defaultBaseUrl: "https://api.siliconflow.com/v1",
|
||||
},
|
||||
sglang: {
|
||||
label: "SGLang",
|
||||
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
||||
},
|
||||
gateway: { label: "AI Gateway" },
|
||||
edgeone: { label: "EdgeOne Pages" },
|
||||
doubao: {
|
||||
label: "Doubao (ByteDance)",
|
||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
}
|
||||
|
||||
// Suggested models per provider for quick add
|
||||
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
||||
openai: [
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5.2-chat-latest",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-chat-latest",
|
||||
"gpt-5.1",
|
||||
"gpt-5-pro",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-codex",
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
anthropic: [
|
||||
// Claude 4.5 series (latest)
|
||||
"claude-opus-4-5-20250514",
|
||||
"claude-sonnet-4-5-20250514",
|
||||
// Claude 4 series
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-20250514",
|
||||
// Claude 3.7 series
|
||||
"claude-3-7-sonnet-20250219",
|
||||
// Claude 3.5 series
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
// Claude 3 series
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
],
|
||||
google: [
|
||||
// Gemini 2.5 series
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
// Gemini 2.0 series
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-flash-lite",
|
||||
// Gemini 1.5 series
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
// Legacy
|
||||
"gemini-pro",
|
||||
],
|
||||
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||
bedrock: [
|
||||
// Anthropic Claude
|
||||
"anthropic.claude-opus-4-5-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"anthropic.claude-3-opus-20240229-v1:0",
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||
// Amazon Nova
|
||||
"amazon.nova-pro-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"amazon.nova-micro-v1:0",
|
||||
// Meta Llama
|
||||
"meta.llama3-3-70b-instruct-v1:0",
|
||||
"meta.llama3-1-405b-instruct-v1:0",
|
||||
"meta.llama3-1-70b-instruct-v1:0",
|
||||
// Mistral
|
||||
"mistral.mistral-large-2411-v1:0",
|
||||
"mistral.mistral-small-2503-v1:0",
|
||||
],
|
||||
openrouter: [
|
||||
// Anthropic
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-opus-4",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3.5-haiku",
|
||||
// OpenAI
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/o1",
|
||||
"openai/o3-mini",
|
||||
// Google
|
||||
"google/gemini-2.5-pro",
|
||||
"google/gemini-2.5-flash",
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
// Meta Llama
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"meta-llama/llama-3.1-405b-instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
// DeepSeek
|
||||
"deepseek/deepseek-chat",
|
||||
"deepseek/deepseek-r1",
|
||||
// Qwen
|
||||
"qwen/qwen-2.5-72b-instruct",
|
||||
],
|
||||
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
|
||||
siliconflow: [
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-V3",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
"deepseek-ai/DeepSeek-V2.5",
|
||||
// Qwen
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
"Qwen/Qwen2.5-32B-Instruct",
|
||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||
"Qwen/Qwen2.5-7B-Instruct",
|
||||
"Qwen/Qwen2-VL-72B-Instruct",
|
||||
],
|
||||
sglang: [
|
||||
// SGLang is OpenAI-compatible, models depend on deployment
|
||||
"default",
|
||||
],
|
||||
gateway: [
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-4o-mini",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-3-5-sonnet",
|
||||
"google/gemini-2.0-flash",
|
||||
],
|
||||
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
|
||||
doubao: [
|
||||
// ByteDance Doubao models
|
||||
"doubao-1.5-thinking-pro-250415",
|
||||
"doubao-1.5-thinking-pro-m-250428",
|
||||
"doubao-1.5-pro-32k-250115",
|
||||
"doubao-1.5-pro-256k-250115",
|
||||
"doubao-pro-32k-241215",
|
||||
"doubao-pro-256k-241215",
|
||||
],
|
||||
}
|
||||
|
||||
// Helper to generate UUID
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
// Create empty config
|
||||
export function createEmptyConfig(): MultiModelConfig {
|
||||
return {
|
||||
version: 1,
|
||||
providers: [],
|
||||
selectedModelId: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Create new provider config
|
||||
export function createProviderConfig(provider: ProviderName): ProviderConfig {
|
||||
return {
|
||||
id: generateId(),
|
||||
provider,
|
||||
apiKey: "",
|
||||
baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,
|
||||
models: [],
|
||||
validated: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Create new model config
|
||||
export function createModelConfig(modelId: string): ModelConfig {
|
||||
return {
|
||||
id: generateId(),
|
||||
modelId,
|
||||
}
|
||||
}
|
||||
|
||||
// Get all models as flattened list for dropdown
|
||||
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
||||
const models: FlattenedModel[] = []
|
||||
|
||||
for (const provider of config.providers) {
|
||||
// Use custom name if provided, otherwise use default provider label
|
||||
const providerLabel =
|
||||
provider.name || PROVIDER_INFO[provider.provider].label
|
||||
|
||||
for (const model of provider.models) {
|
||||
models.push({
|
||||
id: model.id,
|
||||
modelId: model.modelId,
|
||||
provider: provider.provider,
|
||||
providerLabel,
|
||||
apiKey: provider.apiKey,
|
||||
baseUrl: provider.baseUrl,
|
||||
// AWS Bedrock fields
|
||||
awsAccessKeyId: provider.awsAccessKeyId,
|
||||
awsSecretAccessKey: provider.awsSecretAccessKey,
|
||||
awsRegion: provider.awsRegion,
|
||||
awsSessionToken: provider.awsSessionToken,
|
||||
validated: model.validated,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
// Find model by ID
|
||||
export function findModelById(
|
||||
config: MultiModelConfig,
|
||||
modelId: string,
|
||||
): FlattenedModel | undefined {
|
||||
return flattenModels(config).find((m) => m.id === modelId)
|
||||
}
|
||||
@@ -1,245 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { QuotaLimitToast } from "@/components/quota-limit-toast"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
|
||||
export interface QuotaConfig {
|
||||
dailyRequestLimit: number
|
||||
dailyTokenLimit: number
|
||||
tpmLimit: number
|
||||
}
|
||||
|
||||
export interface QuotaCheckResult {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
used: number
|
||||
onConfigModel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing request/token quotas and rate limiting.
|
||||
* Handles three types of limits:
|
||||
* - Daily request limit
|
||||
* - Daily token limit
|
||||
* - Tokens per minute (TPM) rate limit
|
||||
*
|
||||
* Users with their own API key bypass all limits.
|
||||
* Hook for displaying quota limit toasts.
|
||||
* Server-side handles actual quota enforcement via DynamoDB.
|
||||
* This hook only provides UI feedback when limits are exceeded.
|
||||
*/
|
||||
export function useQuotaManager(config: QuotaConfig): {
|
||||
hasOwnApiKey: () => boolean
|
||||
checkDailyLimit: () => QuotaCheckResult
|
||||
checkTokenLimit: () => QuotaCheckResult
|
||||
checkTPMLimit: () => QuotaCheckResult
|
||||
incrementRequestCount: () => void
|
||||
incrementTokenCount: (tokens: number) => void
|
||||
incrementTPMCount: (tokens: number) => void
|
||||
showQuotaLimitToast: () => void
|
||||
showTokenLimitToast: (used: number) => void
|
||||
showTPMLimitToast: () => void
|
||||
showQuotaLimitToast: (used?: number, limit?: number) => void
|
||||
showTokenLimitToast: (used?: number, limit?: number) => void
|
||||
showTPMLimitToast: (limit?: number) => void
|
||||
} {
|
||||
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
|
||||
|
||||
// Check if user has their own API key configured (bypass limits)
|
||||
const hasOwnApiKey = useCallback((): boolean => {
|
||||
const provider = localStorage.getItem(STORAGE_KEYS.aiProvider)
|
||||
const apiKey = localStorage.getItem(STORAGE_KEYS.aiApiKey)
|
||||
return !!(provider && apiKey)
|
||||
}, [])
|
||||
|
||||
// Generic helper: Parse count from localStorage with NaN guard
|
||||
const parseStorageCount = (key: string): number => {
|
||||
const count = parseInt(localStorage.getItem(key) || "0", 10)
|
||||
return Number.isNaN(count) ? 0 : count
|
||||
}
|
||||
|
||||
// Generic helper: Create quota checker factory
|
||||
const createQuotaChecker = useCallback(
|
||||
(
|
||||
getTimeKey: () => string,
|
||||
timeStorageKey: string,
|
||||
countStorageKey: string,
|
||||
limit: number,
|
||||
) => {
|
||||
return (): QuotaCheckResult => {
|
||||
if (hasOwnApiKey())
|
||||
return { allowed: true, remaining: -1, used: 0 }
|
||||
if (limit <= 0) return { allowed: true, remaining: -1, used: 0 }
|
||||
|
||||
const currentTime = getTimeKey()
|
||||
const storedTime = localStorage.getItem(timeStorageKey)
|
||||
let count = parseStorageCount(countStorageKey)
|
||||
|
||||
if (storedTime !== currentTime) {
|
||||
count = 0
|
||||
localStorage.setItem(timeStorageKey, currentTime)
|
||||
localStorage.setItem(countStorageKey, "0")
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: count < limit,
|
||||
remaining: limit - count,
|
||||
used: count,
|
||||
}
|
||||
}
|
||||
},
|
||||
[hasOwnApiKey],
|
||||
)
|
||||
|
||||
// Generic helper: Create quota incrementer factory
|
||||
const createQuotaIncrementer = useCallback(
|
||||
(
|
||||
getTimeKey: () => string,
|
||||
timeStorageKey: string,
|
||||
countStorageKey: string,
|
||||
validateInput: boolean = false,
|
||||
) => {
|
||||
return (tokens: number = 1): void => {
|
||||
if (validateInput && (!Number.isFinite(tokens) || tokens <= 0))
|
||||
return
|
||||
|
||||
const currentTime = getTimeKey()
|
||||
const storedTime = localStorage.getItem(timeStorageKey)
|
||||
let count = parseStorageCount(countStorageKey)
|
||||
|
||||
if (storedTime !== currentTime) {
|
||||
count = 0
|
||||
localStorage.setItem(timeStorageKey, currentTime)
|
||||
}
|
||||
|
||||
localStorage.setItem(countStorageKey, String(count + tokens))
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Check daily request limit
|
||||
const checkDailyLimit = useMemo(
|
||||
() =>
|
||||
createQuotaChecker(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.requestDate,
|
||||
STORAGE_KEYS.requestCount,
|
||||
dailyRequestLimit,
|
||||
),
|
||||
[createQuotaChecker, dailyRequestLimit],
|
||||
)
|
||||
|
||||
// Increment request count
|
||||
const incrementRequestCount = useMemo(
|
||||
() =>
|
||||
createQuotaIncrementer(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.requestDate,
|
||||
STORAGE_KEYS.requestCount,
|
||||
false,
|
||||
),
|
||||
[createQuotaIncrementer],
|
||||
)
|
||||
const { dailyRequestLimit, dailyTokenLimit, tpmLimit, onConfigModel } =
|
||||
config
|
||||
const dict = useDictionary()
|
||||
|
||||
// Show quota limit toast (request-based)
|
||||
const showQuotaLimitToast = useCallback(() => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<QuotaLimitToast
|
||||
used={dailyRequestLimit}
|
||||
limit={dailyRequestLimit}
|
||||
onDismiss={() => toast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
{ duration: 15000 },
|
||||
)
|
||||
}, [dailyRequestLimit])
|
||||
|
||||
// Check daily token limit
|
||||
const checkTokenLimit = useMemo(
|
||||
() =>
|
||||
createQuotaChecker(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.tokenDate,
|
||||
STORAGE_KEYS.tokenCount,
|
||||
dailyTokenLimit,
|
||||
),
|
||||
[createQuotaChecker, dailyTokenLimit],
|
||||
)
|
||||
|
||||
// Increment token count
|
||||
const incrementTokenCount = useMemo(
|
||||
() =>
|
||||
createQuotaIncrementer(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.tokenDate,
|
||||
STORAGE_KEYS.tokenCount,
|
||||
true, // Validate input tokens
|
||||
),
|
||||
[createQuotaIncrementer],
|
||||
)
|
||||
|
||||
// Show token limit toast
|
||||
const showTokenLimitToast = useCallback(
|
||||
(used: number) => {
|
||||
const showQuotaLimitToast = useCallback(
|
||||
(used?: number, limit?: number) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<QuotaLimitToast
|
||||
type="token"
|
||||
used={used}
|
||||
limit={dailyTokenLimit}
|
||||
used={used ?? dailyRequestLimit}
|
||||
limit={limit ?? dailyRequestLimit}
|
||||
onDismiss={() => toast.dismiss(t)}
|
||||
onConfigModel={onConfigModel}
|
||||
/>
|
||||
),
|
||||
{ duration: 15000 },
|
||||
)
|
||||
},
|
||||
[dailyTokenLimit],
|
||||
[dailyRequestLimit, onConfigModel],
|
||||
)
|
||||
|
||||
// Check TPM (tokens per minute) limit
|
||||
const checkTPMLimit = useMemo(
|
||||
() =>
|
||||
createQuotaChecker(
|
||||
() => Math.floor(Date.now() / 60000).toString(),
|
||||
STORAGE_KEYS.tpmMinute,
|
||||
STORAGE_KEYS.tpmCount,
|
||||
tpmLimit,
|
||||
),
|
||||
[createQuotaChecker, tpmLimit],
|
||||
)
|
||||
|
||||
// Increment TPM count
|
||||
const incrementTPMCount = useMemo(
|
||||
() =>
|
||||
createQuotaIncrementer(
|
||||
() => Math.floor(Date.now() / 60000).toString(),
|
||||
STORAGE_KEYS.tpmMinute,
|
||||
STORAGE_KEYS.tpmCount,
|
||||
true, // Validate input tokens
|
||||
),
|
||||
[createQuotaIncrementer],
|
||||
// Show token limit toast
|
||||
const showTokenLimitToast = useCallback(
|
||||
(used?: number, limit?: number) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<QuotaLimitToast
|
||||
type="token"
|
||||
used={used ?? dailyTokenLimit}
|
||||
limit={limit ?? dailyTokenLimit}
|
||||
onDismiss={() => toast.dismiss(t)}
|
||||
onConfigModel={onConfigModel}
|
||||
/>
|
||||
),
|
||||
{ duration: 15000 },
|
||||
)
|
||||
},
|
||||
[dailyTokenLimit, onConfigModel],
|
||||
)
|
||||
|
||||
// Show TPM limit toast
|
||||
const showTPMLimitToast = useCallback(() => {
|
||||
const limitDisplay =
|
||||
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
|
||||
toast.error(
|
||||
`Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`,
|
||||
{ duration: 8000 },
|
||||
)
|
||||
}, [tpmLimit])
|
||||
const showTPMLimitToast = useCallback(
|
||||
(limit?: number) => {
|
||||
const effectiveLimit = limit ?? tpmLimit
|
||||
const limitDisplay =
|
||||
effectiveLimit >= 1000
|
||||
? `${effectiveLimit / 1000}k`
|
||||
: String(effectiveLimit)
|
||||
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
||||
limit: limitDisplay,
|
||||
seconds: 60,
|
||||
})
|
||||
toast.error(message, { duration: 8000 })
|
||||
},
|
||||
[tpmLimit, dict],
|
||||
)
|
||||
|
||||
return {
|
||||
// Check functions
|
||||
hasOwnApiKey,
|
||||
checkDailyLimit,
|
||||
checkTokenLimit,
|
||||
checkTPMLimit,
|
||||
|
||||
// Increment functions
|
||||
incrementRequestCount,
|
||||
incrementTokenCount,
|
||||
incrementTPMCount,
|
||||
|
||||
// Toast functions
|
||||
showQuotaLimitToast,
|
||||
showTokenLimitToast,
|
||||
showTPMLimitToast,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user