mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
3 Commits
fix/electr
...
chore/clea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
950a8754fa | ||
|
|
76c0308ceb | ||
|
|
d40ab4114c |
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@@ -20,42 +20,15 @@ npm run lint # Check lint errors
|
|||||||
npm run check # Run all checks (CI)
|
npm run check # Run all checks (CI)
|
||||||
```
|
```
|
||||||
|
|
||||||
Git hooks via Husky run automatically:
|
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
||||||
- **Pre-commit**: Biome (format/lint) + TypeScript type check
|
|
||||||
- **Pre-push**: Unit tests
|
|
||||||
|
|
||||||
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run tests before submitting PRs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test # Unit tests (Vitest)
|
|
||||||
npm run test:e2e # E2E tests (Playwright)
|
|
||||||
```
|
|
||||||
|
|
||||||
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
|
|
||||||
|
|
||||||
To run a specific test file:
|
|
||||||
```bash
|
|
||||||
npx playwright test tests/e2e/diagram-generation.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
To run tests with UI mode:
|
|
||||||
```bash
|
|
||||||
npx playwright test --ui
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
1. Create a feature branch
|
1. Create a feature branch
|
||||||
2. Make changes (pre-commit runs lint + type check automatically)
|
2. Make changes and ensure `npm run check` passes
|
||||||
3. Run E2E tests with `npm run test:e2e`
|
3. Submit PR against `main` with a clear description
|
||||||
4. Push (pre-push runs unit tests automatically)
|
|
||||||
5. Submit PR against `main` with a clear description
|
|
||||||
|
|
||||||
CI will run the full test suite on your PR.
|
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
|
|||||||
3
.github/renovate.json
vendored
3
.github/renovate.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended"],
|
"extends": ["config:recommended"],
|
||||||
"schedule": ["after 10am on the first day of the month"],
|
"schedule": ["after 10am on saturday"],
|
||||||
"timezone": "Asia/Tokyo",
|
"timezone": "Asia/Tokyo",
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
{
|
{
|
||||||
"matchUpdateTypes": ["major"],
|
"matchUpdateTypes": ["major"],
|
||||||
"matchPackagePatterns": ["*"],
|
"matchPackagePatterns": ["*"],
|
||||||
"groupName": "major dependencies",
|
|
||||||
"automerge": false
|
"automerge": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
81
.github/workflows/electron-release.yml
vendored
81
.github/workflows/electron-release.yml
vendored
@@ -11,8 +11,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Mac and Linux: Build and publish directly (no signing needed)
|
build:
|
||||||
build-mac-linux:
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
@@ -21,9 +20,13 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
platform: mac
|
platform: mac
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
platform: linux
|
platform: linux
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -34,80 +37,10 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Download draw.io static files for offline use
|
|
||||||
run: |
|
|
||||||
rm -rf public/drawio
|
|
||||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
|
||||||
mkdir -p public/drawio
|
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
|
||||||
rm -rf public/drawio/WEB-INF
|
|
||||||
rm -rf public/drawio/META-INF
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Build and publish
|
- name: Build and publish Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run dist:${{ matrix.platform }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Windows: Build, sign with SignPath, then publish
|
|
||||||
build-windows:
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Download draw.io static files for offline use
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
rm -rf public/drawio
|
|
||||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
|
||||||
mkdir -p public/drawio
|
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
|
||||||
rm -rf public/drawio/WEB-INF
|
|
||||||
rm -rf public/drawio/META-INF
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
# Build WITHOUT publishing
|
|
||||||
- name: Build Windows app
|
|
||||||
run: npm run dist:win:build
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Upload unsigned artifacts for signing
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
id: upload-unsigned
|
|
||||||
with:
|
|
||||||
name: windows-unsigned
|
|
||||||
path: release/*.exe
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
- name: Sign with SignPath
|
|
||||||
uses: signpath/github-action-submit-signing-request@v2
|
|
||||||
with:
|
|
||||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
||||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
|
||||||
project-slug: 'next-ai-draw-io'
|
|
||||||
signing-policy-slug: 'release-signing'
|
|
||||||
artifact-configuration-slug: 'windows-exe'
|
|
||||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
|
||||||
wait-for-completion: true
|
|
||||||
output-artifact-directory: release-signed
|
|
||||||
|
|
||||||
- name: Upload signed artifacts to release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: release-signed/*.exe
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
75
.github/workflows/test.yml
vendored
75
.github/workflows/test.yml
vendored
@@ -1,75 +0,0 @@
|
|||||||
name: Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-and-unit:
|
|
||||||
name: Lint & Unit Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: npm run check
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: npm run test -- --run
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: E2E Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
|
||||||
uses: actions/cache@v5
|
|
||||||
id: playwright-cache
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright
|
|
||||||
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
run: npx playwright install chromium --with-deps
|
|
||||||
|
|
||||||
- name: Install Playwright deps (cached)
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
||||||
run: npx playwright install-deps chromium
|
|
||||||
|
|
||||||
- name: Build app
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: npm run test:e2e
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
|
|
||||||
- name: Upload test results
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -14,8 +14,6 @@ packages/*/dist
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
/playwright-report/
|
|
||||||
/test-results/
|
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -56,8 +54,6 @@ push-via-ec2.sh
|
|||||||
/dist-electron/
|
/dist-electron/
|
||||||
/release/
|
/release/
|
||||||
/electron-standalone/
|
/electron-standalone/
|
||||||
# Draw.io static files (downloaded during CI build)
|
|
||||||
public/drawio/
|
|
||||||
*.dmg
|
*.dmg
|
||||||
*.exe
|
*.exe
|
||||||
*.AppImage
|
*.AppImage
|
||||||
@@ -66,9 +62,4 @@ public/drawio/
|
|||||||
*.snap
|
*.snap
|
||||||
|
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.spec-workflow
|
.spec-workflow
|
||||||
|
|
||||||
# edgeone
|
|
||||||
.edgeone
|
|
||||||
opencode.json
|
|
||||||
ai-models.json
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
npx lint-staged
|
npx lint-staged
|
||||||
npx tsc --noEmit
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Skip if node_modules not installed (e.g., on EC2 push server)
|
|
||||||
if [ -d "node_modules" ]; then
|
|
||||||
npm run test -- --run
|
|
||||||
fi
|
|
||||||
@@ -30,10 +30,6 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
|||||||
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||||
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||||
|
|
||||||
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
|
|
||||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
|
||||||
|
|
||||||
# Build Next.js application (standalone mode)
|
# Build Next.js application (standalone mode)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
137
README.md
137
README.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
||||||
|
|
||||||
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
English | [中文](./docs/README_CN.md) | [日本語](./docs/README_JA.md)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
@@ -36,16 +36,15 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Try it Online](#try-it-online)
|
- [Try it Online](#try-it-online)
|
||||||
- [Desktop Application](#desktop-application)
|
- [Desktop Application](#desktop-application)
|
||||||
- [Run with Docker](#run-with-docker)
|
- [Run with Docker (Recommended)](#run-with-docker-recommended)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
|
||||||
- [Deploy on Vercel](#deploy-on-vercel)
|
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
- [Multi-Provider Support](#multi-provider-support)
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
- [FAQ](#faq)
|
|
||||||
- [Star History](#star-history)
|
- [Star History](#star-history)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -101,7 +100,7 @@ Here are some example prompts and their generated diagrams:
|
|||||||
|
|
||||||
## MCP Server (Preview)
|
## MCP Server (Preview)
|
||||||
|
|
||||||
> **Preview Feature**: This feature is experimental and may not be stable.
|
> **Preview Feature**: This feature is experimental and may not stable.
|
||||||
|
|
||||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||||
|
|
||||||
@@ -145,11 +144,53 @@ No installation needed! Try the app directly on our demo site:
|
|||||||
|
|
||||||
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
||||||
|
|
||||||
Supported platforms: Windows, macOS, Linux.
|
| Platform | Download |
|
||||||
|
|----------|----------|
|
||||||
|
| macOS | `.dmg` (Intel & Apple Silicon) |
|
||||||
|
| Windows | `.exe` installer (x64 & ARM64) |
|
||||||
|
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
||||||
|
|
||||||
### Run with Docker
|
**Features:**
|
||||||
|
- **Secure API key storage**: Credentials encrypted using OS keychain
|
||||||
|
- **Configuration presets**: Save and switch between AI providers via menu
|
||||||
|
- **Native file dialogs**: Open/save `.drawio` files directly
|
||||||
|
- **Offline capable**: Works without internet after first launch
|
||||||
|
|
||||||
[Go to Docker Guide](./docs/en/docker.md)
|
**Quick Setup:**
|
||||||
|
1. Download and install for your platform
|
||||||
|
2. Open the app → **Menu → Configuration → Manage Presets**
|
||||||
|
3. Add your AI provider credentials
|
||||||
|
4. Start creating diagrams!
|
||||||
|
|
||||||
|
### Run with Docker (Recommended)
|
||||||
|
|
||||||
|
If you just want to run it locally, the best way is to use Docker.
|
||||||
|
|
||||||
|
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e AI_PROVIDER=openai \
|
||||||
|
-e AI_MODEL=gpt-4o \
|
||||||
|
-e OPENAI_API_KEY=your_api_key \
|
||||||
|
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use an env file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
|
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options.
|
||||||
|
|
||||||
|
> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./docs/offline-deployment.md) for configuration options.
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -158,35 +199,45 @@ Supported platforms: Windows, macOS, Linux.
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure your AI provider:
|
||||||
|
|
||||||
|
Create a `.env.local` file in the root directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
cp env.example .env.local
|
cp env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
|
Edit `.env.local` and configure your chosen provider:
|
||||||
|
|
||||||
2. Run the development server:
|
- Set `AI_PROVIDER` to your chosen provider (doubao,bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||||
|
- Set `AI_MODEL` to the specific model you want to use
|
||||||
|
- Add the required API keys for your provider
|
||||||
|
- `TEMPERATURE`: Optional temperature setting (e.g., `0` for deterministic output). Leave unset for models that don't support it (e.g., reasoning models).
|
||||||
|
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
|
||||||
|
|
||||||
|
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.
|
||||||
|
|
||||||
|
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
||||||
|
|
||||||
|
4. Run the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.
|
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Deploy to EdgeOne Pages
|
### Deploy on Vercel (Recommended)
|
||||||
|
|
||||||
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
|
|
||||||
|
|
||||||
Deploy by this button:
|
|
||||||
|
|
||||||
[](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
|
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
@@ -196,7 +247,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
|
|
||||||
### Deploy on Cloudflare Workers
|
### Deploy on Cloudflare Workers
|
||||||
|
|
||||||
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
|
[Go to Cloudflare Deploy Guide](./docs/Cloudflare_Deploy.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -207,28 +258,20 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
|
|
||||||
|
|
||||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||||
|
|
||||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
|
||||||
|
|
||||||
### Server-Side Multi-Model Configuration
|
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
|
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||||
|
|
||||||
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
Note that `claude` series has trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
||||||
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -241,6 +284,24 @@ The application uses the following technologies:
|
|||||||
|
|
||||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ # Next.js App Router
|
||||||
|
api/chat/ # Chat API endpoint with AI tools
|
||||||
|
page.tsx # Main page with DrawIO embed
|
||||||
|
components/ # React components
|
||||||
|
chat-panel.tsx # Chat interface with diagram control
|
||||||
|
chat-input.tsx # User input component with file upload
|
||||||
|
history-dialog.tsx # Diagram version history viewer
|
||||||
|
ui/ # UI components (buttons, cards, etc.)
|
||||||
|
contexts/ # React context providers
|
||||||
|
diagram-context.tsx # Global diagram state management
|
||||||
|
lib/ # Utility functions and helpers
|
||||||
|
ai-providers.ts # Multi-provider AI configuration
|
||||||
|
utils.ts # XML processing and conversion utilities
|
||||||
|
public/ # Static assets including example images
|
||||||
|
```
|
||||||
|
|
||||||
## Support & Contact
|
## Support & Contact
|
||||||
|
|
||||||
@@ -252,10 +313,6 @@ For support or inquiries, please open an issue on the GitHub repository or conta
|
|||||||
|
|
||||||
- Email: me[at]jiang.jp
|
- Email: me[at]jiang.jp
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ export const metadata: Metadata = {
|
|||||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${num / 1000}k`
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutCN() {
|
export default function AboutCN() {
|
||||||
|
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||||
|
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||||
|
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -61,6 +72,28 @@ export default function AboutCN() {
|
|||||||
<p className="text-xl text-gray-600 font-medium">
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<Link
|
||||||
|
href="/about/cn"
|
||||||
|
className="text-blue-600 font-semibold"
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<Link
|
||||||
|
href="/about/ja"
|
||||||
|
className="text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
@@ -97,6 +130,42 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Limits */}
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
当前使用限制:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyRequestLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
请求/天
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyTokenLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Token/天
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(tpmLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Token/分钟
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 my-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -292,13 +361,11 @@ export default function AboutCN() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${num / 1000}k`
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutJA() {
|
export default function AboutJA() {
|
||||||
|
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||||
|
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||||
|
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -69,6 +80,28 @@ export default function AboutJA() {
|
|||||||
AI搭載のダイアグラム作成ツール -
|
AI搭載のダイアグラム作成ツール -
|
||||||
チャット、描画、可視化
|
チャット、描画、可視化
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<Link
|
||||||
|
href="/about/cn"
|
||||||
|
className="text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<Link
|
||||||
|
href="/about/ja"
|
||||||
|
className="text-blue-600 font-semibold"
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
@@ -105,6 +138,42 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Limits */}
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
現在の使用制限:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyRequestLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
リクエスト/日
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyTokenLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
トークン/日
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(tpmLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
トークン/分
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 my-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -307,13 +376,11 @@ export default function AboutJA() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注:<code>claude-sonnet-4-5</code>
|
注:<code>claude-sonnet-4-5</code>
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${num / 1000}k`
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
|
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
||||||
|
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
||||||
|
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -69,6 +80,28 @@ export default function About() {
|
|||||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||||
Visualize
|
Visualize
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="text-blue-600 font-semibold"
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<Link
|
||||||
|
href="/about/cn"
|
||||||
|
className="text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<Link
|
||||||
|
href="/about/ja"
|
||||||
|
className="text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
@@ -107,6 +140,42 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Limits */}
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
Please note the current usage limits:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5">
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyRequestLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
requests/day
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(dailyTokenLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
tokens/day
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/60 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-amber-600">
|
||||||
|
{formatNumber(tpmLimit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
tokens/min
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="flex items-center gap-3 my-5">
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -326,13 +395,11 @@ export default function About() {
|
|||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
<li>Ollama</li>
|
<li>Ollama</li>
|
||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import ChatPanel from "@/components/chat-panel"
|
||||||
|
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -11,35 +12,54 @@ import {
|
|||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { isIndexedDBUsable } from "@/lib/session-storage"
|
|
||||||
|
const drawioBaseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const {
|
const {
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
saveDiagramToStorage,
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
|
||||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [closeProtection, setCloseProtection] = useState(false)
|
||||||
const [isElectron, setIsElectron] = useState(false)
|
|
||||||
const [canPersist, setCanPersist] = useState(false)
|
|
||||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
|
||||||
)
|
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
|
const isSavingRef = useRef(false)
|
||||||
|
const mouseOverDrawioRef = useRef(false)
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
|
|
||||||
|
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSaveDialog) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
isSavingRef.current = false
|
||||||
|
}, 1000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [showSaveDialog])
|
||||||
|
|
||||||
|
// Handle save from draw.io's built-in save button
|
||||||
|
// Note: draw.io sends save events for various reasons (focus changes, etc.)
|
||||||
|
// We use mouse position to determine if the user is interacting with draw.io
|
||||||
|
const handleDrawioSave = useCallback(() => {
|
||||||
|
if (!mouseOverDrawioRef.current) return
|
||||||
|
if (isSavingRef.current) return
|
||||||
|
isSavingRef.current = true
|
||||||
|
setShowSaveDialog(true)
|
||||||
|
}, [setShowSaveDialog])
|
||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore saved locale and redirect if needed
|
// Restore saved locale and redirect if needed
|
||||||
@@ -72,58 +92,34 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect Electron and use bundled draw.io files for offline use
|
const savedCloseProtection = localStorage.getItem(
|
||||||
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
)
|
||||||
const electronDetected =
|
if (savedCloseProtection === "true") {
|
||||||
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
setCloseProtection(true)
|
||||||
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
|
||||||
if (electronDetected) {
|
|
||||||
setIsElectron(true)
|
|
||||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const usable = await isIndexedDBUsable()
|
|
||||||
setCanPersist(usable)
|
|
||||||
})()
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
const handleDrawioLoad = useCallback(() => {
|
const handleDarkModeChange = async () => {
|
||||||
setIsDrawioReady(true)
|
await saveDiagramToStorage()
|
||||||
onDrawioLoad()
|
|
||||||
}, [onDrawioLoad])
|
|
||||||
|
|
||||||
const handleDrawioAutoSave = useCallback(
|
|
||||||
(data: { xml?: string }) => {
|
|
||||||
handleDiagramAutoSave(data)
|
|
||||||
// Only suppress modified state when persistence is available
|
|
||||||
if (canPersist) {
|
|
||||||
drawioRef.current?.status({ message: "", modified: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[canPersist, drawioRef, handleDiagramAutoSave],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDarkModeChange = () => {
|
|
||||||
const newValue = !darkMode
|
const newValue = !darkMode
|
||||||
setDarkMode(newValue)
|
setDarkMode(newValue)
|
||||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||||
document.documentElement.classList.toggle("dark", newValue)
|
document.documentElement.classList.toggle("dark", newValue)
|
||||||
setIsDrawioReady(false)
|
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrawioUiChange = () => {
|
const handleDrawioUiChange = async () => {
|
||||||
|
await saveDiagramToStorage()
|
||||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||||
localStorage.setItem("drawio-theme", newUi)
|
localStorage.setItem("drawio-theme", newUi)
|
||||||
setDrawioUi(newUi)
|
setDrawioUi(newUi)
|
||||||
setIsDrawioReady(false)
|
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile - reset draw.io before crossing breakpoint
|
// Check mobile - save diagram and reset draw.io before crossing breakpoint
|
||||||
const isInitialRenderRef = useRef(true)
|
const isInitialRenderRef = useRef(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -132,7 +128,7 @@ export default function Home() {
|
|||||||
!isInitialRenderRef.current &&
|
!isInitialRenderRef.current &&
|
||||||
newIsMobile !== isMobileRef.current
|
newIsMobile !== isMobileRef.current
|
||||||
) {
|
) {
|
||||||
setIsDrawioReady(false)
|
saveDiagramToStorage().catch(() => {})
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
isMobileRef.current = newIsMobile
|
isMobileRef.current = newIsMobile
|
||||||
@@ -143,7 +139,7 @@ export default function Home() {
|
|||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile)
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile)
|
||||||
}, [resetDrawioReady])
|
}, [saveDiagramToStorage, resetDrawioReady])
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
const toggleChatPanel = () => {
|
||||||
const panel = chatPanelRef.current
|
const panel = chatPanelRef.current
|
||||||
@@ -171,6 +167,20 @@ export default function Home() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Show confirmation dialog when user tries to leave the page
|
||||||
|
useEffect(() => {
|
||||||
|
if (!closeProtection) return
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
}, [closeProtection])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-background relative overflow-hidden">
|
<div className="h-screen bg-background relative overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -187,52 +197,34 @@ export default function Home() {
|
|||||||
className={`h-full relative ${
|
className={`h-full relative ${
|
||||||
isMobile ? "p-1" : "p-2"
|
isMobile ? "p-1" : "p-2"
|
||||||
}`}
|
}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
mouseOverDrawioRef.current = true
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
mouseOverDrawioRef.current = false
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
||||||
{isLoaded && (
|
{isLoaded ? (
|
||||||
<div
|
<DrawIoEmbed
|
||||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
key={`${drawioUi}-${darkMode}`}
|
||||||
>
|
ref={drawioRef}
|
||||||
<DrawIoEmbed
|
onExport={handleDiagramExport}
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
onLoad={onDrawioLoad}
|
||||||
ref={drawioRef}
|
onSave={handleDrawioSave}
|
||||||
autosave
|
baseUrl={drawioBaseUrl}
|
||||||
onAutoSave={handleDrawioAutoSave}
|
urlParameters={{
|
||||||
onExport={handleDiagramExport}
|
ui: drawioUi,
|
||||||
onLoad={handleDrawioLoad}
|
spin: true,
|
||||||
baseUrl={drawioBaseUrl}
|
libraries: false,
|
||||||
configuration={
|
saveAndExit: false,
|
||||||
canPersist
|
noExitBtn: true,
|
||||||
? { confirmExit: false }
|
dark: darkMode,
|
||||||
: undefined
|
}}
|
||||||
}
|
/>
|
||||||
urlParameters={{
|
) : (
|
||||||
ui: drawioUi,
|
<div className="h-full w-full flex items-center justify-center bg-background">
|
||||||
spin: false,
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||||
libraries: false,
|
|
||||||
// Disable modified tracking only when persistence is available
|
|
||||||
...(canPersist && {
|
|
||||||
modified: false,
|
|
||||||
keepmodified: false,
|
|
||||||
}),
|
|
||||||
saveAndExit: false,
|
|
||||||
noSaveBtn: true,
|
|
||||||
noExitBtn: true,
|
|
||||||
dark: darkMode,
|
|
||||||
lang: currentLang,
|
|
||||||
// Enable offline mode in Electron to disable external service calls
|
|
||||||
...(isElectron && {
|
|
||||||
offline: true,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(!isLoaded || !isDrawioReady) && (
|
|
||||||
<div className="h-full w-full bg-background flex items-center justify-center">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Draw.io panel is loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,23 +247,16 @@ export default function Home() {
|
|||||||
onExpand={() => setIsChatVisible(true)}
|
onExpand={() => setIsChatVisible(true)}
|
||||||
>
|
>
|
||||||
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
||||||
<Suspense
|
<ChatPanel
|
||||||
fallback={
|
isVisible={isChatVisible}
|
||||||
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
onToggleVisibility={toggleChatPanel}
|
||||||
Loading chat...
|
drawioUi={drawioUi}
|
||||||
</div>
|
onToggleDrawioUi={handleDrawioUiChange}
|
||||||
}
|
darkMode={darkMode}
|
||||||
>
|
onToggleDarkMode={handleDarkModeChange}
|
||||||
<ChatPanel
|
isMobile={isMobile}
|
||||||
isVisible={isChatVisible}
|
onCloseProtectionChange={setCloseProtection}
|
||||||
onToggleVisibility={toggleChatPanel}
|
/>
|
||||||
drawioUi={drawioUi}
|
|
||||||
onToggleDrawioUi={handleDrawioUiChange}
|
|
||||||
darkMode={darkMode}
|
|
||||||
onToggleDarkMode={handleDarkModeChange}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -12,17 +12,8 @@ import fs from "fs/promises"
|
|||||||
import { jsonrepair } from "jsonrepair"
|
import { jsonrepair } from "jsonrepair"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import {
|
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
||||||
getAIModel,
|
|
||||||
supportsImageInput,
|
|
||||||
supportsPromptCaching,
|
|
||||||
} from "@/lib/ai-providers"
|
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import {
|
|
||||||
isMinimalDiagram,
|
|
||||||
replaceHistoricalToolInputs,
|
|
||||||
validateFileParts,
|
|
||||||
} from "@/lib/chat-helpers"
|
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
@@ -34,12 +25,98 @@ import {
|
|||||||
setTraceOutput,
|
setTraceOutput,
|
||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
import { findServerModelById } from "@/lib/server-model-config"
|
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
export const maxDuration = 120
|
export const maxDuration = 120
|
||||||
|
|
||||||
|
// File upload limits (must match client-side)
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
|
const MAX_FILES = 5
|
||||||
|
|
||||||
|
// Helper function to validate file parts in messages
|
||||||
|
function validateFileParts(messages: any[]): {
|
||||||
|
valid: boolean
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
const fileParts =
|
||||||
|
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
||||||
|
|
||||||
|
if (fileParts.length > MAX_FILES) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePart of fileParts) {
|
||||||
|
// Data URLs format: data:image/png;base64,<data>
|
||||||
|
// Base64 increases size by ~33%, so we check the decoded size
|
||||||
|
if (filePart.url?.startsWith("data:")) {
|
||||||
|
const base64Data = filePart.url.split(",")[1]
|
||||||
|
if (base64Data) {
|
||||||
|
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||||
|
if (sizeInBytes > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if diagram is minimal/empty
|
||||||
|
function isMinimalDiagram(xml: string): boolean {
|
||||||
|
const stripped = xml.replace(/\s/g, "")
|
||||||
|
return !stripped.includes('id="2"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to replace historical tool call XML with placeholders
|
||||||
|
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
||||||
|
// Also fixes invalid/undefined inputs from interrupted streaming
|
||||||
|
function replaceHistoricalToolInputs(messages: any[]): any[] {
|
||||||
|
return messages.map((msg) => {
|
||||||
|
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
const replacedContent = msg.content
|
||||||
|
.map((part: any) => {
|
||||||
|
if (part.type === "tool-call") {
|
||||||
|
const toolName = part.toolName
|
||||||
|
// Fix invalid/undefined inputs from interrupted streaming
|
||||||
|
if (
|
||||||
|
!part.input ||
|
||||||
|
typeof part.input !== "object" ||
|
||||||
|
Object.keys(part.input).length === 0
|
||||||
|
) {
|
||||||
|
// Skip tool calls with invalid inputs entirely
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
toolName === "display_diagram" ||
|
||||||
|
toolName === "edit_diagram"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
input: {
|
||||||
|
placeholder:
|
||||||
|
"[XML content replaced - see current diagram XML in system context]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
.filter(Boolean) // Remove null entries (invalid tool calls)
|
||||||
|
return { ...msg, content: replacedContent }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to create cached stream response
|
// Helper function to create cached stream response
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
const toolCallId = `cached-${Date.now()}`
|
const toolCallId = `cached-${Date.now()}`
|
||||||
@@ -118,10 +195,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// === SERVER-SIDE QUOTA CHECK START ===
|
// === SERVER-SIDE QUOTA CHECK START ===
|
||||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||||
const hasOwnApiKey = !!(
|
const hasOwnApiKey = !!(
|
||||||
req.headers.get("x-ai-provider") &&
|
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
|
||||||
(req.headers.get("x-ai-api-key") ||
|
|
||||||
req.headers.get("x-aws-access-key-id") ||
|
|
||||||
req.headers.get("x-vertex-api-key"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||||
@@ -170,45 +244,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// === CACHE CHECK END ===
|
// === CACHE CHECK END ===
|
||||||
|
|
||||||
// Read client AI provider overrides from headers
|
// Read client AI provider overrides from headers
|
||||||
const provider = req.headers.get("x-ai-provider")
|
|
||||||
let baseUrl = req.headers.get("x-ai-base-url")
|
|
||||||
const selectedModelId = req.headers.get("x-selected-model-id")
|
|
||||||
|
|
||||||
// For EdgeOne provider, construct full URL from request origin
|
|
||||||
// because createOpenAI needs absolute URL, not relative path
|
|
||||||
if (provider === "edgeone" && !baseUrl) {
|
|
||||||
const origin = req.headers.get("origin") || new URL(req.url).origin
|
|
||||||
baseUrl = `${origin}/api/edgeai`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
|
||||||
const cookieHeader = req.headers.get("cookie")
|
|
||||||
|
|
||||||
// Check if this is a server model with custom env var names
|
|
||||||
let serverModelConfig: {
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
provider?: string
|
|
||||||
} = {}
|
|
||||||
if (selectedModelId?.startsWith("server:")) {
|
|
||||||
const serverModel = await findServerModelById(selectedModelId)
|
|
||||||
console.log(
|
|
||||||
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
|
|
||||||
)
|
|
||||||
if (serverModel) {
|
|
||||||
serverModelConfig = {
|
|
||||||
apiKeyEnv: serverModel.apiKeyEnv,
|
|
||||||
baseUrlEnv: serverModel.baseUrlEnv,
|
|
||||||
// Use actual provider from config (client header may have incorrect value due to ID format change)
|
|
||||||
provider: serverModel.provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientOverrides = {
|
const clientOverrides = {
|
||||||
// Server model provider takes precedence over client header
|
provider: req.headers.get("x-ai-provider"),
|
||||||
provider: serverModelConfig.provider || provider,
|
baseUrl: req.headers.get("x-ai-base-url"),
|
||||||
baseUrl,
|
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
// AWS Bedrock credentials
|
// AWS Bedrock credentials
|
||||||
@@ -216,24 +254,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||||
awsRegion: req.headers.get("x-aws-region"),
|
awsRegion: req.headers.get("x-aws-region"),
|
||||||
awsSessionToken: req.headers.get("x-aws-session-token"),
|
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||||
// Server model custom env var names
|
|
||||||
...serverModelConfig,
|
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
|
||||||
// Pass cookies for EdgeOne Pages authentication
|
|
||||||
...(provider === "edgeone" &&
|
|
||||||
cookieHeader && {
|
|
||||||
headers: { cookie: cookieHeader },
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read minimal style preference from header
|
// Read minimal style preference from header
|
||||||
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
const minimalStyle = req.headers.get("x-minimal-style") === "true"
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get AI model with optional client overrides
|
// Get AI model with optional client overrides
|
||||||
const { model, providerOptions, headers, modelId } =
|
const { model, providerOptions, headers, modelId } =
|
||||||
getAIModel(clientOverrides)
|
getAIModel(clientOverrides)
|
||||||
@@ -252,17 +277,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||||
[]
|
[]
|
||||||
|
|
||||||
// Check if user is sending images to a model that doesn't support them
|
|
||||||
// AI SDK silently drops unsupported parts, so we need to catch this early
|
|
||||||
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// User input only - XML is now in a separate cached system message
|
// User input only - XML is now in a separate cached system message
|
||||||
const formattedUserInput = `User input:
|
const formattedUserInput = `User input:
|
||||||
"""md
|
"""md
|
||||||
@@ -476,13 +490,6 @@ ${userInputText}
|
|||||||
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
||||||
// Fix `= "` instead of `: "`
|
// Fix `= "` instead of `: "`
|
||||||
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
||||||
// Fix inconsistent quote escaping in XML attributes within JSON strings
|
|
||||||
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
|
|
||||||
// Example: y="-20\" should be y=\"-20\"
|
|
||||||
inputToRepair = inputToRepair.replace(
|
|
||||||
/(\w+)="([^"]*?)\\"/g,
|
|
||||||
'$1=\\"$2\\"',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Use jsonrepair to fix truncated JSON
|
// Use jsonrepair to fix truncated JSON
|
||||||
const repairedInput = jsonrepair(inputToRepair)
|
const repairedInput = jsonrepair(inputToRepair)
|
||||||
@@ -598,7 +605,7 @@ Notes:
|
|||||||
Operations:
|
Operations:
|
||||||
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
||||||
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||||
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
- delete: Remove a cell by its id. Only cell_id is needed.
|
||||||
|
|
||||||
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
||||||
|
|
||||||
@@ -607,8 +614,8 @@ For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
|||||||
Example - Add a rectangle:
|
Example - Add a rectangle:
|
||||||
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
||||||
|
|
||||||
Example - Delete container (children & edges auto-deleted):
|
Example - Delete a cell:
|
||||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
operations: z
|
operations: z
|
||||||
.array(
|
.array(
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { extract } from "@extractus/article-extractor"
|
|
||||||
import { NextResponse } from "next/server"
|
|
||||||
import TurndownService from "turndown"
|
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
|
||||||
const EXTRACT_TIMEOUT_MS = 15000
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const { url } = await req.json()
|
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "URL is required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate URL format
|
|
||||||
try {
|
|
||||||
new URL(url)
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid URL format" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSRF protection
|
|
||||||
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Cannot access private/internal URLs" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract article content with timeout to avoid tying up server resources
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
controller.abort()
|
|
||||||
}, EXTRACT_TIMEOUT_MS)
|
|
||||||
|
|
||||||
let article
|
|
||||||
try {
|
|
||||||
article = await extract(url, undefined, {
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.name === "AbortError") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Timed out while fetching URL content" },
|
|
||||||
{ status: 504 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!article || !article.content) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Could not extract content from URL" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert HTML to Markdown
|
|
||||||
const turndownService = new TurndownService({
|
|
||||||
headingStyle: "atx",
|
|
||||||
codeBlockStyle: "fenced",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove unwanted elements before conversion
|
|
||||||
turndownService.remove(["script", "style", "iframe", "noscript"])
|
|
||||||
|
|
||||||
const markdown = turndownService.turndown(article.content)
|
|
||||||
|
|
||||||
// Check content length
|
|
||||||
if (markdown.length > MAX_CONTENT_LENGTH) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
title: article.title || "Untitled",
|
|
||||||
content: markdown,
|
|
||||||
charCount: markdown.length,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("URL extraction error:", error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch or parse URL content" },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { loadFlattenedServerModels } from "@/lib/server-model-config"
|
|
||||||
|
|
||||||
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
|
|
||||||
// This ensures Docker users can set these values when starting containers
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
return NextResponse.json({
|
|
||||||
models,
|
|
||||||
hasConfig: models.length > 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
/**
|
|
||||||
* API endpoint for VLM-based diagram validation.
|
|
||||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { streamObject } from "ai"
|
|
||||||
import { getValidationModel } from "@/lib/ai-providers"
|
|
||||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
|
||||||
import {
|
|
||||||
type ValidationResult,
|
|
||||||
ValidationResultSchema,
|
|
||||||
} from "@/lib/validation-schema"
|
|
||||||
|
|
||||||
export const maxDuration = 30
|
|
||||||
|
|
||||||
interface ValidateDiagramRequest {
|
|
||||||
imageData: string // Base64 PNG data URL
|
|
||||||
sessionId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default valid result for disabled/error cases
|
|
||||||
const DEFAULT_VALID_RESULT: ValidationResult = {
|
|
||||||
valid: true,
|
|
||||||
issues: [],
|
|
||||||
suggestions: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a streaming response for useObject compatibility.
|
|
||||||
* useObject expects text stream format, not plain JSON.
|
|
||||||
*/
|
|
||||||
function createStreamingResponse(result: ValidationResult): Response {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
// Stream the JSON as text (useObject parses this)
|
|
||||||
controller.enqueue(encoder.encode(JSON.stringify(result)))
|
|
||||||
controller.close()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
|
||||||
try {
|
|
||||||
// Check if VLM validation is enabled (default: true)
|
|
||||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
|
||||||
if (!enableValidation) {
|
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
|
||||||
}
|
|
||||||
|
|
||||||
const body: ValidateDiagramRequest = await req.json()
|
|
||||||
const { imageData, sessionId } = body
|
|
||||||
|
|
||||||
if (!imageData) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Missing imageData" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate image data format
|
|
||||||
if (
|
|
||||||
!imageData.startsWith("data:image/png;base64,") &&
|
|
||||||
!imageData.startsWith("data:image/")
|
|
||||||
) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Invalid image data format" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the validation model
|
|
||||||
let model
|
|
||||||
try {
|
|
||||||
model = getValidationModel()
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[validate-diagram] Validation model not available:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
// Return valid if no vision model is configured
|
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
|
||||||
const timeout =
|
|
||||||
Math.max(
|
|
||||||
1000,
|
|
||||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
|
||||||
) || 10000
|
|
||||||
|
|
||||||
// Stream the VLM response for useObject consumption
|
|
||||||
const result = streamObject({
|
|
||||||
model,
|
|
||||||
schema: ValidationResultSchema,
|
|
||||||
system: VALIDATION_SYSTEM_PROMPT,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "image",
|
|
||||||
image: imageData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Please analyze this diagram for visual quality issues.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
maxOutputTokens: 1024,
|
|
||||||
abortSignal: AbortSignal.timeout(timeout),
|
|
||||||
onFinish: ({ object }) => {
|
|
||||||
if (sessionId && object) {
|
|
||||||
console.log(
|
|
||||||
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.toTextStreamResponse()
|
|
||||||
} catch (error) {
|
|
||||||
// Log with session context if available
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
console.error("[validate-diagram] Error:", errorMessage)
|
|
||||||
|
|
||||||
// On error, return valid to not block the user
|
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,16 +3,74 @@ import { createAnthropic } from "@ai-sdk/anthropic"
|
|||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
import { createGateway } from "@ai-sdk/gateway"
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { generateText } from "ai"
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
||||||
|
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||||
|
*/
|
||||||
|
function isPrivateUrl(urlString: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// Block localhost
|
||||||
|
if (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block AWS/cloud metadata endpoints
|
||||||
|
if (
|
||||||
|
hostname === "169.254.169.254" ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IPv4 ranges
|
||||||
|
const ipv4Match = hostname.match(
|
||||||
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||||
|
)
|
||||||
|
if (ipv4Match) {
|
||||||
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
|
// 10.0.0.0/8
|
||||||
|
if (a === 10) return true
|
||||||
|
// 172.16.0.0/12
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true
|
||||||
|
// 192.168.0.0/16
|
||||||
|
if (a === 192 && b === 168) return true
|
||||||
|
// 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 169 && b === 254) return true
|
||||||
|
// 127.0.0.0/8 (loopback)
|
||||||
|
if (a === 127) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
// Invalid URL - block it
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ValidateRequest {
|
interface ValidateRequest {
|
||||||
provider: string
|
provider: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -22,8 +80,6 @@ interface ValidateRequest {
|
|||||||
awsAccessKeyId?: string
|
awsAccessKeyId?: string
|
||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
// Vertex AI specific
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -37,8 +93,6 @@ export async function POST(req: Request) {
|
|||||||
awsAccessKeyId,
|
awsAccessKeyId,
|
||||||
awsSecretAccessKey,
|
awsSecretAccessKey,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
// Note: Express Mode only needs vertexApiKey
|
|
||||||
vertexApiKey,
|
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
if (!provider || !modelId) {
|
||||||
@@ -49,7 +103,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
|
if (baseUrl && isPrivateUrl(baseUrl)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid base URL" },
|
{ valid: false, error: "Invalid base URL" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -67,17 +121,7 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (provider === "vertexai") {
|
} else if (provider !== "ollama" && !apiKey) {
|
||||||
if (!vertexApiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: "Vertex AI API key is required for Express Mode",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "API key is required" },
|
{ valid: false, error: "API key is required" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -114,15 +158,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "vertexai": {
|
|
||||||
const vertex = createVertex({
|
|
||||||
apiKey: vertexApiKey,
|
|
||||||
...(baseUrl && { baseURL: baseUrl }),
|
|
||||||
})
|
|
||||||
model = vertex(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const azure = createOpenAI({
|
const azure = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -167,7 +202,7 @@ export async function POST(req: Request) {
|
|||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const sf = createOpenAI({
|
const sf = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
||||||
})
|
})
|
||||||
model = sf.chat(modelId)
|
model = sf.chat(modelId)
|
||||||
break
|
break
|
||||||
@@ -190,21 +225,6 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "edgeone": {
|
|
||||||
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
|
||||||
// Need to pass cookies for EdgeOne Pages authentication
|
|
||||||
const cookieHeader = req.headers.get("cookie") || ""
|
|
||||||
const edgeone = createOpenAI({
|
|
||||||
apiKey: "edgeone", // EdgeOne doesn't require API key
|
|
||||||
baseURL: baseUrl || "/api/edgeai",
|
|
||||||
headers: {
|
|
||||||
cookie: cookieHeader,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
model = edgeone.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "sglang": {
|
case "sglang": {
|
||||||
// SGLang is OpenAI-compatible
|
// SGLang is OpenAI-compatible
|
||||||
const sglang = createOpenAI({
|
const sglang = createOpenAI({
|
||||||
@@ -216,98 +236,16 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
// ByteDance Doubao uses DeepSeek-compatible API
|
||||||
const doubaoBaseUrl =
|
const doubao = createDeepSeek({
|
||||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
apiKey,
|
||||||
const lowerModelId = modelId.toLowerCase()
|
baseURL:
|
||||||
if (
|
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
lowerModelId.includes("deepseek") ||
|
})
|
||||||
lowerModelId.includes("kimi")
|
model = doubao(modelId)
|
||||||
) {
|
|
||||||
const doubao = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
baseURL: doubaoBaseUrl,
|
|
||||||
})
|
|
||||||
model = doubao(modelId)
|
|
||||||
} else {
|
|
||||||
const doubao = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL: doubaoBaseUrl,
|
|
||||||
})
|
|
||||||
model = doubao.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "modelscope": {
|
|
||||||
const baseURL =
|
|
||||||
baseUrl || "https://api-inference.modelscope.cn/v1"
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
|
||||||
const response = await fetch(
|
|
||||||
`${baseURL}/chat/completions`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: modelId,
|
|
||||||
messages: [
|
|
||||||
{ role: "user", content: "Say 'OK'" },
|
|
||||||
],
|
|
||||||
max_tokens: 20,
|
|
||||||
stream: true,
|
|
||||||
enable_thinking: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
throw new Error(
|
|
||||||
`ModelScope API error (${response.status}): ${errorText}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
response.headers.get("content-type") || ""
|
|
||||||
const isValidStreamingResponse =
|
|
||||||
response.status === 200 &&
|
|
||||||
(contentType.includes("text/event-stream") ||
|
|
||||||
contentType.includes("application/json"))
|
|
||||||
|
|
||||||
if (!isValidStreamingResponse) {
|
|
||||||
throw new Error(
|
|
||||||
`Unexpected response format: ${contentType}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime
|
|
||||||
|
|
||||||
if (response.body) {
|
|
||||||
response.body.cancel().catch(() => {
|
|
||||||
/* Ignore cancellation errors */
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
valid: true,
|
|
||||||
responseTime,
|
|
||||||
note: "ModelScope model validated (using streaming API)",
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[validate-model] ModelScope validation failed:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||||
|
|||||||
@@ -74,8 +74,8 @@
|
|||||||
--accent: oklch(0.94 0.03 280);
|
--accent: oklch(0.94 0.03 280);
|
||||||
--accent-foreground: oklch(0.35 0.08 270);
|
--accent-foreground: oklch(0.35 0.08 270);
|
||||||
|
|
||||||
/* Muted rose destructive */
|
/* Coral destructive */
|
||||||
--destructive: oklch(0.45 0.12 10);
|
--destructive: oklch(0.6 0.2 25);
|
||||||
|
|
||||||
/* Subtle borders */
|
/* Subtle borders */
|
||||||
--border: oklch(0.92 0.01 260);
|
--border: oklch(0.92 0.01 260);
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
--accent: oklch(0.3 0.04 280);
|
--accent: oklch(0.3 0.04 280);
|
||||||
--accent-foreground: oklch(0.9 0.03 270);
|
--accent-foreground: oklch(0.9 0.03 270);
|
||||||
|
|
||||||
--destructive: oklch(0.55 0.12 10);
|
--destructive: oklch(0.65 0.22 25);
|
||||||
|
|
||||||
--border: oklch(0.28 0.015 260);
|
--border: oklch(0.28 0.015 260);
|
||||||
--input: oklch(0.25 0.015 260);
|
--input: oklch(0.25 0.015 260);
|
||||||
@@ -244,19 +244,6 @@
|
|||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: oklch(0.75 0.01 260);
|
background-color: oklch(0.75 0.01 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode scrollbar */
|
|
||||||
.dark .scrollbar-thin {
|
|
||||||
scrollbar-color: oklch(0.35 0.015 260) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
|
||||||
background-color: oklch(0.35 0.015 260);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: oklch(0.45 0.015 260);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth page transitions */
|
/* Smooth page transitions */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ export const ModelSelectorLogo = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
alt={`${provider} logo`}
|
alt={`${provider} logo`}
|
||||||
@@ -169,27 +168,3 @@ export const ModelSelectorName = ({
|
|||||||
}: ModelSelectorNameProps) => (
|
}: ModelSelectorNameProps) => (
|
||||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||||
)
|
)
|
||||||
|
|
||||||
export type ModelSelectorSectionHeaderProps = {
|
|
||||||
icon: ReactNode
|
|
||||||
label: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorSectionHeader = ({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
}: ModelSelectorSectionHeaderProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -70,11 +70,9 @@ function ExampleCard({
|
|||||||
export default function ExamplePanel({
|
export default function ExamplePanel({
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
minimal = false,
|
|
||||||
}: {
|
}: {
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
minimal?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
|
||||||
@@ -122,55 +120,49 @@ export default function ExamplePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
{!minimal && (
|
{/* MCP Server Notice */}
|
||||||
<>
|
<a
|
||||||
{/* MCP Server Notice */}
|
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||||
<a
|
target="_blank"
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
<div className="flex items-center gap-3">
|
||||||
>
|
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<Terminal className="w-4 h-4 text-purple-500" />
|
||||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
</div>
|
||||||
<Terminal className="w-4 h-4 text-purple-500" />
|
<div className="min-w-0">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0">
|
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||||
<div className="flex items-center gap-2">
|
{dict.examples.mcpServer}
|
||||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
</span>
|
||||||
{dict.examples.mcpServer}
|
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
||||||
</span>
|
{dict.examples.preview}
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
</span>
|
||||||
{dict.examples.preview}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.examples.mcpDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.examples.mcpDescription}
|
||||||
{/* Welcome section */}
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
|
||||||
{dict.examples.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
|
||||||
{dict.examples.subtitle}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
</a>
|
||||||
|
|
||||||
|
{/* Welcome section */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{dict.examples.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
|
{dict.examples.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Examples grid */}
|
{/* Examples grid */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{!minimal && (
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
{dict.examples.quickExamples}
|
||||||
{dict.examples.quickExamples}
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<ExampleCard
|
<ExampleCard
|
||||||
|
|||||||
@@ -4,37 +4,27 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Link,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import {
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { UrlInputDialog } from "@/components/url-input-dialog"
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
|
||||||
import { isRealDiagram } from "@/lib/utils"
|
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -145,23 +135,18 @@ function showValidationErrors(errors: string[], dict: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatInputRef {
|
|
||||||
focus: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string
|
input: string
|
||||||
status: "submitted" | "streaming" | "ready" | "error"
|
status: "submitted" | "streaming" | "ready" | "error"
|
||||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
|
onClearChat: () => void
|
||||||
files?: File[]
|
files?: File[]
|
||||||
onFileChange?: (files: File[]) => void
|
onFileChange?: (files: File[]) => void
|
||||||
pdfData?: Map<
|
pdfData?: Map<
|
||||||
File,
|
File,
|
||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
urlData?: Map<string, UrlData>
|
|
||||||
onUrlChange?: (data: Map<string, UrlData>) => void
|
|
||||||
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
@@ -171,217 +156,90 @@ interface ChatInputProps {
|
|||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
showUnvalidatedModels?: boolean
|
showUnvalidatedModels?: boolean
|
||||||
onConfigureModels?: () => void
|
onConfigureModels?: () => void
|
||||||
// Focus control props
|
|
||||||
shouldFocus?: boolean
|
|
||||||
onFocused?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
export function ChatInput({
|
||||||
function ChatInput(
|
input,
|
||||||
{
|
status,
|
||||||
input,
|
onSubmit,
|
||||||
status,
|
onChange,
|
||||||
onSubmit,
|
onClearChat,
|
||||||
onChange,
|
files = [],
|
||||||
files = [],
|
onFileChange = () => {},
|
||||||
onFileChange = () => {},
|
pdfData = new Map(),
|
||||||
pdfData = new Map(),
|
sessionId,
|
||||||
urlData,
|
error = null,
|
||||||
onUrlChange,
|
models = [],
|
||||||
sessionId,
|
selectedModelId,
|
||||||
error = null,
|
onModelSelect = () => {},
|
||||||
models = [],
|
showUnvalidatedModels = false,
|
||||||
selectedModelId,
|
onConfigureModels = () => {},
|
||||||
onModelSelect = () => {},
|
}: ChatInputProps) {
|
||||||
showUnvalidatedModels = false,
|
const dict = useDictionary()
|
||||||
onConfigureModels = () => {},
|
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
||||||
shouldFocus = false,
|
|
||||||
onFocused,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const {
|
|
||||||
chartXML,
|
|
||||||
diagramHistory,
|
|
||||||
saveDiagramToFile,
|
|
||||||
showSaveDialog,
|
|
||||||
setShowSaveDialog,
|
|
||||||
} = useDiagram()
|
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
|
const isDisabled =
|
||||||
|
(status === "streaming" || status === "submitted") && !error
|
||||||
|
|
||||||
// Expose focus method via ref
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
useImperativeHandle(ref, () => ({
|
const textarea = textareaRef.current
|
||||||
focus: () => {
|
if (textarea) {
|
||||||
textareaRef.current?.focus()
|
textarea.style.height = "auto"
|
||||||
},
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||||
}))
|
}
|
||||||
|
}, [])
|
||||||
|
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||||
|
useEffect(() => {
|
||||||
|
adjustTextareaHeight()
|
||||||
|
}, [input, adjustTextareaHeight])
|
||||||
|
|
||||||
// Focus the textarea when shouldFocus becomes true
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
onChange(e)
|
||||||
useEffect(() => {
|
adjustTextareaHeight()
|
||||||
if (shouldFocus) {
|
}
|
||||||
const timer = setTimeout(() => {
|
|
||||||
textareaRef.current?.focus()
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
onFocused?.()
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
}, 150)
|
e.preventDefault()
|
||||||
return () => clearTimeout(timer)
|
const form = e.currentTarget.closest("form")
|
||||||
|
if (form && input.trim() && !isDisabled) {
|
||||||
|
form.requestSubmit()
|
||||||
}
|
}
|
||||||
}, [shouldFocus, onFocused])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
if (isDisabled) return
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
|
||||||
const isDisabled =
|
|
||||||
(status === "streaming" || status === "submitted") && !error
|
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
const items = e.clipboardData.items
|
||||||
const textarea = textareaRef.current
|
const imageItems = Array.from(items).filter((item) =>
|
||||||
if (textarea) {
|
item.type.startsWith("image/"),
|
||||||
textarea.style.height = "auto"
|
)
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
|
||||||
useEffect(() => {
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}, [input, adjustTextareaHeight])
|
|
||||||
|
|
||||||
// Load send shortcut preference from localStorage and listen for changes
|
if (imageItems.length > 0) {
|
||||||
useEffect(() => {
|
const imageFiles = (
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
await Promise.all(
|
||||||
if (stored) setSendShortcut(stored)
|
imageItems.map(async (item, index) => {
|
||||||
|
const file = item.getAsFile()
|
||||||
const handleChange = (e: CustomEvent<string>) =>
|
if (!file) return null
|
||||||
setSendShortcut(e.detail)
|
return new File(
|
||||||
window.addEventListener(
|
[file],
|
||||||
"sendShortcutChange",
|
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||||
handleChange as EventListener,
|
{ type: file.type },
|
||||||
)
|
)
|
||||||
return () =>
|
}),
|
||||||
window.removeEventListener(
|
|
||||||
"sendShortcutChange",
|
|
||||||
handleChange as EventListener,
|
|
||||||
)
|
)
|
||||||
}, [])
|
).filter((f): f is File => f !== null)
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
onChange(e)
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
const shouldSend =
|
|
||||||
sendShortcut === "enter"
|
|
||||||
? e.key === "Enter" &&
|
|
||||||
!e.shiftKey &&
|
|
||||||
!e.ctrlKey &&
|
|
||||||
!e.metaKey
|
|
||||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
|
||||||
|
|
||||||
if (shouldSend) {
|
|
||||||
e.preventDefault()
|
|
||||||
const form = e.currentTarget.closest("form")
|
|
||||||
if (form && input.trim() && !isDisabled) {
|
|
||||||
form.requestSubmit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
|
||||||
if (isDisabled) return
|
|
||||||
|
|
||||||
const items = e.clipboardData.items
|
|
||||||
const imageItems = Array.from(items).filter((item) =>
|
|
||||||
item.type.startsWith("image/"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
|
||||||
const imageFiles = (
|
|
||||||
await Promise.all(
|
|
||||||
imageItems.map(async (item, index) => {
|
|
||||||
const file = item.getAsFile()
|
|
||||||
if (!file) return null
|
|
||||||
return new File(
|
|
||||||
[file],
|
|
||||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
|
||||||
{ type: file.type },
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
).filter((f): f is File => f !== null)
|
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
imageFiles,
|
|
||||||
files.length,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newFiles = Array.from(e.target.files || [])
|
|
||||||
const { validFiles, errors } = validateFiles(
|
|
||||||
newFiles,
|
|
||||||
files.length,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
showValidationErrors(errors, dict)
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveFile = (fileToRemove: File) => {
|
|
||||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
|
|
||||||
if (isDisabled) return
|
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files
|
|
||||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
|
||||||
isValidFileType(file),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
const { validFiles, errors } = validateFiles(
|
||||||
supportedFiles,
|
imageFiles,
|
||||||
files.length,
|
files.length,
|
||||||
dict,
|
dict,
|
||||||
)
|
)
|
||||||
@@ -390,97 +248,132 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
onFileChange([...files, ...validFiles])
|
onFileChange([...files, ...validFiles])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleUrlExtract = async (url: string) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!onUrlChange) return
|
const newFiles = Array.from(e.target.files || [])
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
setIsExtractingUrl(true)
|
newFiles,
|
||||||
|
files.length,
|
||||||
try {
|
dict,
|
||||||
const existing = urlData
|
)
|
||||||
? new Map(urlData)
|
showValidationErrors(errors, dict)
|
||||||
: new Map<string, UrlData>()
|
if (validFiles.length > 0) {
|
||||||
existing.set(url, {
|
onFileChange([...files, ...validFiles])
|
||||||
url,
|
|
||||||
title: url,
|
|
||||||
content: "",
|
|
||||||
charCount: 0,
|
|
||||||
isExtracting: true,
|
|
||||||
})
|
|
||||||
onUrlChange(existing)
|
|
||||||
|
|
||||||
const data = await extractUrlContent(url)
|
|
||||||
|
|
||||||
const newUrlData = new Map(existing)
|
|
||||||
newUrlData.set(url, data)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
|
|
||||||
setShowUrlDialog(false)
|
|
||||||
} catch (error) {
|
|
||||||
// Remove the URL from the data map on error
|
|
||||||
const newUrlData = urlData
|
|
||||||
? new Map(urlData)
|
|
||||||
: new Map<string, UrlData>()
|
|
||||||
newUrlData.delete(url)
|
|
||||||
onUrlChange(newUrlData)
|
|
||||||
showErrorToast(
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to extract URL content"}
|
|
||||||
</span>,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsExtractingUrl(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (fileInputRef.current) {
|
||||||
<form
|
fileInputRef.current.value = ""
|
||||||
onSubmit={onSubmit}
|
}
|
||||||
className={`w-full transition-all duration-200 ${
|
}
|
||||||
isDragging
|
|
||||||
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
: ""
|
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||||
}`}
|
if (fileInputRef.current) {
|
||||||
onDragOver={handleDragOver}
|
fileInputRef.current.value = ""
|
||||||
onDragLeave={handleDragLeave}
|
}
|
||||||
onDrop={handleDrop}
|
}
|
||||||
>
|
|
||||||
{/* File & URL previews */}
|
const triggerFileInput = () => {
|
||||||
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
fileInputRef.current?.click()
|
||||||
<div className="mb-3">
|
}
|
||||||
<FilePreviewList
|
|
||||||
files={files}
|
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
onRemoveFile={handleRemoveFile}
|
e.preventDefault()
|
||||||
pdfData={pdfData}
|
e.stopPropagation()
|
||||||
urlData={urlData}
|
setIsDragging(true)
|
||||||
onRemoveUrl={
|
}
|
||||||
onUrlChange
|
|
||||||
? (url) => {
|
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
const next = new Map(urlData)
|
e.preventDefault()
|
||||||
next.delete(url)
|
e.stopPropagation()
|
||||||
onUrlChange(next)
|
setIsDragging(false)
|
||||||
}
|
}
|
||||||
: undefined
|
|
||||||
}
|
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
if (isDisabled) return
|
||||||
|
|
||||||
|
const droppedFiles = e.dataTransfer.files
|
||||||
|
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||||
|
isValidFileType(file),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { validFiles, errors } = validateFiles(
|
||||||
|
supportedFiles,
|
||||||
|
files.length,
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
showValidationErrors(errors, dict)
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFileChange([...files, ...validFiles])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onClearChat()
|
||||||
|
setShowClearDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className={`w-full transition-all duration-200 ${
|
||||||
|
isDragging
|
||||||
|
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* File previews */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<FilePreviewList
|
||||||
|
files={files}
|
||||||
|
onRemoveFile={handleRemoveFile}
|
||||||
|
pdfData={pdfData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
placeholder={dict.chat.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label="Chat input"
|
||||||
|
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
|
||||||
|
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowClearDialog(true)}
|
||||||
|
tooltipContent={dict.chat.clearConversation}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
<ResetWarningModal
|
||||||
|
open={showClearDialog}
|
||||||
|
onOpenChange={setShowClearDialog}
|
||||||
|
onClear={handleClear}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
placeholder={dict.chat.placeholder}
|
|
||||||
disabled={isDisabled}
|
|
||||||
aria-label="Chat input"
|
|
||||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
<div className="flex items-center gap-1 overflow-hidden justify-end">
|
||||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
type="button"
|
type="button"
|
||||||
@@ -501,9 +394,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(true)}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
disabled={
|
disabled={isDisabled}
|
||||||
isDisabled || !isRealDiagram(chartXML)
|
|
||||||
}
|
|
||||||
tooltipContent={dict.chat.saveDiagram}
|
tooltipContent={dict.chat.saveDiagram}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -522,20 +413,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
{onUrlChange && (
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowUrlDialog(true)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
tooltipContent={dict.chat.ExtractURL}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Link className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -575,34 +452,21 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HistoryDialog
|
</div>
|
||||||
showHistory={showHistory}
|
<HistoryDialog
|
||||||
onToggleHistory={setShowHistory}
|
showHistory={showHistory}
|
||||||
/>
|
onToggleHistory={setShowHistory}
|
||||||
<SaveDialog
|
/>
|
||||||
open={showSaveDialog}
|
<SaveDialog
|
||||||
onOpenChange={setShowSaveDialog}
|
open={showSaveDialog}
|
||||||
onSave={(filename, format) =>
|
onOpenChange={setShowSaveDialog}
|
||||||
saveDiagramToFile(
|
onSave={(filename, format) =>
|
||||||
filename,
|
saveDiagramToFile(filename, format, sessionId)
|
||||||
format,
|
}
|
||||||
sessionId,
|
defaultFilename={`diagram-${new Date()
|
||||||
dict.save.savedSuccessfully,
|
.toISOString()
|
||||||
)
|
.slice(0, 10)}`}
|
||||||
}
|
/>
|
||||||
defaultFilename={`diagram-${new Date()
|
</form>
|
||||||
.toISOString()
|
)
|
||||||
.slice(0, 10)}`}
|
}
|
||||||
/>
|
|
||||||
{onUrlChange && (
|
|
||||||
<UrlInputDialog
|
|
||||||
open={showUrlDialog}
|
|
||||||
onOpenChange={setShowUrlDialog}
|
|
||||||
onSubmit={handleUrlExtract}
|
|
||||||
isExtracting={isExtractingUrl}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
|
Cpu,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
Link,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
@@ -26,21 +26,24 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
|
||||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
|
||||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
|
||||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
|
isMxCellXmlComplete,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import ExamplePanel from "./chat-example-panel"
|
||||||
|
import { CodeBlock } from "./code-block"
|
||||||
|
|
||||||
|
interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -54,26 +57,76 @@ function getCompleteOperations(
|
|||||||
["update", "add", "delete"].includes(op.operation) &&
|
["update", "add", "delete"].includes(op.operation) &&
|
||||||
typeof op.cell_id === "string" &&
|
typeof op.cell_id === "string" &&
|
||||||
op.cell_id.length > 0 &&
|
op.cell_id.length > 0 &&
|
||||||
|
// delete doesn't need new_xml, update/add do
|
||||||
(op.operation === "delete" || typeof op.new_xml === "string"),
|
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tool part interface for type safety
|
||||||
|
interface ToolPartLike {
|
||||||
|
type: string
|
||||||
|
toolCallId: string
|
||||||
|
state?: string
|
||||||
|
input?: {
|
||||||
|
xml?: string
|
||||||
|
operations?: DiagramOperation[]
|
||||||
|
} & Record<string, unknown>
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{operations.map((op, index) => (
|
||||||
|
<div
|
||||||
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||||
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||||
|
op.operation === "delete"
|
||||||
|
? "text-red-600"
|
||||||
|
: op.operation === "add"
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{op.operation}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
cell_id: {op.cell_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{op.new_xml && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{op.new_xml}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
|
||||||
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
|
// Helper to split text content into regular text and file sections (PDF or text files)
|
||||||
interface TextSection {
|
interface TextSection {
|
||||||
type: "text" | "file" | "url"
|
type: "text" | "file"
|
||||||
content: string
|
content: string
|
||||||
filename?: string
|
filename?: string
|
||||||
charCount?: number
|
charCount?: number
|
||||||
fileType?: "pdf" | "text" | "url"
|
fileType?: "pdf" | "text"
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitTextIntoFileSections(text: string): TextSection[] {
|
function splitTextIntoFileSections(text: string): TextSection[] {
|
||||||
const sections: TextSection[] = []
|
const sections: TextSection[] = []
|
||||||
// Match [PDF: filename], [File: filename], or [URL: url] patterns
|
// Match [PDF: filename] or [File: filename] patterns
|
||||||
const filePattern =
|
const filePattern =
|
||||||
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
|
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
let match
|
let match
|
||||||
|
|
||||||
@@ -84,34 +137,28 @@ function splitTextIntoFileSections(text: string): TextSection[] {
|
|||||||
sections.push({ type: "text", content: beforeText })
|
sections.push({ type: "text", content: beforeText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add file/url section
|
// Add file section
|
||||||
const sectionType = match[1].toLowerCase()
|
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
|
||||||
const fileType =
|
|
||||||
sectionType === "pdf"
|
|
||||||
? "pdf"
|
|
||||||
: sectionType === "url"
|
|
||||||
? "url"
|
|
||||||
: "text"
|
|
||||||
const filename = match[2].trim()
|
const filename = match[2].trim()
|
||||||
const content = match[3].trim()
|
const fileContent = match[3].trim()
|
||||||
sections.push({
|
sections.push({
|
||||||
type: sectionType === "url" ? "url" : "file",
|
type: "file",
|
||||||
content: content,
|
content: fileContent,
|
||||||
filename,
|
filename,
|
||||||
charCount: content.length,
|
charCount: fileContent.length,
|
||||||
fileType,
|
fileType,
|
||||||
})
|
})
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length
|
lastIndex = match.index + match[0].length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text after last section
|
// Add remaining text after last file section
|
||||||
const remainingText = text.slice(lastIndex).trim()
|
const remainingText = text.slice(lastIndex).trim()
|
||||||
if (remainingText) {
|
if (remainingText) {
|
||||||
sections.push({ type: "text", content: remainingText })
|
sections.push({ type: "text", content: remainingText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no file/url sections found, return original text
|
// If no file sections found, return original text
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
sections.push({ type: "text", content: text })
|
sections.push({ type: "text", content: text })
|
||||||
}
|
}
|
||||||
@@ -130,18 +177,11 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
// Get only the user's original text, excluding appended file content
|
// Get only the user's original text, excluding appended file content
|
||||||
const getUserOriginalText = (message: UIMessage): string => {
|
const getUserOriginalText = (message: UIMessage): string => {
|
||||||
const fullText = getMessageTextContent(message)
|
const fullText = getMessageTextContent(message)
|
||||||
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
|
// Strip out [PDF: ...] and [File: ...] sections that were appended
|
||||||
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
||||||
return fullText.replace(filePattern, "").trim()
|
return fullText.replace(filePattern, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMetadata {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
updatedAt: number
|
|
||||||
thumbnailDataUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
@@ -152,13 +192,6 @@ interface ChatMessageDisplayProps {
|
|||||||
onRegenerate?: (messageIndex: number) => void
|
onRegenerate?: (messageIndex: number) => void
|
||||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||||
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
||||||
isRestored?: boolean
|
|
||||||
sessions?: SessionMetadata[]
|
|
||||||
onSelectSession?: (id: string) => void
|
|
||||||
onDeleteSession?: (id: string) => void
|
|
||||||
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
|
||||||
validationStates?: Record<string, ValidationState>
|
|
||||||
onImproveWithSuggestions?: (feedback: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
@@ -171,35 +204,13 @@ export function ChatMessageDisplay({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
status = "idle",
|
status = "idle",
|
||||||
isRestored = false,
|
|
||||||
sessions = [],
|
|
||||||
onSelectSession,
|
|
||||||
onDeleteSession,
|
|
||||||
loadedMessageIdsRef,
|
|
||||||
validationStates = {},
|
|
||||||
onImproveWithSuggestions,
|
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollTopRef = useRef<HTMLDivElement>(null)
|
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
const processedToolCalls = processedToolCallsRef
|
const processedToolCalls = processedToolCallsRef
|
||||||
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
||||||
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
// Reset refs when messages become empty (new chat or session switch)
|
|
||||||
// This ensures cached examples work correctly after starting a new session
|
|
||||||
useEffect(() => {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
previousXML.current = ""
|
|
||||||
lastProcessedXmlRef.current.clear()
|
|
||||||
// Note: processedToolCalls is passed from parent, so we clear it too
|
|
||||||
processedToolCalls.current.clear()
|
|
||||||
// Scroll to top to show newest history items
|
|
||||||
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
|
||||||
}
|
|
||||||
}, [messages.length, processedToolCalls])
|
|
||||||
// Debounce streaming diagram updates - store pending XML and timeout
|
// Debounce streaming diagram updates - store pending XML and timeout
|
||||||
const pendingXmlRef = useRef<string | null>(null)
|
const pendingXmlRef = useRef<string | null>(null)
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
@@ -217,12 +228,6 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const [copiedToolCallId, setCopiedToolCallId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [copyFailedToolCallId, setCopyFailedToolCallId] = useState<
|
|
||||||
string | null
|
|
||||||
>(null)
|
|
||||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -238,39 +243,13 @@ export function ChatMessageDisplay({
|
|||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({})
|
>({})
|
||||||
|
|
||||||
const setCopyState = (
|
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||||
messageId: string,
|
|
||||||
isToolCall: boolean,
|
|
||||||
isSuccess: boolean,
|
|
||||||
) => {
|
|
||||||
if (isSuccess) {
|
|
||||||
if (isToolCall) {
|
|
||||||
setCopiedToolCallId(messageId)
|
|
||||||
setTimeout(() => setCopiedToolCallId(null), 2000)
|
|
||||||
} else {
|
|
||||||
setCopiedMessageId(messageId)
|
|
||||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isToolCall) {
|
|
||||||
setCopyFailedToolCallId(messageId)
|
|
||||||
setTimeout(() => setCopyFailedToolCallId(null), 2000)
|
|
||||||
} else {
|
|
||||||
setCopyFailedMessageId(messageId)
|
|
||||||
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyMessageToClipboard = async (
|
|
||||||
messageId: string,
|
|
||||||
text: string,
|
|
||||||
isToolCall = false,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopyState(messageId, isToolCall, true)
|
|
||||||
} catch (_err) {
|
setCopiedMessageId(messageId)
|
||||||
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||||
|
} catch (err) {
|
||||||
// Fallback for non-secure contexts (HTTP) or permission denied
|
// Fallback for non-secure contexts (HTTP) or permission denied
|
||||||
const textarea = document.createElement("textarea")
|
const textarea = document.createElement("textarea")
|
||||||
textarea.value = text
|
textarea.value = text
|
||||||
@@ -285,11 +264,15 @@ export function ChatMessageDisplay({
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
throw new Error("Copy command failed")
|
throw new Error("Copy command failed")
|
||||||
}
|
}
|
||||||
setCopyState(messageId, isToolCall, true)
|
setCopiedMessageId(messageId)
|
||||||
|
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
console.error("Failed to copy message:", fallbackErr)
|
console.error("Failed to copy message:", fallbackErr)
|
||||||
toast.error(dict.chat.failedToCopyDetail)
|
toast.error(
|
||||||
setCopyState(messageId, isToolCall, false)
|
"Failed to copy message. Please copy manually or check clipboard permissions.",
|
||||||
|
)
|
||||||
|
setCopyFailedMessageId(messageId)
|
||||||
|
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textarea)
|
document.body.removeChild(textarea)
|
||||||
}
|
}
|
||||||
@@ -321,7 +304,7 @@ export function ChatMessageDisplay({
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to log feedback:", error)
|
console.error("Failed to log feedback:", error)
|
||||||
toast.error(dict.errors.failedToRecordFeedback)
|
toast.error("Failed to record your feedback. Please try again.")
|
||||||
// Revert optimistic UI update
|
// Revert optimistic UI update
|
||||||
setFeedback((prev) => {
|
setFeedback((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
@@ -334,6 +317,7 @@ export function ChatMessageDisplay({
|
|||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string, showToast = false) => {
|
||||||
let currentXml = xml || ""
|
let currentXml = xml || ""
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
// During streaming (showToast=false), extract only complete mxCell elements
|
// During streaming (showToast=false), extract only complete mxCell elements
|
||||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||||
@@ -357,9 +341,17 @@ export function ChatMessageDisplay({
|
|||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
// Use console.warn instead of console.error to avoid triggering
|
||||||
|
// Next.js dev mode error overlay for expected streaming states
|
||||||
|
// (partial XML during streaming is normal and will be fixed by subsequent updates)
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.error(dict.errors.malformedXml)
|
// Only log as error and show toast if this is the final XML
|
||||||
|
console.error(
|
||||||
|
"[ChatMessageDisplay] Malformed XML detected in final output",
|
||||||
|
)
|
||||||
|
toast.error(
|
||||||
|
"AI generated invalid diagram XML. Please try regenerating.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return // Skip this update
|
return // Skip this update
|
||||||
}
|
}
|
||||||
@@ -372,12 +364,18 @@ export function ChatMessageDisplay({
|
|||||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
|
const xmlProcessTime = performance.now() - startTime
|
||||||
|
|
||||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||||
// The quick DOM parse check above catches malformed XML
|
// The quick DOM parse check above catches malformed XML
|
||||||
// Full validation runs on final output (showToast=true)
|
// Full validation runs on final output (showToast=true)
|
||||||
if (!showToast) {
|
if (!showToast) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
|
const loadStartTime = performance.now()
|
||||||
onDisplayChart(replacedXML, true)
|
onDisplayChart(replacedXML, true)
|
||||||
|
console.log(
|
||||||
|
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,15 +385,37 @@ export function ChatMessageDisplay({
|
|||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
// Use fixed XML if available, otherwise use original
|
// Use fixed XML if available, otherwise use original
|
||||||
const xmlToLoad = validation.fixed || replacedXML
|
const xmlToLoad = validation.fixed || replacedXML
|
||||||
|
if (validation.fixes.length > 0) {
|
||||||
|
console.log(
|
||||||
|
"[ChatMessageDisplay] Auto-fixed XML issues:",
|
||||||
|
validation.fixes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Skip validation in loadDiagram since we already validated above
|
||||||
|
const loadStartTime = performance.now()
|
||||||
onDisplayChart(xmlToLoad, true)
|
onDisplayChart(xmlToLoad, true)
|
||||||
|
console.log(
|
||||||
|
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
toast.error(dict.errors.validationFailed)
|
console.error(
|
||||||
|
"[ChatMessageDisplay] XML validation failed:",
|
||||||
|
validation.error,
|
||||||
|
)
|
||||||
|
toast.error(
|
||||||
|
"Diagram validation failed. Please try regenerating.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing XML:", error)
|
console.error(
|
||||||
|
"[ChatMessageDisplay] Error processing XML:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.error(dict.errors.failedToProcess)
|
toast.error(
|
||||||
|
"Failed to process diagram. Please try regenerating.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,22 +423,8 @@ export function ChatMessageDisplay({
|
|||||||
[chartXML, onDisplayChart],
|
[chartXML, onDisplayChart],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track previous message count to detect bulk loads vs streaming
|
|
||||||
const prevMessageCountRef = useRef(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current && messages.length > 0) {
|
if (messagesEndRef.current) {
|
||||||
const prevCount = prevMessageCountRef.current
|
|
||||||
const currentCount = messages.length
|
|
||||||
prevMessageCountRef.current = currentCount
|
|
||||||
|
|
||||||
// Bulk load (session restore) - instant scroll, no animation
|
|
||||||
if (prevCount === 0 || currentCount - prevCount > 1) {
|
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single message added - smooth scroll
|
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
@@ -442,15 +448,11 @@ export function ChatMessageDisplay({
|
|||||||
const toolPart = part as ToolPartLike
|
const toolPart = part as ToolPartLike
|
||||||
const { toolCallId, state, input } = toolPart
|
const { toolCallId, state, input } = toolPart
|
||||||
|
|
||||||
// Auto-collapse on completion, but only if user hasn't manually toggled
|
|
||||||
if (state === "output-available") {
|
if (state === "output-available") {
|
||||||
setExpandedTools((prev) => {
|
setExpandedTools((prev) => ({
|
||||||
// Only auto-collapse if not already set (user hasn't interacted)
|
...prev,
|
||||||
if (prev[toolCallId] === undefined) {
|
[toolCallId]: false,
|
||||||
return { ...prev, [toolCallId]: false }
|
}))
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -640,19 +642,149 @@ export function ChatMessageDisplay({
|
|||||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||||
}, [messages, handleDisplayChart, chartXML])
|
}, [messages, handleDisplayChart, chartXML])
|
||||||
|
|
||||||
|
const renderToolPart = (part: ToolPartLike) => {
|
||||||
|
const callId = part.toolCallId
|
||||||
|
const { state, input, output } = part
|
||||||
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
|
const toolName = part.type?.replace("tool-", "")
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setExpandedTools((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[callId]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolDisplayName = (name: string) => {
|
||||||
|
switch (name) {
|
||||||
|
case "display_diagram":
|
||||||
|
return "Generate Diagram"
|
||||||
|
case "edit_diagram":
|
||||||
|
return "Edit Diagram"
|
||||||
|
case "get_shape_library":
|
||||||
|
return "Get Shape Library"
|
||||||
|
default:
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={callId}
|
||||||
|
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground/80">
|
||||||
|
{getToolDisplayName(toolName)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{state === "input-streaming" && (
|
||||||
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
{state === "output-available" && (
|
||||||
|
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
// Check if this is a truncation (incomplete XML) vs real error
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return isTruncated ? (
|
||||||
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||||
|
Truncated
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{input && Object.keys(input).length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{input && isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
|
{typeof input === "object" && input.xml ? (
|
||||||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
input.operations &&
|
||||||
|
Array.isArray(input.operations) ? (
|
||||||
|
<OperationsDisplay operations={input.operations} />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
Object.keys(input).length > 0 ? (
|
||||||
|
<CodeBlock
|
||||||
|
code={JSON.stringify(input, null, 2)}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{output &&
|
||||||
|
state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{isTruncated
|
||||||
|
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||||
|
: output}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{/* Show get_shape_library output on success */}
|
||||||
|
{output &&
|
||||||
|
toolName === "get_shape_library" &&
|
||||||
|
state === "output-available" &&
|
||||||
|
isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
|
Library loaded (
|
||||||
|
{typeof output === "string" ? output.length : 0}{" "}
|
||||||
|
chars)
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||||
|
{typeof output === "string"
|
||||||
|
? output.substring(0, 800) +
|
||||||
|
(output.length > 800 ? "\n..." : "")
|
||||||
|
: String(output)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||||
<div ref={scrollTopRef} />
|
{messages.length === 0 ? (
|
||||||
{messages.length === 0 && isRestored ? (
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
<ChatLobby
|
) : (
|
||||||
sessions={sessions}
|
|
||||||
onSelectSession={onSelectSession || (() => {})}
|
|
||||||
onDeleteSession={onDeleteSession}
|
|
||||||
setInput={setInput}
|
|
||||||
setFiles={setFiles}
|
|
||||||
dict={dict}
|
|
||||||
/>
|
|
||||||
) : messages.length === 0 ? null : (
|
|
||||||
<div className="py-4 px-4 space-y-4">
|
<div className="py-4 px-4 space-y-4">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const userMessageText =
|
const userMessageText =
|
||||||
@@ -672,21 +804,13 @@ export function ChatMessageDisplay({
|
|||||||
.slice(messageIndex + 1)
|
.slice(messageIndex + 1)
|
||||||
.every((m) => m.role !== "user"))
|
.every((m) => m.role !== "user"))
|
||||||
const isEditing = editingMessageId === message.id
|
const isEditing = editingMessageId === message.id
|
||||||
// Skip animation for loaded messages (from session restore)
|
|
||||||
const isRestoredMessage =
|
|
||||||
loadedMessageIdsRef?.current.has(message.id) ??
|
|
||||||
false
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
|
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||||
style={
|
style={{
|
||||||
isRestoredMessage
|
animationDelay: `${messageIndex * 50}ms`,
|
||||||
? undefined
|
}}
|
||||||
: {
|
|
||||||
animationDelay: `${messageIndex * 50}ms`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{message.role === "user" &&
|
{message.role === "user" &&
|
||||||
userMessageText &&
|
userMessageText &&
|
||||||
@@ -708,10 +832,7 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||||
title={
|
title="Edit message"
|
||||||
dict.chat
|
|
||||||
.editMessage
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -728,13 +849,11 @@ export function ChatMessageDisplay({
|
|||||||
title={
|
title={
|
||||||
copiedMessageId ===
|
copiedMessageId ===
|
||||||
message.id
|
message.id
|
||||||
? dict.chat.copied
|
? "Copied!"
|
||||||
: copyFailedMessageId ===
|
: copyFailedMessageId ===
|
||||||
message.id
|
message.id
|
||||||
? dict.chat
|
? "Failed to copy"
|
||||||
.failedToCopy
|
: "Copy message"
|
||||||
: dict.chat
|
|
||||||
.copyResponse
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{copiedMessageId ===
|
{copiedMessageId ===
|
||||||
@@ -783,9 +902,6 @@ export function ChatMessageDisplay({
|
|||||||
isStreaming={
|
isStreaming={
|
||||||
isStreamingReasoning
|
isStreamingReasoning
|
||||||
}
|
}
|
||||||
defaultOpen={
|
|
||||||
!isRestoredMessage
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
@@ -852,7 +968,7 @@ export function ChatMessageDisplay({
|
|||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
||||||
>
|
>
|
||||||
{dict.common.cancel}
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -874,7 +990,7 @@ export function ChatMessageDisplay({
|
|||||||
disabled={!editText.trim()}
|
disabled={!editText.trim()}
|
||||||
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{dict.chat.saveAndSubmit}
|
Save & Submit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -928,56 +1044,9 @@ export function ChatMessageDisplay({
|
|||||||
return groups.map(
|
return groups.map(
|
||||||
(group, groupIndex) => {
|
(group, groupIndex) => {
|
||||||
if (group.type === "tool") {
|
if (group.type === "tool") {
|
||||||
const toolPart = group
|
return renderToolPart(
|
||||||
.parts[0] as ToolPartLike
|
group
|
||||||
const toolCallId =
|
.parts[0] as ToolPartLike,
|
||||||
toolPart.toolCallId
|
|
||||||
const isDisplayDiagram =
|
|
||||||
toolPart.type ===
|
|
||||||
"tool-display_diagram"
|
|
||||||
const validationState =
|
|
||||||
validationStates[
|
|
||||||
toolCallId
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${message.id}-tool-${group.startIndex}`}
|
|
||||||
>
|
|
||||||
<ToolCallCard
|
|
||||||
part={
|
|
||||||
toolPart
|
|
||||||
}
|
|
||||||
expandedTools={
|
|
||||||
expandedTools
|
|
||||||
}
|
|
||||||
setExpandedTools={
|
|
||||||
setExpandedTools
|
|
||||||
}
|
|
||||||
onCopy={
|
|
||||||
copyMessageToClipboard
|
|
||||||
}
|
|
||||||
copiedToolCallId={
|
|
||||||
copiedToolCallId
|
|
||||||
}
|
|
||||||
copyFailedToolCallId={
|
|
||||||
copyFailedToolCallId
|
|
||||||
}
|
|
||||||
dict={dict}
|
|
||||||
/>
|
|
||||||
{/* Show validation card for display_diagram tools */}
|
|
||||||
{isDisplayDiagram &&
|
|
||||||
validationState && (
|
|
||||||
<ValidationCard
|
|
||||||
state={
|
|
||||||
validationState
|
|
||||||
}
|
|
||||||
onImproveWithSuggestions={
|
|
||||||
onImproveWithSuggestions
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,8 +1123,7 @@ export function ChatMessageDisplay({
|
|||||||
"user" &&
|
"user" &&
|
||||||
isLastUserMessage &&
|
isLastUserMessage &&
|
||||||
onEditMessage
|
onEditMessage
|
||||||
? dict.chat
|
? "Click to edit"
|
||||||
.clickToEdit
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1091,14 +1159,12 @@ export function ChatMessageDisplay({
|
|||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
section.type ===
|
section.type ===
|
||||||
"file" ||
|
"file"
|
||||||
section.type ===
|
|
||||||
"url"
|
|
||||||
) {
|
) {
|
||||||
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
|
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
|
||||||
const isExpanded =
|
const isExpanded =
|
||||||
expandedPdfSections[
|
expandedPdfSections[
|
||||||
sectionKey
|
pdfKey
|
||||||
] ??
|
] ??
|
||||||
false
|
false
|
||||||
const charDisplay =
|
const charDisplay =
|
||||||
@@ -1107,27 +1173,10 @@ export function ChatMessageDisplay({
|
|||||||
1000
|
1000
|
||||||
? `${(section.charCount / 1000).toFixed(1)}k`
|
? `${(section.charCount / 1000).toFixed(1)}k`
|
||||||
: section.charCount
|
: section.charCount
|
||||||
|
|
||||||
// Icon selector
|
|
||||||
const Icon =
|
|
||||||
section.fileType ===
|
|
||||||
"pdf"
|
|
||||||
? FileText
|
|
||||||
: section.fileType ===
|
|
||||||
"url"
|
|
||||||
? Link
|
|
||||||
: FileCode
|
|
||||||
|
|
||||||
const iconColor =
|
|
||||||
section.fileType ===
|
|
||||||
"pdf"
|
|
||||||
? "text-red-500"
|
|
||||||
: "text-blue-700"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
sectionKey
|
pdfKey
|
||||||
}
|
}
|
||||||
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -1142,7 +1191,7 @@ export function ChatMessageDisplay({
|
|||||||
prev,
|
prev,
|
||||||
) => ({
|
) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[sectionKey]:
|
[pdfKey]:
|
||||||
!isExpanded,
|
!isExpanded,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1150,10 +1199,13 @@ export function ChatMessageDisplay({
|
|||||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon
|
{section.fileType ===
|
||||||
className={`h-4 w-4 ${iconColor}`}
|
"pdf" ? (
|
||||||
/>
|
<FileText className="h-4 w-4 text-red-500" />
|
||||||
<span className="text-xs font-medium truncate max-w-[200px]">
|
) : (
|
||||||
|
<FileCode className="h-4 w-4 text-blue-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium">
|
||||||
{
|
{
|
||||||
section.filename
|
section.filename
|
||||||
}
|
}
|
||||||
@@ -1173,7 +1225,7 @@ export function ChatMessageDisplay({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin">
|
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
|
||||||
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
|
||||||
{
|
{
|
||||||
section.content
|
section.content
|
||||||
@@ -1273,8 +1325,8 @@ export function ChatMessageDisplay({
|
|||||||
title={
|
title={
|
||||||
copiedMessageId ===
|
copiedMessageId ===
|
||||||
message.id
|
message.id
|
||||||
? dict.chat.copied
|
? "Copied!"
|
||||||
: dict.chat.copyResponse
|
: "Copy response"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{copiedMessageId ===
|
{copiedMessageId ===
|
||||||
@@ -1300,9 +1352,7 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||||
title={
|
title="Regenerate response"
|
||||||
dict.chat.regenerate
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1324,7 +1374,7 @@ export function ChatMessageDisplay({
|
|||||||
? "text-green-600 bg-green-100"
|
? "text-green-600 bg-green-100"
|
||||||
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
||||||
}`}
|
}`}
|
||||||
title={dict.chat.goodResponse}
|
title="Good response"
|
||||||
>
|
>
|
||||||
<ThumbsUp className="h-3.5 w-3.5" />
|
<ThumbsUp className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1343,7 +1393,7 @@ export function ChatMessageDisplay({
|
|||||||
? "text-red-600 bg-red-100"
|
? "text-red-600 bg-red-100"
|
||||||
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
||||||
}`}
|
}`}
|
||||||
title={dict.chat.badResponse}
|
title="Bad response"
|
||||||
>
|
>
|
||||||
<ThumbsDown className="h-3.5 w-3.5" />
|
<ThumbsDown className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
MessageSquare,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react"
|
|
||||||
import Image from "next/image"
|
|
||||||
import { useState } from "react"
|
|
||||||
import ExamplePanel from "@/components/chat-example-panel"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
interface SessionMetadata {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
updatedAt: number
|
|
||||||
thumbnailDataUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatLobbyProps {
|
|
||||||
sessions: SessionMetadata[]
|
|
||||||
onSelectSession: (id: string) => void
|
|
||||||
onDeleteSession?: (id: string) => void
|
|
||||||
setInput: (input: string) => void
|
|
||||||
setFiles: (files: File[]) => void
|
|
||||||
dict: {
|
|
||||||
sessionHistory?: {
|
|
||||||
recentChats?: string
|
|
||||||
searchPlaceholder?: string
|
|
||||||
noResults?: string
|
|
||||||
justNow?: string
|
|
||||||
deleteTitle?: string
|
|
||||||
deleteDescription?: string
|
|
||||||
}
|
|
||||||
examples?: {
|
|
||||||
quickExamples?: string
|
|
||||||
}
|
|
||||||
common: {
|
|
||||||
delete: string
|
|
||||||
cancel: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format session date
|
|
||||||
function formatSessionDate(
|
|
||||||
timestamp: number,
|
|
||||||
dict?: { justNow?: string },
|
|
||||||
): string {
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
||||||
|
|
||||||
if (diffMins < 1) return dict?.justNow || "Just now"
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`
|
|
||||||
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatLobby({
|
|
||||||
sessions,
|
|
||||||
onSelectSession,
|
|
||||||
onDeleteSession,
|
|
||||||
setInput,
|
|
||||||
setFiles,
|
|
||||||
dict,
|
|
||||||
}: ChatLobbyProps) {
|
|
||||||
// Track whether examples section is expanded (collapsed by default when there's history)
|
|
||||||
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
|
||||||
// Delete confirmation dialog state
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
|
||||||
// Search filter for history
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
|
|
||||||
const hasHistory = sessions.length > 0
|
|
||||||
|
|
||||||
if (!hasHistory) {
|
|
||||||
// Show full examples when no history
|
|
||||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show history + collapsible examples when there are sessions
|
|
||||||
return (
|
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
|
||||||
{/* Recent Chats Section */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
|
||||||
</p>
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="relative mb-3">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={
|
|
||||||
dict.sessionHistory?.searchPlaceholder ||
|
|
||||||
"Search chats..."
|
|
||||||
}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sessions
|
|
||||||
.filter((session) =>
|
|
||||||
session.title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((session) => (
|
|
||||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
|
||||||
<div
|
|
||||||
key={session.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
|
||||||
onClick={() => onSelectSession(session.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault()
|
|
||||||
onSelectSession(session.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.thumbnailDataUrl ? (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={session.thumbnailDataUrl}
|
|
||||||
alt=""
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
className="object-contain w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
||||||
<MessageSquare className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium truncate">
|
|
||||||
{session.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatSessionDate(
|
|
||||||
session.updatedAt,
|
|
||||||
dict.sessionHistory,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onDeleteSession && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setSessionToDelete(session.id)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
||||||
title={dict.common.delete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.filter((s) =>
|
|
||||||
s.title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
).length === 0 &&
|
|
||||||
searchQuery && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
{dict.sessionHistory?.noResults ||
|
|
||||||
"No chats found"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible Examples Section */}
|
|
||||||
<div className="border-t border-border/50 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{dict.examples?.quickExamples || "Quick Examples"}
|
|
||||||
</span>
|
|
||||||
{examplesExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{examplesExpanded && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<ExamplePanel
|
|
||||||
setInput={setInput}
|
|
||||||
setFiles={setFiles}
|
|
||||||
minimal
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<AlertDialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
>
|
|
||||||
<AlertDialogContent className="max-w-sm">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{dict.sessionHistory?.deleteTitle ||
|
|
||||||
"Delete this chat?"}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{dict.sessionHistory?.deleteDescription ||
|
|
||||||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{dict.common.cancel}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => {
|
|
||||||
if (sessionToDelete && onDeleteSession) {
|
|
||||||
onDeleteSession(sessionToDelete)
|
|
||||||
}
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
setSessionToDelete(null)
|
|
||||||
}}
|
|
||||||
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
|
||||||
>
|
|
||||||
{dict.common.delete}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
|
||||||
import type { Dispatch, SetStateAction } from "react"
|
|
||||||
import { CodeBlock } from "@/components/code-block"
|
|
||||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
|
||||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
|
||||||
|
|
||||||
interface ToolCallCardProps {
|
|
||||||
part: ToolPartLike
|
|
||||||
expandedTools: Record<string, boolean>
|
|
||||||
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
|
||||||
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
|
||||||
copiedToolCallId: string | null
|
|
||||||
copyFailedToolCallId: string | null
|
|
||||||
dict: {
|
|
||||||
tools: { complete: string }
|
|
||||||
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{operations.map((op, index) => (
|
|
||||||
<div
|
|
||||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
|
||||||
>
|
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
|
||||||
op.operation === "delete"
|
|
||||||
? "text-red-600"
|
|
||||||
: op.operation === "add"
|
|
||||||
? "text-green-600"
|
|
||||||
: "text-blue-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{op.operation}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
cell_id: {op.cell_id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{op.new_xml && (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
|
||||||
{op.new_xml}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToolCallCard({
|
|
||||||
part,
|
|
||||||
expandedTools,
|
|
||||||
setExpandedTools,
|
|
||||||
onCopy,
|
|
||||||
copiedToolCallId,
|
|
||||||
copyFailedToolCallId,
|
|
||||||
dict,
|
|
||||||
}: ToolCallCardProps) {
|
|
||||||
const callId = part.toolCallId
|
|
||||||
const { state, input, output } = part
|
|
||||||
// Default to expanded for all states (user can manually collapse if needed)
|
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
|
||||||
const toolName = part.type?.replace("tool-", "")
|
|
||||||
const isCopied = copiedToolCallId === callId
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
setExpandedTools((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[callId]: !isExpanded,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getToolDisplayName = (name: string) => {
|
|
||||||
switch (name) {
|
|
||||||
case "display_diagram":
|
|
||||||
return "Generate Diagram"
|
|
||||||
case "edit_diagram":
|
|
||||||
return "Edit Diagram"
|
|
||||||
case "get_shape_library":
|
|
||||||
return "Get Shape Library"
|
|
||||||
default:
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
let textToCopy = ""
|
|
||||||
|
|
||||||
if (input && typeof input === "object") {
|
|
||||||
if (input.xml) {
|
|
||||||
textToCopy = input.xml
|
|
||||||
} else if (input.operations && Array.isArray(input.operations)) {
|
|
||||||
textToCopy = JSON.stringify(input.operations, null, 2)
|
|
||||||
} else if (Object.keys(input).length > 0) {
|
|
||||||
textToCopy = JSON.stringify(input, null, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
typeof output === "string"
|
|
||||||
) {
|
|
||||||
textToCopy = output
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textToCopy) {
|
|
||||||
onCopy(callId, textToCopy, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
||||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-foreground/80">
|
|
||||||
{getToolDisplayName(toolName)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{state === "input-streaming" && (
|
|
||||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
{state === "output-available" && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
|
||||||
{dict.tools.complete}
|
|
||||||
</span>
|
|
||||||
{isExpanded && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
title={
|
|
||||||
copiedToolCallId === callId
|
|
||||||
? dict.chat.copied
|
|
||||||
: copyFailedToolCallId === callId
|
|
||||||
? dict.chat.failedToCopy
|
|
||||||
: dict.chat.copyResponse
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<Check className="w-4 h-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
// Check if this is a truncation (incomplete XML) vs real error
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return isTruncated ? (
|
|
||||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
|
||||||
Truncated
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
|
||||||
Error
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{input && Object.keys(input).length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{input && isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
|
||||||
{typeof input === "object" && input.xml ? (
|
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
input.operations &&
|
|
||||||
Array.isArray(input.operations) ? (
|
|
||||||
<OperationsDisplay operations={input.operations} />
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
Object.keys(input).length > 0 ? (
|
|
||||||
<CodeBlock
|
|
||||||
code={JSON.stringify(input, null, 2)}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{output &&
|
|
||||||
state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
|
||||||
>
|
|
||||||
{isTruncated
|
|
||||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
|
||||||
: output}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{/* Show get_shape_library output on success */}
|
|
||||||
{output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
state === "output-available" &&
|
|
||||||
isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40">
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
|
||||||
Library loaded (
|
|
||||||
{typeof output === "string" ? output.length : 0}{" "}
|
|
||||||
chars)
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
|
||||||
{typeof output === "string"
|
|
||||||
? output.substring(0, 800) +
|
|
||||||
(output.length > 800 ? "\n..." : "")
|
|
||||||
: String(output)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Eye,
|
|
||||||
ImageIcon,
|
|
||||||
RefreshCw,
|
|
||||||
X,
|
|
||||||
} from "lucide-react"
|
|
||||||
import Image from "next/image"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
|
||||||
|
|
||||||
export type ValidationStatus =
|
|
||||||
| "idle"
|
|
||||||
| "capturing"
|
|
||||||
| "validating"
|
|
||||||
| "success"
|
|
||||||
| "success_with_warnings"
|
|
||||||
| "failed"
|
|
||||||
| "error"
|
|
||||||
| "skipped"
|
|
||||||
|
|
||||||
export interface ValidationState {
|
|
||||||
status: ValidationStatus
|
|
||||||
attempt?: number
|
|
||||||
maxAttempts?: number
|
|
||||||
result?: ValidationResult
|
|
||||||
error?: string
|
|
||||||
imageData?: string // Base64 PNG data URL
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidationCardProps {
|
|
||||||
state: ValidationState
|
|
||||||
onImproveWithSuggestions?: (feedback: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ValidationCard({
|
|
||||||
state,
|
|
||||||
onImproveWithSuggestions,
|
|
||||||
}: ValidationCardProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [isExpanded, setIsExpanded] = useState(
|
|
||||||
state.status === "validating" || state.status === "failed",
|
|
||||||
)
|
|
||||||
const [hasRequestedImprovement, setHasRequestedImprovement] =
|
|
||||||
useState(false)
|
|
||||||
|
|
||||||
// Generate improvement feedback from validation result
|
|
||||||
const generateImprovementFeedback = (): string => {
|
|
||||||
if (!state.result) return ""
|
|
||||||
|
|
||||||
const lines: string[] = []
|
|
||||||
lines.push(
|
|
||||||
"Please improve the diagram based on the following visual analysis feedback:",
|
|
||||||
)
|
|
||||||
lines.push("")
|
|
||||||
|
|
||||||
if (state.result.issues.length > 0) {
|
|
||||||
lines.push("Issues to address:")
|
|
||||||
for (const issue of state.result.issues) {
|
|
||||||
lines.push(
|
|
||||||
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
lines.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.result.suggestions.length > 0) {
|
|
||||||
lines.push("Suggestions for improvement:")
|
|
||||||
for (const suggestion of state.result.suggestions) {
|
|
||||||
lines.push(` - ${suggestion}`)
|
|
||||||
}
|
|
||||||
lines.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("Regenerate the diagram with these improvements applied.")
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImproveClick = () => {
|
|
||||||
if (
|
|
||||||
!onImproveWithSuggestions ||
|
|
||||||
!state.result ||
|
|
||||||
hasRequestedImprovement
|
|
||||||
)
|
|
||||||
return
|
|
||||||
setHasRequestedImprovement(true)
|
|
||||||
const feedback = generateImprovementFeedback()
|
|
||||||
onImproveWithSuggestions(feedback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should show the improve button
|
|
||||||
const showImproveButton =
|
|
||||||
onImproveWithSuggestions &&
|
|
||||||
state.result &&
|
|
||||||
(state.status === "success" ||
|
|
||||||
state.status === "success_with_warnings" ||
|
|
||||||
state.status === "skipped") &&
|
|
||||||
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
|
|
||||||
|
|
||||||
const getStatusDisplay = () => {
|
|
||||||
switch (state.status) {
|
|
||||||
case "capturing":
|
|
||||||
return {
|
|
||||||
label: dict.validation.capturing,
|
|
||||||
color: "text-blue-600 bg-blue-50",
|
|
||||||
icon: (
|
|
||||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
||||||
),
|
|
||||||
}
|
|
||||||
case "validating":
|
|
||||||
return {
|
|
||||||
label: state.attempt
|
|
||||||
? dict.validation.validatingWithAttempt
|
|
||||||
.replace("{attempt}", String(state.attempt))
|
|
||||||
.replace("{max}", String(state.maxAttempts || 3))
|
|
||||||
: dict.validation.validating,
|
|
||||||
color: "text-blue-600 bg-blue-50",
|
|
||||||
icon: (
|
|
||||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
||||||
),
|
|
||||||
}
|
|
||||||
case "success":
|
|
||||||
return {
|
|
||||||
label: dict.validation.valid,
|
|
||||||
color: "text-green-600 bg-green-50",
|
|
||||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
|
||||||
}
|
|
||||||
case "success_with_warnings":
|
|
||||||
return {
|
|
||||||
label: dict.validation.validWithWarnings,
|
|
||||||
color: "text-amber-600 bg-amber-50",
|
|
||||||
icon: (
|
|
||||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
}
|
|
||||||
case "failed":
|
|
||||||
return {
|
|
||||||
label: dict.validation.issuesFound,
|
|
||||||
color: "text-yellow-600 bg-yellow-50",
|
|
||||||
icon: (
|
|
||||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
return {
|
|
||||||
label: dict.validation.error,
|
|
||||||
color: "text-red-600 bg-red-50",
|
|
||||||
icon: <X className="h-4 w-4" aria-hidden="true" />,
|
|
||||||
}
|
|
||||||
case "skipped":
|
|
||||||
return {
|
|
||||||
label: dict.validation.skipped,
|
|
||||||
color: "text-gray-600 bg-gray-50",
|
|
||||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusDisplay = getStatusDisplay()
|
|
||||||
if (!statusDisplay || state.status === "idle") return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
||||||
<Eye
|
|
||||||
className="w-3.5 h-3.5 text-primary"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-foreground/80">
|
|
||||||
{dict.validation.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
|
|
||||||
>
|
|
||||||
{statusDisplay.icon}
|
|
||||||
<span className="ml-1">{statusDisplay.label}</span>
|
|
||||||
</span>
|
|
||||||
{(state.result || state.error) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp
|
|
||||||
className="w-4 h-4 text-muted-foreground"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronDown
|
|
||||||
className="w-4 h-4 text-muted-foreground"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Validation details when expanded */}
|
|
||||||
{isExpanded && (state.result || state.imageData) && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
|
|
||||||
{/* Captured image */}
|
|
||||||
{state.imageData && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
|
|
||||||
<ImageIcon
|
|
||||||
className="h-3 w-3"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.validation.capturedScreenshot}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
|
|
||||||
<Image
|
|
||||||
src={state.imageData}
|
|
||||||
alt="Captured diagram for validation"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="w-full h-auto max-h-48 object-contain"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Issues */}
|
|
||||||
{state.result && state.result.issues.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
|
||||||
{dict.validation.issuesFoundLabel}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{state.result.issues.map((issue, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`text-xs px-3 py-2 rounded-lg border ${
|
|
||||||
issue.severity === "critical"
|
|
||||||
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
|
|
||||||
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="font-medium uppercase text-[10px] mr-2">
|
|
||||||
[{issue.type}]
|
|
||||||
</span>
|
|
||||||
{issue.description}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Suggestions */}
|
|
||||||
{state.result && state.result.suggestions.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
|
||||||
{dict.validation.suggestions}
|
|
||||||
</div>
|
|
||||||
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
|
|
||||||
{state.result.suggestions.map(
|
|
||||||
(suggestion, index) => (
|
|
||||||
<li key={index}>{suggestion}</li>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Valid result message */}
|
|
||||||
{state.result?.valid &&
|
|
||||||
state.result.issues.length === 0 && (
|
|
||||||
<div className="text-xs text-green-600 dark:text-green-400">
|
|
||||||
{dict.validation.passedValidation}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
|
|
||||||
{showImproveButton && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
|
|
||||||
{hasRequestedImprovement ? (
|
|
||||||
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
|
|
||||||
<Check className="h-4 w-4" aria-hidden="true" />
|
|
||||||
{dict.validation.improvementRequested}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleImproveClick}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className="h-4 w-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{dict.validation.improveWithSuggestions}
|
|
||||||
</button>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
|
||||||
{dict.validation.regenerateWithFeedback}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error details when expanded */}
|
|
||||||
{isExpanded && state.error && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
|
|
||||||
<div className="text-xs text-red-600">{state.error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export interface DiagramOperation {
|
|
||||||
operation: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
|
||||||
new_xml?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolPartLike {
|
|
||||||
type: string
|
|
||||||
toolCallId: string
|
|
||||||
state?: string
|
|
||||||
input?: {
|
|
||||||
xml?: string
|
|
||||||
operations?: DiagramOperation[]
|
|
||||||
} & Record<string, unknown>
|
|
||||||
output?: string
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { wrapWithMxFile } from "@/lib/utils"
|
import { wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
// Dev XML presets for streaming simulator
|
// Dev XML presets for streaming simulator
|
||||||
@@ -143,7 +142,6 @@ export function DevXmlSimulator({
|
|||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onShowQuotaToast,
|
onShowQuotaToast,
|
||||||
}: DevXmlSimulatorProps) {
|
}: DevXmlSimulatorProps) {
|
||||||
const dict = useDictionary()
|
|
||||||
const [devXml, setDevXml] = useState("")
|
const [devXml, setDevXml] = useState("")
|
||||||
const [isSimulating, setIsSimulating] = useState(false)
|
const [isSimulating, setIsSimulating] = useState(false)
|
||||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||||
@@ -180,7 +178,7 @@ export function DevXmlSimulator({
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: dict.dev.simulatingMessage,
|
text: "[Dev] Simulating XML streaming",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -230,7 +228,7 @@ export function DevXmlSimulator({
|
|||||||
const lastMsg = updated[updated.length - 1] as any
|
const lastMsg = updated[updated.length - 1] as any
|
||||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||||
lastMsg.parts[0].state = "output-available"
|
lastMsg.parts[0].state = "output-available"
|
||||||
lastMsg.parts[0].output = dict.dev.successMessage
|
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||||
lastMsg.parts[0].input = { xml }
|
lastMsg.parts[0].input = { xml }
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
@@ -247,12 +245,12 @@ export function DevXmlSimulator({
|
|||||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||||
<details>
|
<details>
|
||||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||||
{dict.dev.title}
|
Dev: XML Streaming Simulator
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.preset}
|
Preset:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -264,7 +262,7 @@ export function DevXmlSimulator({
|
|||||||
defaultValue=""
|
defaultValue=""
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{dict.dev.selectPreset}
|
Select a preset...
|
||||||
</option>
|
</option>
|
||||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||||
<option key={name} value={name}>
|
<option key={name} value={name}>
|
||||||
@@ -277,19 +275,19 @@ export function DevXmlSimulator({
|
|||||||
onClick={() => setDevXml("")}
|
onClick={() => setDevXml("")}
|
||||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||||
>
|
>
|
||||||
{dict.dev.clear}
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={devXml}
|
value={devXml}
|
||||||
onChange={(e) => setDevXml(e.target.value)}
|
onChange={(e) => setDevXml(e.target.value)}
|
||||||
placeholder={dict.dev.placeholder}
|
placeholder="Paste mxCell XML here or select a preset..."
|
||||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.interval}
|
Interval:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -308,7 +306,7 @@ export function DevXmlSimulator({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.chars}
|
Chars:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -332,8 +330,8 @@ export function DevXmlSimulator({
|
|||||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isSimulating
|
{isSimulating
|
||||||
? dict.dev.streaming
|
? "Streaming..."
|
||||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||||
</button>
|
</button>
|
||||||
{isSimulating && (
|
{isSimulating && (
|
||||||
<button
|
<button
|
||||||
@@ -343,7 +341,7 @@ export function DevXmlSimulator({
|
|||||||
}}
|
}}
|
||||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
>
|
>
|
||||||
{dict.dev.stop}
|
Stop
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onShowQuotaToast && (
|
{onShowQuotaToast && (
|
||||||
@@ -352,7 +350,7 @@ export function DevXmlSimulator({
|
|||||||
onClick={onShowQuotaToast}
|
onClick={onShowQuotaToast}
|
||||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||||
>
|
>
|
||||||
{dict.dev.testQuotaToast}
|
Test Quota Toast
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
import { FileCode, FileText, Loader2, X } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
@@ -20,19 +20,12 @@ interface FilePreviewListProps {
|
|||||||
File,
|
File,
|
||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
urlData?: Map<
|
|
||||||
string,
|
|
||||||
{ url: string; title: string; charCount: number; isExtracting: boolean }
|
|
||||||
>
|
|
||||||
onRemoveUrl?: (url: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePreviewList({
|
export function FilePreviewList({
|
||||||
files,
|
files,
|
||||||
onRemoveFile,
|
onRemoveFile,
|
||||||
pdfData = new Map(),
|
pdfData = new Map(),
|
||||||
urlData,
|
|
||||||
onRemoveUrl,
|
|
||||||
}: FilePreviewListProps) {
|
}: FilePreviewListProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||||
@@ -84,7 +77,7 @@ export function FilePreviewList({
|
|||||||
}
|
}
|
||||||
}, [imageUrls, selectedImage])
|
}, [imageUrls, selectedImage])
|
||||||
|
|
||||||
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
|
if (files.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -159,59 +152,6 @@ export function FilePreviewList({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{/* URL previews */}
|
|
||||||
{urlData && urlData.size > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Array.from(urlData.entries()).map(
|
|
||||||
([url, data], index) => (
|
|
||||||
<div
|
|
||||||
key={url + index}
|
|
||||||
className="relative group"
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
|
|
||||||
<div className="flex flex-col items-center justify-center h-full p-1">
|
|
||||||
{data.isExtracting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{dict.file.reading}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link className="h-6 w-6 text-blue-500 mb-1" />
|
|
||||||
<span className="text-xs text-center truncate w-full px-1">
|
|
||||||
{data.title.length > 10
|
|
||||||
? `${data.title.slice(0, 7)}...`
|
|
||||||
: data.title}
|
|
||||||
</span>
|
|
||||||
{data.charCount && (
|
|
||||||
<span className="text-[10px] text-green-600 font-medium">
|
|
||||||
{formatCharCount(
|
|
||||||
data.charCount,
|
|
||||||
)}{" "}
|
|
||||||
{dict.file.chars}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onRemoveUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemoveUrl(url)}
|
|
||||||
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
aria-label={dict.file.removeFile}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Image Modal/Lightbox */}
|
{/* Image Modal/Lightbox */}
|
||||||
{selectedImage && (
|
{selectedImage && (
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function HistoryDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dict.history.title}</DialogTitle>
|
<DialogTitle>{dict.history.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,8 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Monitor,
|
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
User,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +19,6 @@ import {
|
|||||||
ModelSelectorLogo,
|
ModelSelectorLogo,
|
||||||
ModelSelectorName,
|
ModelSelectorName,
|
||||||
ModelSelector as ModelSelectorRoot,
|
ModelSelector as ModelSelectorRoot,
|
||||||
ModelSelectorSectionHeader,
|
|
||||||
ModelSelectorSeparator,
|
ModelSelectorSeparator,
|
||||||
ModelSelectorTrigger,
|
ModelSelectorTrigger,
|
||||||
} from "@/components/ai-elements/model-selector"
|
} from "@/components/ai-elements/model-selector"
|
||||||
@@ -51,10 +48,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
siliconflow: "siliconflow",
|
siliconflow: "siliconflow",
|
||||||
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
||||||
gateway: "vercel",
|
gateway: "vercel",
|
||||||
edgeone: "tencent-cloud",
|
|
||||||
vertexai: "google",
|
|
||||||
doubao: "bytedance",
|
doubao: "bytedance",
|
||||||
modelscope: "modelscope",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
@@ -66,11 +60,7 @@ function groupModelsByProvider(
|
|||||||
{ provider: string; models: FlattenedModel[] }
|
{ provider: string; models: FlattenedModel[] }
|
||||||
>()
|
>()
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
// For server models, strip "Server · " prefix for cleaner grouping
|
const key = model.providerLabel
|
||||||
const key =
|
|
||||||
model.source === "server"
|
|
||||||
? model.providerLabel.replace(/^Server · /, "")
|
|
||||||
: model.providerLabel
|
|
||||||
const existing = groups.get(key)
|
const existing = groups.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.models.push(model)
|
existing.models.push(model)
|
||||||
@@ -98,26 +88,10 @@ export function ModelSelector({
|
|||||||
}
|
}
|
||||||
return models.filter((m) => m.validated === true)
|
return models.filter((m) => m.validated === true)
|
||||||
}, [models, showUnvalidatedModels])
|
}, [models, showUnvalidatedModels])
|
||||||
|
const groupedModels = useMemo(
|
||||||
// Separate server and user models
|
() => groupModelsByProvider(displayModels),
|
||||||
const serverModels = useMemo(
|
|
||||||
() => displayModels.filter((m) => m.source === "server"),
|
|
||||||
[displayModels],
|
[displayModels],
|
||||||
)
|
)
|
||||||
const userModels = useMemo(
|
|
||||||
() => displayModels.filter((m) => m.source !== "server"),
|
|
||||||
[displayModels],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Group each category separately
|
|
||||||
const groupedServerModels = useMemo(
|
|
||||||
() => groupModelsByProvider(serverModels),
|
|
||||||
[serverModels],
|
|
||||||
)
|
|
||||||
const groupedUserModels = useMemo(
|
|
||||||
() => groupModelsByProvider(userModels),
|
|
||||||
[userModels],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Find selected model for display
|
// Find selected model for display
|
||||||
const selectedModel = useMemo(
|
const selectedModel = useMemo(
|
||||||
@@ -184,7 +158,7 @@ export function ModelSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
|
"hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out",
|
||||||
!showLabel && "px-1.5 justify-center",
|
!showLabel && "px-1.5 justify-center",
|
||||||
)}
|
)}
|
||||||
// accessibility: expose label to screen readers
|
// accessibility: expose label to screen readers
|
||||||
@@ -221,169 +195,83 @@ export function ModelSelector({
|
|||||||
: dict.modelConfig.noModelsFound}
|
: dict.modelConfig.noModelsFound}
|
||||||
</ModelSelectorEmpty>
|
</ModelSelectorEmpty>
|
||||||
|
|
||||||
{/* Server Default Option - only show when no server models are configured */}
|
{/* Server Default Option */}
|
||||||
{serverModels.length === 0 && (
|
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
||||||
<ModelSelectorGroup
|
<ModelSelectorItem
|
||||||
heading={dict.modelConfig.default}
|
value="__server_default__"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
!selectedModelId && "bg-accent",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ModelSelectorItem
|
<Check
|
||||||
value="__server_default__"
|
|
||||||
onSelect={handleSelect}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer",
|
"mr-2 h-4 w-4",
|
||||||
!selectedModelId && "bg-accent",
|
!selectedModelId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.serverDefault}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
|
||||||
|
{/* Configured Models by Provider */}
|
||||||
|
{Array.from(groupedModels.entries()).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{ provider, models: providerModels },
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={providerLabel}
|
||||||
|
heading={providerLabel}
|
||||||
>
|
>
|
||||||
<Check
|
{providerModels.map((model) => (
|
||||||
className={cn(
|
<ModelSelectorItem
|
||||||
"mr-2 h-4 w-4",
|
key={model.id}
|
||||||
!selectedModelId
|
value={model.modelId}
|
||||||
? "opacity-100"
|
onSelect={() =>
|
||||||
: "opacity-0",
|
handleSelect(model.id)
|
||||||
)}
|
}
|
||||||
/>
|
className="cursor-pointer"
|
||||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<ModelSelectorName>
|
|
||||||
{dict.modelConfig.serverDefault}
|
|
||||||
</ModelSelectorName>
|
|
||||||
</ModelSelectorItem>
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server Models Section */}
|
|
||||||
{serverModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
<ModelSelectorSectionHeader
|
|
||||||
icon={<Monitor />}
|
|
||||||
label={dict.modelConfig.serverModels}
|
|
||||||
/>
|
|
||||||
{Array.from(groupedServerModels.entries()).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{ provider, models: providerModels },
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={`server-${providerLabel}`}
|
|
||||||
heading={providerLabel}
|
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
|
||||||
>
|
>
|
||||||
{providerModels.map((model) => (
|
<Check
|
||||||
<ModelSelectorItem
|
className={cn(
|
||||||
key={model.id}
|
"mr-2 h-4 w-4",
|
||||||
value={model.modelId}
|
selectedModelId === model.id
|
||||||
onSelect={() =>
|
? "opacity-100"
|
||||||
handleSelect(model.id)
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] || provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{model.modelId}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.validated !== true && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict.modelConfig
|
||||||
|
.unvalidatedModelWarning
|
||||||
}
|
}
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<Check
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
className={cn(
|
</span>
|
||||||
"mr-2 h-4 w-4",
|
)}
|
||||||
selectedModelId ===
|
</ModelSelectorItem>
|
||||||
model.id
|
))}
|
||||||
? "opacity-100"
|
</ModelSelectorGroup>
|
||||||
: "opacity-0",
|
),
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] || provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{model.modelId}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.isDefault && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict.modelConfig
|
|
||||||
.serverDefaultModel
|
|
||||||
}
|
|
||||||
className="ml-auto text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dict.modelConfig
|
|
||||||
.default
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
))}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Models Section */}
|
|
||||||
{userModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
{serverModels.length > 0 && (
|
|
||||||
<ModelSelectorSeparator />
|
|
||||||
)}
|
|
||||||
<ModelSelectorSectionHeader
|
|
||||||
icon={<User />}
|
|
||||||
label={dict.modelConfig.userModels}
|
|
||||||
/>
|
|
||||||
{Array.from(groupedUserModels.entries()).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{ provider, models: providerModels },
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={`user-${providerLabel}`}
|
|
||||||
heading={providerLabel}
|
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
|
||||||
>
|
|
||||||
{providerModels.map((model) => (
|
|
||||||
<ModelSelectorItem
|
|
||||||
key={model.id}
|
|
||||||
value={model.modelId}
|
|
||||||
onSelect={() =>
|
|
||||||
handleSelect(model.id)
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] || provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{model.modelId}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.validated !==
|
|
||||||
true && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict.modelConfig
|
|
||||||
.unvalidatedModelWarning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
))}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Configure Option */}
|
{/* Configure Option */}
|
||||||
@@ -392,7 +280,7 @@ export function ModelSelector({
|
|||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
value="__configure__"
|
value="__configure__"
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
<ModelSelectorName>
|
<ModelSelectorName>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
|
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -25,7 +24,6 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
|
||||||
|
|
||||||
// Reusable setting item component for consistent layout
|
// Reusable setting item component for consistent layout
|
||||||
function SettingItem({
|
function SettingItem({
|
||||||
@@ -61,17 +59,17 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
|||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCloseProtectionChange?: (enabled: boolean) => void
|
||||||
drawioUi: "min" | "sketch"
|
drawioUi: "min" | "sketch"
|
||||||
onToggleDrawioUi: () => void
|
onToggleDrawioUi: () => void
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
onToggleDarkMode: () => void
|
onToggleDarkMode: () => void
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
vlmValidationEnabled?: boolean
|
|
||||||
onVlmValidationChange?: (value: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
|
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
|
|
||||||
function getStoredAccessCodeRequired(): boolean | null {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
@@ -84,32 +82,26 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
function SettingsContent({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
onCloseProtectionChange,
|
||||||
drawioUi,
|
drawioUi,
|
||||||
onToggleDrawioUi,
|
onToggleDrawioUi,
|
||||||
darkMode,
|
darkMode,
|
||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
vlmValidationEnabled = false,
|
|
||||||
onVlmValidationChange = () => {},
|
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname() || "/"
|
const pathname = usePathname() || "/"
|
||||||
const search = useSearchParams()
|
const search = useSearchParams()
|
||||||
const [accessCode, setAccessCode] = useState("")
|
const [accessCode, setAccessCode] = useState("")
|
||||||
|
const [closeProtection, setCloseProtection] = useState(true)
|
||||||
const [isVerifying, setIsVerifying] = useState(false)
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [currentLang, setCurrentLang] = useState("en")
|
const [currentLang, setCurrentLang] = useState("en")
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
|
||||||
|
|
||||||
// Proxy settings state (Electron only)
|
|
||||||
const [httpProxy, setHttpProxy] = useState("")
|
|
||||||
const [httpsProxy, setHttpsProxy] = useState("")
|
|
||||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Only fetch if not cached in localStorage
|
||||||
@@ -151,20 +143,13 @@ function SettingsContent({
|
|||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||||
setAccessCode(storedCode)
|
setAccessCode(storedCode)
|
||||||
|
|
||||||
const storedSendShortcut = localStorage.getItem(
|
const storedCloseProtection = localStorage.getItem(
|
||||||
STORAGE_KEYS.sendShortcut,
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
)
|
)
|
||||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
// Default to true if not set
|
||||||
|
setCloseProtection(storedCloseProtection !== "false")
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
// Load proxy settings (Electron only)
|
|
||||||
if (window.electronAPI?.getProxy) {
|
|
||||||
window.electronAPI.getProxy().then((config) => {
|
|
||||||
setHttpProxy(config.httpProxy || "")
|
|
||||||
setHttpsProxy(config.httpsProxy || "")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
@@ -172,13 +157,6 @@ function SettingsContent({
|
|||||||
// Save locale to localStorage for persistence across restarts
|
// Save locale to localStorage for persistence across restarts
|
||||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||||
|
|
||||||
// Notify Electron main process to update its menu language
|
|
||||||
if (window.electronAPI?.setUserLocale) {
|
|
||||||
window.electronAPI.setUserLocale(lang).catch((error) => {
|
|
||||||
console.error("Failed to sync locale with Electron:", error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = pathname.split("/")
|
const parts = pathname.split("/")
|
||||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||||
parts[1] = lang
|
parts[1] = lang
|
||||||
@@ -230,46 +208,6 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyProxy = async () => {
|
|
||||||
if (!window.electronAPI?.setProxy) return
|
|
||||||
|
|
||||||
// Validate proxy URLs (must start with http:// or https://)
|
|
||||||
const validateProxyUrl = (url: string): boolean => {
|
|
||||||
if (!url) return true // Empty is OK
|
|
||||||
return url.startsWith("http://") || url.startsWith("https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedHttp = httpProxy.trim()
|
|
||||||
const trimmedHttps = httpsProxy.trim()
|
|
||||||
|
|
||||||
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
|
|
||||||
toast.error("HTTP Proxy must start with http:// or https://")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
|
|
||||||
toast.error("HTTPS Proxy must start with http:// or https://")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsApplyingProxy(true)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.setProxy({
|
|
||||||
httpProxy: trimmedHttp || undefined,
|
|
||||||
httpsProxy: trimmedHttps || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(dict.settings.proxyApplied)
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Failed to apply proxy settings")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to apply proxy settings")
|
|
||||||
} finally {
|
|
||||||
setIsApplyingProxy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -395,6 +333,25 @@ function SettingsContent({
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Close Protection */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.closeProtection}
|
||||||
|
description={dict.settings.closeProtectionDescription}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="close-protection"
|
||||||
|
checked={closeProtection}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setCloseProtection(checked)
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_CLOSE_PROTECTION_KEY,
|
||||||
|
checked.toString(),
|
||||||
|
)
|
||||||
|
onCloseProtectionChange?.(checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* Diagram Style */}
|
{/* Diagram Style */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramStyle}
|
label={dict.settings.diagramStyle}
|
||||||
@@ -413,110 +370,6 @@ function SettingsContent({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
{/* VLM Diagram Validation */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.diagramValidation}
|
|
||||||
description={dict.settings.diagramValidationDescription}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="vlm-validation"
|
|
||||||
checked={vlmValidationEnabled}
|
|
||||||
onCheckedChange={onVlmValidationChange}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{vlmValidationEnabled
|
|
||||||
? dict.settings.enabled
|
|
||||||
: dict.settings.disabled}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Send Shortcut */}
|
|
||||||
<SettingItem
|
|
||||||
label={dict.settings.sendShortcut}
|
|
||||||
description={dict.settings.sendShortcutDescription}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={sendShortcut}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSendShortcut(value)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.sendShortcut,
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("sendShortcutChange", {
|
|
||||||
detail: value,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="send-shortcut-select"
|
|
||||||
className="w-auto h-9 rounded-xl"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="enter">
|
|
||||||
{dict.settings.enterToSend}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="ctrl-enter">
|
|
||||||
{dict.settings.ctrlEnterToSend}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
{/* Proxy Settings - Electron only */}
|
|
||||||
{typeof window !== "undefined" &&
|
|
||||||
window.electronAPI?.isElectron && (
|
|
||||||
<div className="py-4 space-y-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label className="text-sm font-medium">
|
|
||||||
{dict.settings.proxy}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.proxyDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
id="http-proxy"
|
|
||||||
type="text"
|
|
||||||
value={httpProxy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setHttpProxy(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="https-proxy"
|
|
||||||
type="text"
|
|
||||||
value={httpsProxy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setHttpsProxy(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleApplyProxy}
|
|
||||||
disabled={isApplyingProxy}
|
|
||||||
className="h-9 px-4 rounded-xl w-full"
|
|
||||||
>
|
|
||||||
{isApplyingProxy
|
|
||||||
? "..."
|
|
||||||
: dict.settings.applyProxy}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -542,13 +395,13 @@ function SettingsContent({
|
|||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">·</span>
|
<span className="text-muted-foreground">·</span>
|
||||||
<a
|
<a
|
||||||
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
|
href="/about"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Info className="h-3 w-3" />
|
<Info className="h-3 w-3" />
|
||||||
{dict.nav.about}
|
About
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Link, Loader2 } from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
|
|
||||||
interface UrlInputDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSubmit: (url: string) => void
|
|
||||||
isExtracting: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UrlInputDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSubmit,
|
|
||||||
isExtracting,
|
|
||||||
}: UrlInputDialogProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [url, setUrl] = useState("")
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
setError("")
|
|
||||||
|
|
||||||
if (!url.trim()) {
|
|
||||||
setError(dict.url.enterUrl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
new URL(url)
|
|
||||||
} catch {
|
|
||||||
setError(dict.url.invalidFormat)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(url.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter" && !isExtracting) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSubmit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{dict.url.title}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{dict.url.description}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => {
|
|
||||||
setUrl(e.target.value)
|
|
||||||
setError("")
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="https://example.com/article"
|
|
||||||
disabled={isExtracting}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isExtracting}
|
|
||||||
>
|
|
||||||
{dict.url.Cancel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isExtracting || !url.trim()}
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{dict.url.Extracting}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link className="mr-2 h-4 w-4" />
|
|
||||||
{dict.url.extract}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,36 +3,28 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef } from "react-drawio"
|
||||||
import { toast } from "sonner"
|
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
||||||
extractDiagramXML,
|
|
||||||
isRealDiagram,
|
|
||||||
validateAndFixXml,
|
|
||||||
} from "../lib/utils"
|
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
latestSvg: string
|
latestSvg: string
|
||||||
diagramHistory: { svg: string; xml: string }[]
|
diagramHistory: { svg: string; xml: string }[]
|
||||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
|
||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
resolverRef: React.Ref<((value: string) => void) | null>
|
||||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: any) => void
|
handleDiagramExport: (data: any) => void
|
||||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
successMessage?: string,
|
|
||||||
) => void
|
) => void
|
||||||
getThumbnailSvg: () => Promise<string | null>
|
saveDiagramToStorage: () => Promise<void>
|
||||||
captureValidationPng: () => Promise<string | null>
|
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -49,54 +41,72 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
{ svg: string; xml: string }[]
|
{ svg: string; xml: string }[]
|
||||||
>([])
|
>([])
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
|
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
// Resolver for PNG export (used for VLM validation)
|
|
||||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
const expectHistoryExportRef = useRef<boolean>(false)
|
||||||
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
|
// Track if diagram has been restored from localStorage
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
const hasDiagramRestoredRef = useRef<boolean>(false)
|
||||||
// Track latest chartXML for restoration after remount
|
|
||||||
const chartXMLRef = useRef<string>("")
|
|
||||||
|
|
||||||
const onDrawioLoad = () => {
|
const onDrawioLoad = () => {
|
||||||
// Only set ready state once to prevent infinite loops
|
// Only set ready state once to prevent infinite loops
|
||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
|
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
|
||||||
setIsDrawioReady(true)
|
setIsDrawioReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDrawioReady = () => {
|
const resetDrawioReady = () => {
|
||||||
|
// console.log("[DiagramContext] Resetting DrawIO ready state")
|
||||||
hasCalledOnLoadRef.current = false
|
hasCalledOnLoadRef.current = false
|
||||||
setIsDrawioReady(false)
|
setIsDrawioReady(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep chartXMLRef in sync with state for restoration after remount
|
// Restore diagram XML when DrawIO becomes ready
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chartXMLRef.current = chartXML
|
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
|
||||||
}, [chartXML])
|
|
||||||
|
|
||||||
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
|
|
||||||
if (!isDrawioReady) {
|
if (!isDrawioReady) {
|
||||||
hasDiagramRestoredRef.current = false
|
hasDiagramRestoredRef.current = false
|
||||||
|
setCanSaveDiagram(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only restore once per ready cycle
|
|
||||||
if (hasDiagramRestoredRef.current) return
|
if (hasDiagramRestoredRef.current) return
|
||||||
hasDiagramRestoredRef.current = true
|
hasDiagramRestoredRef.current = true
|
||||||
|
|
||||||
// Restore diagram from ref if we have one
|
try {
|
||||||
const xmlToRestore = chartXMLRef.current
|
const savedDiagramXml = localStorage.getItem(
|
||||||
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
STORAGE_DIAGRAM_XML_KEY,
|
||||||
drawioRef.current.load({ xml: xmlToRestore })
|
)
|
||||||
|
if (savedDiagramXml) {
|
||||||
|
// Skip validation for trusted saved diagrams
|
||||||
|
loadDiagram(savedDiagramXml, true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to restore diagram from localStorage:", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow saving after restore is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
setCanSaveDiagram(true)
|
||||||
|
}, 500)
|
||||||
}, [isDrawioReady])
|
}, [isDrawioReady])
|
||||||
|
|
||||||
|
// Save diagram XML to localStorage whenever it changes (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canSaveDiagram) return
|
||||||
|
if (!chartXML || chartXML.length <= 300) return
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [chartXML, canSaveDiagram])
|
||||||
|
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
const saveResolverRef = useRef<{
|
const saveResolverRef = useRef<{
|
||||||
resolver: ((data: string) => void) | null
|
resolver: ((data: string) => void) | null
|
||||||
@@ -122,63 +132,27 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
// Save current diagram to localStorage (used before theme/UI changes)
|
||||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
const saveDiagramToStorage = async (): Promise<void> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return
|
||||||
// Don't export if diagram is empty
|
|
||||||
if (!isRealDiagram(chartXML)) return null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const svgData = await Promise.race([
|
const currentXml = await Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
resolverRef.current = resolve
|
resolverRef.current = resolve
|
||||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
setTimeout(() => reject(new Error("Export timeout")), 2000),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Update latestSvg so it's available for future saves
|
// Only save if diagram has meaningful content (not empty template)
|
||||||
if (svgData?.includes("<svg")) {
|
if (currentXml && currentXml.length > 300) {
|
||||||
setLatestSvg(svgData)
|
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
|
||||||
return svgData
|
|
||||||
}
|
}
|
||||||
return null
|
} catch (error) {
|
||||||
} catch {
|
console.error("Failed to save diagram to storage:", error)
|
||||||
// Timeout is expected occasionally - don't log as error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture current diagram as PNG for VLM validation
|
|
||||||
const captureValidationPng = async (): Promise<string | null> => {
|
|
||||||
if (!drawioRef.current) return null
|
|
||||||
// Don't export if diagram is empty
|
|
||||||
if (!isRealDiagram(chartXML)) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pngData = await Promise.race([
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
pngResolverRef.current = resolve
|
|
||||||
drawioRef.current?.exportDiagram({ format: "png" })
|
|
||||||
}),
|
|
||||||
new Promise<string>((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error("PNG export timeout")),
|
|
||||||
5000,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
// PNG data should be a base64 data URL
|
|
||||||
if (pngData?.startsWith("data:image/png")) {
|
|
||||||
return pngData
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
// Timeout is expected occasionally - don't log as error
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,13 +195,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramExport = (data: any) => {
|
const handleDiagramExport = (data: any) => {
|
||||||
// Handle PNG export for VLM validation
|
|
||||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
|
||||||
pngResolverRef.current(data.data)
|
|
||||||
pngResolverRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle save to file if requested (process raw data before extraction)
|
// Handle save to file if requested (process raw data before extraction)
|
||||||
if (saveResolverRef.current.resolver) {
|
if (saveResolverRef.current.resolver) {
|
||||||
const format = saveResolverRef.current.format
|
const format = saveResolverRef.current.format
|
||||||
@@ -268,11 +235,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
|
||||||
if (!data?.xml) return
|
|
||||||
setChartXML(data.xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
@@ -285,7 +247,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
successMessage?: string,
|
|
||||||
) => {
|
) => {
|
||||||
if (!drawioRef.current) {
|
if (!drawioRef.current) {
|
||||||
console.warn("Draw.io editor not ready")
|
console.warn("Draw.io editor not ready")
|
||||||
@@ -312,6 +273,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fileContent = xmlContent
|
fileContent = xmlContent
|
||||||
mimeType = "application/xml"
|
mimeType = "application/xml"
|
||||||
extension = ".drawio"
|
extension = ".drawio"
|
||||||
|
|
||||||
|
// Save to localStorage when user manually saves
|
||||||
|
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
|
||||||
} else if (format === "png") {
|
} else if (format === "png") {
|
||||||
// PNG data comes as base64 data URL
|
// PNG data comes as base64 data URL
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
@@ -347,14 +311,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
|
|
||||||
// Show success toast after download is initiated
|
|
||||||
if (successMessage) {
|
|
||||||
toast.success(successMessage, {
|
|
||||||
position: "bottom-left",
|
|
||||||
duration: 2500,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
// Delay URL revocation to ensure download completes
|
||||||
if (!url.startsWith("data:")) {
|
if (!url.startsWith("data:")) {
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||||
@@ -390,18 +346,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
chartXML,
|
chartXML,
|
||||||
latestSvg,
|
latestSvg,
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
setDiagramHistory,
|
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
resolverRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
saveDiagramToStorage,
|
||||||
captureValidationPng,
|
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
# environment:
|
environment:
|
||||||
# # For subdirectory deployment, uncomment and set your path:
|
# For subdirectory deployment, uncomment and set your path:
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
depends_on: [drawio]
|
depends_on: [drawio]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**AI驱动的图表创建工具 - 对话、绘制、可视化**
|
**AI驱动的图表创建工具 - 对话、绘制、可视化**
|
||||||
|
|
||||||
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
|
[English](../README.md) | 中文 | [日本語](./README_JA.md)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,20 +29,15 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [示例](#示例)
|
- [示例](#示例)
|
||||||
- [功能特性](#功能特性)
|
- [功能特性](#功能特性)
|
||||||
- [MCP服务器(预览)](#mcp服务器预览)
|
- [MCP服务器(预览)](#mcp服务器预览)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [在线试用](#在线试用)
|
- [在线试用](#在线试用)
|
||||||
- [桌面应用](#桌面应用)
|
- [使用Docker运行(推荐)](#使用docker运行推荐)
|
||||||
- [使用Docker运行](#使用docker运行)
|
|
||||||
- [安装](#安装)
|
- [安装](#安装)
|
||||||
- [部署](#部署)
|
- [部署](#部署)
|
||||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
|
||||||
- [部署到Vercel](#部署到vercel)
|
|
||||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
|
||||||
- [多提供商支持](#多提供商支持)
|
- [多提供商支持](#多提供商支持)
|
||||||
- [工作原理](#工作原理)
|
- [工作原理](#工作原理)
|
||||||
|
- [项目结构](#项目结构)
|
||||||
- [支持与联系](#支持与联系)
|
- [支持与联系](#支持与联系)
|
||||||
- [常见问题](#常见问题)
|
|
||||||
- [Star历史](#star历史)
|
- [Star历史](#star历史)
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
@@ -55,31 +50,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>动画Transformer连接器</strong><br />
|
<strong>动画Transformer连接器</strong><br />
|
||||||
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
<img src="../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCP架构图</strong><br />
|
<strong>GCP架构图</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
<img src="../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWS架构图</strong><br />
|
<strong>AWS架构图</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
<img src="../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azure架构图</strong><br />
|
<strong>Azure架构图</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
<img src="../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫咪素描</strong><br />
|
<strong>猫咪素描</strong><br />
|
||||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
<img src="../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -98,7 +93,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
|
|
||||||
## MCP服务器(预览)
|
## MCP服务器(预览)
|
||||||
|
|
||||||
> **预览功能**:此功能为实验性功能,可能不稳定。
|
> **预览功能**:此功能为实验性功能,可能会有变化。
|
||||||
|
|
||||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||||
|
|
||||||
@@ -124,7 +119,7 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
|
|
||||||
图表会实时显示在浏览器中!
|
图表会实时显示在浏览器中!
|
||||||
|
|
||||||
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
详情请参阅[MCP服务器README](../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -132,19 +127,39 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
|
|
||||||
无需安装!直接在我们的演示站点试用:
|
无需安装!直接在我们的演示站点试用:
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
|
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
|
||||||
|
|
||||||
### 桌面应用
|
### 使用Docker运行(推荐)
|
||||||
|
|
||||||
从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用:
|
如果您只想在本地运行,最好的方式是使用Docker。
|
||||||
|
|
||||||
支持的平台:Windows、macOS、Linux。
|
首先,如果您还没有安装Docker,请先安装:[获取Docker](https://docs.docker.com/get-docker/)
|
||||||
|
|
||||||
### 使用Docker运行
|
然后运行:
|
||||||
|
|
||||||
[查看 Docker 指南](./docker.md)
|
```bash
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e AI_PROVIDER=openai \
|
||||||
|
-e AI_MODEL=gpt-4o \
|
||||||
|
-e OPENAI_API_KEY=your_api_key \
|
||||||
|
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或者使用 env 文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
# 编辑 .env 填写您的配置
|
||||||
|
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
在浏览器中打开 [http://localhost:3000](http://localhost:3000)。
|
||||||
|
|
||||||
|
请根据您首选的AI提供商配置替换环境变量。可用选项请参阅[多提供商支持](#多提供商支持)。
|
||||||
|
|
||||||
|
> **离线部署:** 如果 `embed.diagrams.net` 被屏蔽,请参阅 [离线部署指南](./offline-deployment.md) 了解配置选项。
|
||||||
|
|
||||||
### 安装
|
### 安装
|
||||||
|
|
||||||
@@ -153,44 +168,52 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 配置您的AI提供商:
|
||||||
|
|
||||||
|
在根目录创建 `.env.local` 文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
cp env.example .env.local
|
cp env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
编辑 `.env.local` 并配置您选择的提供商:
|
||||||
|
|
||||||
|
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, doubao)
|
||||||
|
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||||
|
- 添加您的提供商所需的API密钥
|
||||||
|
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
|
||||||
|
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
||||||
|
|
||||||
|
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||||
|
|
||||||
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
|
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
|
||||||
|
|
||||||
2. 运行开发服务器:
|
4. 运行开发服务器:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。
|
5. 在浏览器中打开 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
### 部署到腾讯云EdgeOne Pages
|
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。
|
||||||
|
|
||||||
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。
|
|
||||||
|
|
||||||
直接点击此按钮一键部署:
|
|
||||||
[](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)了解更多详情。
|
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
|
||||||
|
|
||||||
### 部署到Cloudflare Workers
|
或者您可以通过此按钮部署:
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
[查看 Cloudflare 部署指南](./cloudflare-deploy.md)
|
请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
|
||||||
|
|
||||||
|
|
||||||
## 多提供商支持
|
## 多提供商支持
|
||||||
@@ -200,27 +223,19 @@ npm run dev
|
|||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
|
|
||||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
||||||
|
|
||||||
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
|
||||||
|
|
||||||
### 服务端多模型配置
|
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
|
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
|
|
||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
|
||||||
|
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
@@ -233,6 +248,24 @@ npm run dev
|
|||||||
|
|
||||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ # Next.js App Router
|
||||||
|
api/chat/ # 带AI工具的聊天API端点
|
||||||
|
page.tsx # 带DrawIO嵌入的主页面
|
||||||
|
components/ # React组件
|
||||||
|
chat-panel.tsx # 带图表控制的聊天界面
|
||||||
|
chat-input.tsx # 带文件上传的用户输入组件
|
||||||
|
history-dialog.tsx # 图表版本历史查看器
|
||||||
|
ui/ # UI组件(按钮、卡片等)
|
||||||
|
contexts/ # React上下文提供者
|
||||||
|
diagram-context.tsx # 全局图表状态管理
|
||||||
|
lib/ # 工具函数和辅助程序
|
||||||
|
ai-providers.ts # 多提供商AI配置
|
||||||
|
utils.ts # XML处理和转换工具
|
||||||
|
public/ # 静态资源包括示例图片
|
||||||
|
```
|
||||||
|
|
||||||
## 支持与联系
|
## 支持与联系
|
||||||
|
|
||||||
@@ -244,10 +277,6 @@ npm run dev
|
|||||||
|
|
||||||
- 邮箱:me[at]jiang.jp
|
- 邮箱:me[at]jiang.jp
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
|
|
||||||
|
|
||||||
## Star历史
|
## Star历史
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
||||||
|
|
||||||
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
[English](../README.md) | [中文](./README_CN.md) | 日本語
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,20 +29,15 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [例](#例)
|
- [例](#例)
|
||||||
- [機能](#機能)
|
- [機能](#機能)
|
||||||
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
|
||||||
- [はじめに](#はじめに)
|
- [はじめに](#はじめに)
|
||||||
- [オンラインで試す](#オンラインで試す)
|
- [オンラインで試す](#オンラインで試す)
|
||||||
- [デスクトップアプリケーション](#デスクトップアプリケーション)
|
- [Dockerで実行(推奨)](#dockerで実行推奨)
|
||||||
- [Dockerで実行](#dockerで実行)
|
|
||||||
- [インストール](#インストール)
|
- [インストール](#インストール)
|
||||||
- [デプロイ](#デプロイ)
|
- [デプロイ](#デプロイ)
|
||||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
|
||||||
- [Vercelへのデプロイ](#vercelへのデプロイ)
|
|
||||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
|
||||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||||
- [仕組み](#仕組み)
|
- [仕組み](#仕組み)
|
||||||
|
- [プロジェクト構造](#プロジェクト構造)
|
||||||
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
- [サポート&お問い合わせ](#サポートお問い合わせ)
|
||||||
- [よくある質問](#よくある質問)
|
|
||||||
- [スター履歴](#スター履歴)
|
- [スター履歴](#スター履歴)
|
||||||
|
|
||||||
## 例
|
## 例
|
||||||
@@ -55,31 +50,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
<img src="../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCPアーキテクチャ図</strong><br />
|
<strong>GCPアーキテクチャ図</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
<img src="../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWSアーキテクチャ図</strong><br />
|
<strong>AWSアーキテクチャ図</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
<img src="../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azureアーキテクチャ図</strong><br />
|
<strong>Azureアーキテクチャ図</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
<img src="../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
<img src="../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -98,7 +93,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
|
|
||||||
## MCPサーバー(プレビュー)
|
## MCPサーバー(プレビュー)
|
||||||
|
|
||||||
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
> **プレビュー機能**:この機能は実験的であり、変更される可能性があります。
|
||||||
|
|
||||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||||
|
|
||||||
@@ -124,7 +119,7 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
|
|
||||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||||
|
|
||||||
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
詳細は[MCPサーバーREADME](../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
|
|
||||||
@@ -132,19 +127,39 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
|
|
||||||
インストール不要!デモサイトで直接お試しください:
|
インストール不要!デモサイトで直接お試しください:
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||||
|
|
||||||
### デスクトップアプリケーション
|
### Dockerで実行(推奨)
|
||||||
|
|
||||||
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください:
|
ローカルで実行したいだけなら、Dockerを使用するのが最も簡単です。
|
||||||
|
|
||||||
対応プラットフォーム:Windows、macOS、Linux。
|
まず、Dockerをインストールしていない場合はインストールしてください:[Dockerを入手](https://docs.docker.com/get-docker/)
|
||||||
|
|
||||||
### Dockerで実行
|
次に実行:
|
||||||
|
|
||||||
[Docker ガイドを参照](./docker.md)
|
```bash
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e AI_PROVIDER=openai \
|
||||||
|
-e AI_MODEL=gpt-4o \
|
||||||
|
-e OPENAI_API_KEY=your_api_key \
|
||||||
|
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
または env ファイルを使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
# .env を編集して設定を入力
|
||||||
|
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
ブラウザで [http://localhost:3000](http://localhost:3000) を開いてください。
|
||||||
|
|
||||||
|
環境変数はお好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては[マルチプロバイダーサポート](#マルチプロバイダーサポート)を参照してください。
|
||||||
|
|
||||||
|
> **オフラインデプロイ:** `embed.diagrams.net` がブロックされている場合は、[オフラインデプロイガイド](./offline-deployment.md) で設定オプションをご確認ください。
|
||||||
|
|
||||||
### インストール
|
### インストール
|
||||||
|
|
||||||
@@ -153,45 +168,52 @@ Claudeにダイアグラムの作成を依頼:
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||||
cd next-ai-draw-io
|
cd next-ai-draw-io
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 依存関係をインストール:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. AIプロバイダーを設定:
|
||||||
|
|
||||||
|
ルートディレクトリに`.env.local`ファイルを作成:
|
||||||
|
|
||||||
|
```bash
|
||||||
cp env.example .env.local
|
cp env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`.env.local`を編集して選択したプロバイダーを設定:
|
||||||
|
|
||||||
|
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, doubao)
|
||||||
|
- `AI_MODEL`を使用する特定のモデルに設定
|
||||||
|
- プロバイダーに必要なAPIキーを追加
|
||||||
|
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
|
||||||
|
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
||||||
|
|
||||||
|
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||||
|
|
||||||
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
||||||
|
|
||||||
2. 開発サーバーを起動:
|
4. 開発サーバーを起動:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。
|
5. ブラウザで[http://localhost:3000](http://localhost:3000)を開いてアプリケーションを確認。
|
||||||
|
|
||||||
## デプロイ
|
## デプロイ
|
||||||
|
|
||||||
### EdgeOne Pagesへのデプロイ
|
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
|
||||||
|
|
||||||
[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。
|
|
||||||
|
|
||||||
このボタンでデプロイ:
|
|
||||||
|
|
||||||
[](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)をご覧ください。
|
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
|
||||||
|
|
||||||
### Cloudflare Workersへのデプロイ
|
または、このボタンでデプロイできます:
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md)
|
ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
||||||
|
|
||||||
|
|
||||||
## マルチプロバイダーサポート
|
## マルチプロバイダーサポート
|
||||||
@@ -201,27 +223,19 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Google Vertex AI
|
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
|
||||||
- SGLang
|
|
||||||
- Vercel AI Gateway
|
|
||||||
|
|
||||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
||||||
|
|
||||||
### サーバーサイドマルチモデル設定
|
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
|
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
|
|
||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
## 仕組み
|
||||||
@@ -234,6 +248,24 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||||
|
|
||||||
|
## プロジェクト構造
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ # Next.js App Router
|
||||||
|
api/chat/ # AIツール付きチャットAPIエンドポイント
|
||||||
|
page.tsx # DrawIO埋め込み付きメインページ
|
||||||
|
components/ # Reactコンポーネント
|
||||||
|
chat-panel.tsx # ダイアグラム制御付きチャットインターフェース
|
||||||
|
chat-input.tsx # ファイルアップロード付きユーザー入力コンポーネント
|
||||||
|
history-dialog.tsx # ダイアグラムバージョン履歴ビューア
|
||||||
|
ui/ # UIコンポーネント(ボタン、カードなど)
|
||||||
|
contexts/ # Reactコンテキストプロバイダー
|
||||||
|
diagram-context.tsx # グローバルダイアグラム状態管理
|
||||||
|
lib/ # ユーティリティ関数とヘルパー
|
||||||
|
ai-providers.ts # マルチプロバイダーAI設定
|
||||||
|
utils.ts # XML処理と変換ユーティリティ
|
||||||
|
public/ # サンプル画像を含む静的アセット
|
||||||
|
```
|
||||||
|
|
||||||
## サポート&お問い合わせ
|
## サポート&お問い合わせ
|
||||||
|
|
||||||
@@ -245,10 +277,6 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
- メール:me[at]jiang.jp
|
- メール:me[at]jiang.jp
|
||||||
|
|
||||||
## よくある質問
|
|
||||||
|
|
||||||
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
|
|
||||||
|
|
||||||
## スター履歴
|
## スター履歴
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
@@ -33,21 +33,6 @@ Optional custom endpoint:
|
|||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
GOOGLE_BASE_URL=https://your-custom-endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Google Vertex AI (Enterprise GCP)
|
|
||||||
|
|
||||||
Google Vertex AI offers enterprise-grade features and data residency. **Express Mode** allows for simple API key authentication, making it compatible with edge runtimes like Vercel and Cloudflare.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_VERTEX_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_VERTEX_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
### OpenAI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -100,18 +85,7 @@ Optional custom endpoint (defaults to the recommended domain):
|
|||||||
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1
|
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
### SGLang
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure OpenAI
|
### Azure OpenAI
|
||||||
|
|
||||||
@@ -173,19 +147,6 @@ Optional custom URL:
|
|||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
### Vercel AI Gateway
|
||||||
|
|
||||||
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
||||||
@@ -229,66 +190,9 @@ If you only configure **one** provider's API key, the system will automatically
|
|||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server-Side Multi-Model Configuration
|
|
||||||
|
|
||||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys.
|
|
||||||
|
|
||||||
### Configuration Methods
|
|
||||||
|
|
||||||
**Option 1: Environment Variable** (recommended for cloud deployments)
|
|
||||||
|
|
||||||
Set `AI_MODELS_CONFIG` as a JSON string:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Config File**
|
|
||||||
|
|
||||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
|
||||||
|
|
||||||
### Example Configuration
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field Reference
|
|
||||||
|
|
||||||
| Field | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `name` | Yes | Display name (supports multiple configs for same provider) |
|
|
||||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
|
||||||
| `models` | Yes | List of model IDs |
|
|
||||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
|
||||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
|
||||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- API keys and credentials are provided via environment variables. By default, standard var names are used (e.g., `OPENAI_API_KEY`), but you can specify custom var names with `apiKeyEnv`.
|
|
||||||
- The `name` field allows multiple configurations for the same provider (e.g., "OpenAI Production" and "OpenAI Staging" both using `provider: "openai"` but with different `apiKeyEnv` values).
|
|
||||||
- If config is not present, the app falls back to `AI_PROVIDER`/`AI_MODEL` environment variable configuration.
|
|
||||||
|
|
||||||
## Model Capability Requirements
|
## Model Capability Requirements
|
||||||
|
|
||||||
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 常见问题解答 (FAQ)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 无法导出 PDF
|
|
||||||
|
|
||||||
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
|
|
||||||
|
|
||||||
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
|
|
||||||
|
|
||||||
**解决方案**: 先导出为图片(PNG),再打印转成 PDF
|
|
||||||
|
|
||||||
**相关 Issue**: #539, #125
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 无法访问 embed.diagrams.net(离线/内网部署)
|
|
||||||
|
|
||||||
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
|
|
||||||
|
|
||||||
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
|
||||||
|
|
||||||
**解决方案**: 必须在构建时通过 `args` 传入:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
|
|
||||||
|
|
||||||
**相关 Issue**: #295, #317
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 自建模型只思考不画图
|
|
||||||
|
|
||||||
**问题**: 本地部署的模型(如 Qwen、LiteLLM)只输出思考过程,不生成图表
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
|
|
||||||
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
|
|
||||||
|
|
||||||
**解决方案**: 开启 tool calling,例如 vLLM:
|
|
||||||
```bash
|
|
||||||
python -m vllm.entrypoints.openai.api_server \
|
|
||||||
--model Qwen/Qwen3-32B \
|
|
||||||
--enable-auto-tool-choice \
|
|
||||||
--tool-call-parser hermes
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关 Issue**: #269, #75
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 上传图片后提示"未提供图片"
|
|
||||||
|
|
||||||
**问题**: 上传图片后,系统显示"未提供图片"错误
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
- 使用支持视觉的模型:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
|
||||||
- 模型名带 `vision` 或 `vl` 的支持图片
|
|
||||||
- 更新到最新版本(v0.4.9+)
|
|
||||||
|
|
||||||
**相关 Issue**: #324, #421, #469
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
# AI 提供商配置
|
|
||||||
|
|
||||||
本指南介绍如何为 next-ai-draw-io 配置不同的 AI 模型提供商。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
1. 将 `.env.example` 复制为 `.env.local`
|
|
||||||
2. 设置所选提供商的 API 密钥
|
|
||||||
3. 将 `AI_MODEL` 设置为所需的模型
|
|
||||||
4. 运行 `npm run dev`
|
|
||||||
|
|
||||||
## 支持的提供商
|
|
||||||
|
|
||||||
### 豆包 (字节跳动火山引擎)
|
|
||||||
|
|
||||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOUBAO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型
|
|
||||||
```
|
|
||||||
|
|
||||||
### Google Gemini
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点(用于 OpenAI 兼容服务):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### DeepSeek
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### SiliconFlow (OpenAI 兼容)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-ai/DeepSeek-V3 # 示例;使用任何 SiliconFlow 模型 ID
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点(默认为推荐域名):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # 或 https://api.siliconflow.cn/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### SGLang
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_RESOURCE_NAME=your-resource-name # 必填:您的 Azure 资源名称
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用自定义端点代替资源名称:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME 的替代方案
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的推理配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_REASONING_EFFORT=low # 可选:low, medium, high
|
|
||||||
AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
|
|
||||||
```
|
|
||||||
|
|
||||||
### AWS Bedrock
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AWS_REGION=us-west-2
|
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
|
||||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
|
||||||
```
|
|
||||||
|
|
||||||
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
|
||||||
|
|
||||||
### OpenRouter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_API_KEY=your_api_key
|
|
||||||
AI_MODEL=anthropic/claude-sonnet-4
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama (本地)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=ollama
|
|
||||||
AI_MODEL=llama3.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义端点:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
可选的自定义 URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
|
||||||
|
|
||||||
Vercel AI Gateway 通过单个 API 密钥提供对多个 AI 提供商的统一访问。这简化了身份验证,让您无需管理多个 API 密钥即可在不同提供商之间切换。
|
|
||||||
|
|
||||||
**基本用法(Vercel 托管网关):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_gateway_api_key
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
**自定义网关 URL(用于本地开发或自托管网关):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_custom_api_key
|
|
||||||
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
模型格式使用 `provider/model` 语法:
|
|
||||||
|
|
||||||
- `openai/gpt-4o` - OpenAI GPT-4o
|
|
||||||
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
|
||||||
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
|
|
||||||
|
|
||||||
**配置说明:**
|
|
||||||
|
|
||||||
- 如果未设置 `AI_GATEWAY_BASE_URL`,则使用默认的 Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`)
|
|
||||||
- 自定义基础 URL 适用于:
|
|
||||||
- 使用自定义网关实例进行本地开发
|
|
||||||
- 自托管 AI Gateway 部署
|
|
||||||
- 企业代理配置
|
|
||||||
- 当使用自定义基础 URL 时,必须同时提供 `AI_GATEWAY_API_KEY`
|
|
||||||
|
|
||||||
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
|
|
||||||
|
|
||||||
## 自动检测
|
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
|
||||||
|
|
||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
|
||||||
```
|
|
||||||
|
|
||||||
## 服务端多模型配置
|
|
||||||
|
|
||||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。
|
|
||||||
|
|
||||||
### 配置方式
|
|
||||||
|
|
||||||
**方式一:环境变量**(推荐用于云部署)
|
|
||||||
|
|
||||||
设置 `AI_MODELS_CONFIG` 为 JSON 字符串:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式二:配置文件**
|
|
||||||
|
|
||||||
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
|
||||||
|
|
||||||
### 配置示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 字段说明
|
|
||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `name` | 是 | 显示名称(支持同一提供商多个配置) |
|
|
||||||
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
|
||||||
| `models` | 是 | 模型 ID 列表 |
|
|
||||||
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
|
||||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
|
||||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
|
||||||
|
|
||||||
### 说明
|
|
||||||
|
|
||||||
- API Key 和凭证通过环境变量提供。默认使用标准变量名(如 `OPENAI_API_KEY`),也可通过 `apiKeyEnv` 指定自定义变量名。
|
|
||||||
- `name` 字段允许同一提供商多个配置(例如 "OpenAI Production" 和 "OpenAI Staging" 都使用 `provider: "openai"` 但 `apiKeyEnv` 不同)。
|
|
||||||
- 如果配置不存在,应用会回退到 `AI_PROVIDER`/`AI_MODEL` 环境变量配置。
|
|
||||||
|
|
||||||
## 模型能力要求
|
|
||||||
|
|
||||||
此任务对模型能力要求极高,因为它涉及生成具有严格格式约束(draw.io XML)的长文本。
|
|
||||||
|
|
||||||
**推荐模型**:
|
|
||||||
|
|
||||||
- Claude Sonnet 4.5 / Opus 4.5
|
|
||||||
|
|
||||||
**关于 Ollama 的说明**:虽然支持将 Ollama 作为提供商,但除非您在本地运行像 DeepSeek R1 或 Qwen3-235B 这样的高性能模型,否则对于此用例通常不太实用。
|
|
||||||
|
|
||||||
## 温度设置 (Temperature)
|
|
||||||
|
|
||||||
您可以通过环境变量选择性地配置温度:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TEMPERATURE=0 # 输出更具确定性(推荐用于图表)
|
|
||||||
```
|
|
||||||
|
|
||||||
**重要提示**:对于不支持温度设置的模型(例如以下模型),请勿设置 `TEMPERATURE`:
|
|
||||||
- GPT-5.1 和其他推理模型
|
|
||||||
- 某些专用模型
|
|
||||||
|
|
||||||
未设置时,模型将使用其默认行为。
|
|
||||||
|
|
||||||
## 推荐
|
|
||||||
|
|
||||||
- **最佳体验**:使用支持视觉的模型(GPT-4o, Claude, Gemini)以获得图像转图表功能
|
|
||||||
- **经济实惠**:DeepSeek 提供具有竞争力的价格
|
|
||||||
- **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持)
|
|
||||||
- **灵活性**:OpenRouter 通过单一 API 提供对众多模型的访问
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# 部署到 Cloudflare Workers
|
|
||||||
|
|
||||||
本项目可以通过 **OpenNext 适配器** 部署为 **Cloudflare Worker**,为您提供:
|
|
||||||
|
|
||||||
- 全球边缘部署
|
|
||||||
- 极低延迟
|
|
||||||
- 免费的 `workers.dev` 域名托管
|
|
||||||
- 通过 R2 实现完整的 Next.js ISR 支持(可选)
|
|
||||||
|
|
||||||
> **Windows 用户重要提示:** OpenNext 和 Wrangler 在 **原生 Windows 环境下并不完全可靠**。建议方案:
|
|
||||||
>
|
|
||||||
> - 使用 **GitHub Codespaces**(完美运行)
|
|
||||||
> - 或者使用 **WSL (Linux)**
|
|
||||||
>
|
|
||||||
> 纯 Windows 构建可能会因为 WASM 文件路径问题而失败。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 前置条件
|
|
||||||
|
|
||||||
1. 一个 **Cloudflare 账户**(免费版即可满足基本部署需求)
|
|
||||||
2. **Node.js 18+**
|
|
||||||
3. 安装 **Wrangler CLI**(作为开发依赖安装即可):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D wrangler
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 登录 Cloudflare:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx wrangler login
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意:** 只有在启用 R2 进行 ISR 缓存时才需要绑定支付方式。基本的 Workers 部署是免费的。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第一步 — 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第二步 — 配置环境变量
|
|
||||||
|
|
||||||
Cloudflare 在本地测试时使用不同的文件。
|
|
||||||
|
|
||||||
### 1) 创建 `.dev.vars`(用于 Cloudflare 本地调试 + 部署)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .dev.vars
|
|
||||||
```
|
|
||||||
|
|
||||||
填入您的 API 密钥和配置信息。
|
|
||||||
|
|
||||||
### 2) 确保 `.env.local` 也存在(用于常规 Next.js 开发)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
在此处填入相同的值。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第三步 — 选择部署类型
|
|
||||||
|
|
||||||
### 选项 A:不使用 R2 部署(简单,免费)
|
|
||||||
|
|
||||||
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
|
|
||||||
|
|
||||||
**1. 使用简单的 `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. 使用简单的 `wrangler.jsonc`(不包含 r2_buckets):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
直接跳至 **第四步**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 选项 B:使用 R2 部署(完整的 ISR 支持)
|
|
||||||
|
|
||||||
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
|
|
||||||
|
|
||||||
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
|
||||||
|
|
||||||
- 进入 **Storage & Databases → R2**
|
|
||||||
- 点击 **Create bucket**
|
|
||||||
- 命名为:`next-inc-cache`
|
|
||||||
|
|
||||||
**2. 配置 `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
|
||||||
incrementalCache: r2IncrementalCache,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. 配置 `wrangler.jsonc`(包含 R2):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"r2_buckets": [
|
|
||||||
{
|
|
||||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
|
||||||
"bucket_name": "next-inc-cache"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
|
||||||
|
|
||||||
在首次部署之前,您需要一个 workers.dev 子域名。
|
|
||||||
|
|
||||||
**选项 1:通过 Cloudflare 控制台(推荐)**
|
|
||||||
|
|
||||||
访问:https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
|
|
||||||
|
|
||||||
**选项 2:在部署过程中**
|
|
||||||
|
|
||||||
运行 `npm run deploy` 时,Wrangler 可能会提示:
|
|
||||||
|
|
||||||
```
|
|
||||||
Would you like to register a workers.dev subdomain? (Y/n)
|
|
||||||
```
|
|
||||||
|
|
||||||
输入 `Y` 并选择一个子域名。
|
|
||||||
|
|
||||||
> **注意:** 在 CI/CD 或非交互式环境中,该提示不会出现。请先通过控制台进行注册。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第五步 — 部署到 Cloudflare
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
该脚本执行的操作:
|
|
||||||
|
|
||||||
- 构建 Next.js 应用
|
|
||||||
- 通过 OpenNext 将其转换为 Cloudflare Worker
|
|
||||||
- 上传静态资源
|
|
||||||
- 发布 Worker
|
|
||||||
|
|
||||||
您的应用将可通过以下地址访问:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://<worker-name>.<your-subdomain>.workers.dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题与修复
|
|
||||||
|
|
||||||
### `You need to register a workers.dev subdomain`
|
|
||||||
|
|
||||||
**原因:** 您的账户尚未注册 workers.dev 子域名。
|
|
||||||
|
|
||||||
**修复:** 前往 https://dash.cloudflare.com → Workers & Pages → Set up a subdomain。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Please enable R2 through the Cloudflare Dashboard`
|
|
||||||
|
|
||||||
**原因:** wrangler.jsonc 中配置了 R2,但您的账户尚未启用该功能。
|
|
||||||
|
|
||||||
**修复:** 启用 R2(需要支付方式)或使用选项 A(不使用 R2 部署)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `No R2 binding "NEXT_INC_CACHE_R2_BUCKET" found`
|
|
||||||
|
|
||||||
**原因:** `wrangler.jsonc` 中缺少 `r2_buckets` 配置。
|
|
||||||
|
|
||||||
**修复:** 添加 `r2_buckets` 部分或切换到选项 A(不使用 R2)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Can't set compatibility date in the future`
|
|
||||||
|
|
||||||
**原因:** wrangler 配置中的 `compatibility_date` 设置为了未来的日期。
|
|
||||||
|
|
||||||
**修复:** 将 `compatibility_date` 修改为今天或更早的日期。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Windows 错误:`resvg.wasm?module` (ENOENT)
|
|
||||||
|
|
||||||
**原因:** Windows 文件名不能包含 `?`,但某个 wasm 资源文件名中使用了 `?module`。
|
|
||||||
|
|
||||||
**修复:** 在 Linux 环境(WSL、Codespaces 或 CI)上进行构建/部署。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 可选:本地预览
|
|
||||||
|
|
||||||
部署前在本地预览 Worker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
|
|
||||||
| 功能 | 不使用 R2 | 使用 R2 |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| 成本 | 免费 | 需要绑定支付方式 |
|
|
||||||
| ISR 缓存 | 无 | 有 |
|
|
||||||
| 静态页面 | 支持 | 支持 |
|
|
||||||
| API 路由 | 支持 | 支持 |
|
|
||||||
| 配置复杂度 | 简单 | 中等 |
|
|
||||||
|
|
||||||
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 使用 Docker 运行
|
|
||||||
|
|
||||||
如果您只是想在本地运行,最好的方式是使用 Docker。
|
|
||||||
|
|
||||||
首先,如果您尚未安装 Docker,请先安装:[获取 Docker](https://docs.docker.com/get-docker/)
|
|
||||||
|
|
||||||
然后运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e AI_PROVIDER=openai \
|
|
||||||
-e AI_MODEL=gpt-4o \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
或者使用环境变量文件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env
|
|
||||||
# 编辑 .env 文件并填入您的配置
|
|
||||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
在浏览器中打开 [http://localhost:3000](http://localhost:3000)。
|
|
||||||
|
|
||||||
请将环境变量替换为您首选的 AI 提供商配置。查看 [AI 提供商](./ai-providers.md) 了解可用选项。
|
|
||||||
|
|
||||||
> **离线部署:** 如果无法访问 `embed.diagrams.net`,请参阅 [离线部署](./offline-deployment.md) 了解配置选项。
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 离线部署
|
|
||||||
|
|
||||||
通过自托管 draw.io 来替代 `embed.diagrams.net`,从而离线部署 Next AI Draw.io。
|
|
||||||
|
|
||||||
**注意:** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。
|
|
||||||
|
|
||||||
## Docker Compose 设置
|
|
||||||
|
|
||||||
1. 克隆仓库并在 `.env` 文件中定义 API 密钥。
|
|
||||||
2. 创建 `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
depends_on: [drawio]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`。
|
|
||||||
|
|
||||||
## 配置与重要警告
|
|
||||||
|
|
||||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**
|
|
||||||
|
|
||||||
| 场景 | URL 值 |
|
|
||||||
|----------|-----------|
|
|
||||||
| 本地主机 (Localhost) | `http://localhost:8080` |
|
|
||||||
| 远程/服务器 | `http://YOUR_SERVER_IP:8080` |
|
|
||||||
|
|
||||||
**切勿使用** Docker 内部别名(如 `http://drawio:8080`),因为浏览器无法解析它们。
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# Frequently Asked Questions (FAQ)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Cannot Export PDF
|
|
||||||
|
|
||||||
**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens
|
|
||||||
|
|
||||||
**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe
|
|
||||||
|
|
||||||
**Solution**: Export as image (PNG) first, then print to PDF
|
|
||||||
|
|
||||||
**Related Issues**: #539, #125
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)
|
|
||||||
|
|
||||||
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
|
|
||||||
|
|
||||||
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
|
|
||||||
|
|
||||||
**Solution**: Must pass via `args` at build time:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://your-server-ip:8080/
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet
|
|
||||||
|
|
||||||
**Related Issues**: #295, #317
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Self-hosted Model Only Thinks But Doesn't Draw
|
|
||||||
|
|
||||||
**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models
|
|
||||||
2. **Tool calling not enabled** - Model service needs tool use configuration
|
|
||||||
|
|
||||||
**Solution**: Enable tool calling, e.g., vLLM:
|
|
||||||
```bash
|
|
||||||
python -m vllm.entrypoints.openai.api_server \
|
|
||||||
--model Qwen/Qwen3-32B \
|
|
||||||
--enable-auto-tool-choice \
|
|
||||||
--tool-call-parser hermes
|
|
||||||
```
|
|
||||||
|
|
||||||
**Related Issues**: #269, #75
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. "No Image Provided" After Uploading Image
|
|
||||||
|
|
||||||
**Problem**: After uploading an image, the system shows "No image provided" error
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro
|
|
||||||
- Models with `vision` or `vl` in name support images
|
|
||||||
- Update to latest version (v0.4.9+)
|
|
||||||
|
|
||||||
**Related Issues**: #324, #421, #469
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Run with Docker
|
|
||||||
|
|
||||||
If you just want to run it locally, the best way is to use Docker.
|
|
||||||
|
|
||||||
First, install Docker if you haven't already: [Get Docker](https://docs.docker.com/get-docker/)
|
|
||||||
|
|
||||||
Then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e AI_PROVIDER=openai \
|
|
||||||
-e AI_MODEL=gpt-4o \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use an env file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env
|
|
||||||
# Edit .env with your configuration
|
|
||||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using server-side model configuration
|
|
||||||
|
|
||||||
You can mount an `ai-models.json` file into the container to provide multiple server-side models without exposing user API keys:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
-v $(pwd)/ai-models.json:/app/ai-models.json:ro \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
If you prefer to keep the config in a different path inside the container, set `AI_MODELS_CONFIG_PATH`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
-e AI_MODELS_CONFIG_PATH=/config/ai-models.json \
|
|
||||||
-v $(pwd)/ai-models.json:/config/ai-models.json:ro \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
|
||||||
|
|
||||||
Replace the environment variables with your preferred AI provider configuration. See [AI Providers](./ai-providers.md) for available options.
|
|
||||||
|
|
||||||
> **Offline Deployment:** If `embed.diagrams.net` is blocked, see [Offline Deployment](./offline-deployment.md) for configuration options.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# よくある質問 (FAQ)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. PDFをエクスポートできない
|
|
||||||
|
|
||||||
**問題**: Web版でPDFエクスポートをクリックすると `convert.diagrams.net/node/export` にリダイレクトされ、その後何も起こらない
|
|
||||||
|
|
||||||
**原因**: 埋め込みDraw.ioは直接PDFエクスポートをサポートしておらず、外部変換サービスに依存しているが、iframe内では正常に動作しない
|
|
||||||
|
|
||||||
**解決策**: まず画像(PNG)としてエクスポートし、その後PDFに印刷する
|
|
||||||
|
|
||||||
**関連Issue**: #539, #125
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. embed.diagrams.netにアクセスできない(オフライン/イントラネットデプロイ)
|
|
||||||
|
|
||||||
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
|
|
||||||
|
|
||||||
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
|
||||||
|
|
||||||
**解決策**: ビルド時に `args` で渡す必要があります:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://あなたのサーバーIP:8080/
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**イントラネットユーザー**: 外部ネットワークでDockerfileを修正してイメージをビルドし、イントラネットに転送する
|
|
||||||
|
|
||||||
**関連Issue**: #295, #317
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 自前モデルが思考するだけで描画しない
|
|
||||||
|
|
||||||
**問題**: ローカルデプロイのモデル(Qwen、LiteLLMなど)が思考過程のみを出力し、図表を生成しない
|
|
||||||
|
|
||||||
**考えられる原因**:
|
|
||||||
1. **モデルが小さすぎる** - 小さいモデルはtool calling指示に正しく従うことが難しい、32B+パラメータのモデルを推奨
|
|
||||||
2. **tool callingが有効になっていない** - モデルサービスでtool use機能を設定する必要がある
|
|
||||||
|
|
||||||
**解決策**: tool callingを有効にする、例えばvLLM:
|
|
||||||
```bash
|
|
||||||
python -m vllm.entrypoints.openai.api_server \
|
|
||||||
--model Qwen/Qwen3-32B \
|
|
||||||
--enable-auto-tool-choice \
|
|
||||||
--tool-call-parser hermes
|
|
||||||
```
|
|
||||||
|
|
||||||
**関連Issue**: #269, #75
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 画像アップロード後「画像が提供されていません」と表示される
|
|
||||||
|
|
||||||
**問題**: 画像をアップロードした後、「画像が提供されていません」というエラーが表示される
|
|
||||||
|
|
||||||
**考えられる原因**:
|
|
||||||
1. モデルがビジョン機能をサポートしていない(Kimi K2、DeepSeek、Qwenテキストモデルなど)
|
|
||||||
|
|
||||||
**解決策**:
|
|
||||||
- ビジョン対応モデルを使用:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
|
||||||
- モデル名に `vision` または `vl` が含まれているものは画像をサポート
|
|
||||||
- 最新バージョン(v0.4.9+)にアップデート
|
|
||||||
|
|
||||||
**関連Issue**: #324, #421, #469
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
# AIプロバイダーの設定
|
|
||||||
|
|
||||||
このガイドでは、next-ai-draw-io でさまざまな AI モデルプロバイダーを設定する方法について説明します。
|
|
||||||
|
|
||||||
## クイックスタート
|
|
||||||
|
|
||||||
1. `.env.example` を `.env.local` にコピーします
|
|
||||||
2. 選択したプロバイダーの API キーを設定します
|
|
||||||
3. `AI_MODEL` を希望のモデルに設定します
|
|
||||||
4. `npm run dev` を実行します
|
|
||||||
|
|
||||||
## 対応プロバイダー
|
|
||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
|
||||||
|
|
||||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOUBAO_API_KEY=your_api_key
|
|
||||||
AI_MODEL=doubao-seed-1-8-251215 # または他の Doubao モデル
|
|
||||||
```
|
|
||||||
|
|
||||||
### Google Gemini
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント(OpenAI 互換サービス用):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### DeepSeek
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### SiliconFlow (OpenAI 互換)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-ai/DeepSeek-V3 # 例; 任意の SiliconFlow モデル ID を使用
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント(デフォルトは推奨ドメイン):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # または https://api.siliconflow.cn/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### SGLang
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your_model_id
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SGLANG_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_RESOURCE_NAME=your-resource-name # 必須: Azure リソース名
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
またはリソース名の代わりにカスタムエンドポイントを使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME の代替
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
任意の推論設定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_REASONING_EFFORT=low # 任意: low, medium, high
|
|
||||||
AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
|
|
||||||
```
|
|
||||||
|
|
||||||
### AWS Bedrock
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AWS_REGION=us-west-2
|
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
|
||||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
|
||||||
```
|
|
||||||
|
|
||||||
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
|
||||||
|
|
||||||
### OpenRouter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_API_KEY=your_api_key
|
|
||||||
AI_MODEL=anthropic/claude-sonnet-4
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama (ローカル)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=ollama
|
|
||||||
AI_MODEL=llama3.2
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタム URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
### ModelScope
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
|
||||||
```
|
|
||||||
|
|
||||||
任意のカスタムエンドポイント:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vercel AI Gateway
|
|
||||||
|
|
||||||
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
|
||||||
|
|
||||||
**基本的な使用法 (Vercel ホストの Gateway):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_gateway_api_key
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
**カスタム Gateway URL (ローカル開発またはセルフホスト Gateway 用):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_GATEWAY_API_KEY=your_custom_api_key
|
|
||||||
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
|
|
||||||
AI_MODEL=openai/gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
モデル形式は `provider/model` 構文を使用します:
|
|
||||||
|
|
||||||
- `openai/gpt-4o` - OpenAI GPT-4o
|
|
||||||
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
|
||||||
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
|
|
||||||
|
|
||||||
**設定に関する注意点:**
|
|
||||||
|
|
||||||
- `AI_GATEWAY_BASE_URL` が設定されていない場合、デフォルトの Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) が使用されます
|
|
||||||
- カスタムベース URL は以下の場合に便利です:
|
|
||||||
- カスタム Gateway インスタンスを使用したローカル開発
|
|
||||||
- セルフホスト AI Gateway デプロイメント
|
|
||||||
- エンタープライズプロキシ設定
|
|
||||||
- カスタムベース URL を使用する場合、`AI_GATEWAY_API_KEY` も指定する必要があります
|
|
||||||
|
|
||||||
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
|
|
||||||
|
|
||||||
## 自動検出
|
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
|
||||||
|
|
||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
|
||||||
```
|
|
||||||
|
|
||||||
## サーバーサイドマルチモデル設定
|
|
||||||
|
|
||||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。
|
|
||||||
|
|
||||||
### 設定方法
|
|
||||||
|
|
||||||
**方法1:環境変数**(クラウドデプロイ推奨)
|
|
||||||
|
|
||||||
`AI_MODELS_CONFIG` をJSON文字列として設定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["gpt-4o"],"default":true}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**方法2:設定ファイル**
|
|
||||||
|
|
||||||
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
|
||||||
|
|
||||||
### 設定例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"name": "OpenAI Production",
|
|
||||||
"provider": "openai",
|
|
||||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Custom DeepSeek",
|
|
||||||
"provider": "deepseek",
|
|
||||||
"models": ["deepseek-chat"],
|
|
||||||
"apiKeyEnv": "MY_DEEPSEEK_KEY",
|
|
||||||
"baseUrlEnv": "MY_DEEPSEEK_URL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### フィールド説明
|
|
||||||
|
|
||||||
| フィールド | 必須 | 説明 |
|
|
||||||
|------------|------|------|
|
|
||||||
| `name` | はい | 表示名(同一プロバイダーの複数設定をサポート) |
|
|
||||||
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
|
||||||
| `models` | はい | モデルIDのリスト |
|
|
||||||
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
|
||||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
|
||||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
|
||||||
|
|
||||||
### 備考
|
|
||||||
|
|
||||||
- APIキーと認証情報は環境変数で提供します。デフォルトは標準変数名(例:`OPENAI_API_KEY`)を使用しますが、`apiKeyEnv` でカスタム変数名を指定できます。
|
|
||||||
- `name` フィールドにより同一プロバイダーの複数設定が可能です(例:「OpenAI Production」と「OpenAI Staging」が両方とも `provider: "openai"` を使用しつつ、異なる `apiKeyEnv` を持つ)。
|
|
||||||
- 設定が存在しない場合、アプリは `AI_PROVIDER`/`AI_MODEL` 環境変数設定にフォールバックします。
|
|
||||||
|
|
||||||
## モデル性能要件
|
|
||||||
|
|
||||||
このタスクは、厳密なフォーマット制約(draw.io XML)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
|
|
||||||
|
|
||||||
**推奨モデル**:
|
|
||||||
|
|
||||||
- Claude Sonnet 4.5 / Opus 4.5
|
|
||||||
|
|
||||||
**Ollama に関する注意**: Ollama はプロバイダーとしてサポートされていますが、DeepSeek R1 や Qwen3-235B のような高性能モデルをローカルで実行していない限り、このユースケースでは一般的に実用的ではありません。
|
|
||||||
|
|
||||||
## Temperature(温度)設定
|
|
||||||
|
|
||||||
環境変数で Temperature を任意に設定できます:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TEMPERATURE=0 # より決定論的な出力(ダイアグラムに推奨)
|
|
||||||
```
|
|
||||||
|
|
||||||
**重要**: 以下の Temperature 設定をサポートしていないモデルでは、`TEMPERATURE` を未設定のままにしてください:
|
|
||||||
- GPT-5.1 およびその他の推論モデル
|
|
||||||
- 一部の特殊なモデル
|
|
||||||
|
|
||||||
未設定の場合、モデルはデフォルトの挙動を使用します。
|
|
||||||
|
|
||||||
## 推奨事項
|
|
||||||
|
|
||||||
- **最高の体験**: 画像からダイアグラムを生成する機能には、ビジョン(画像認識)をサポートするモデル(GPT-4o, Claude, Gemini)を使用してください
|
|
||||||
- **低コスト**: DeepSeek は競争力のある価格を提供しています
|
|
||||||
- **プライバシー**: 完全にローカルなオフライン操作には Ollama を使用してください(強力なハードウェアが必要です)
|
|
||||||
- **柔軟性**: OpenRouter は単一の API で多数のモデルへのアクセスを提供します
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# Cloudflare Workers へのデプロイ
|
|
||||||
|
|
||||||
このプロジェクトは **OpenNext アダプター** を使用して **Cloudflare Worker** としてデプロイすることができ、以下のメリットがあります:
|
|
||||||
|
|
||||||
- グローバルエッジへのデプロイ
|
|
||||||
- 超低レイテンシー
|
|
||||||
- 無料の `workers.dev` ホスティング
|
|
||||||
- R2 を介した完全な Next.js ISR サポート(オプション)
|
|
||||||
|
|
||||||
> **Windows ユーザー向けの重要な注意:** OpenNext と Wrangler は、**ネイティブ Windows 環境では完全には信頼できません**。以下の方法を推奨します:
|
|
||||||
>
|
|
||||||
> - **GitHub Codespaces** を使用する(完全に動作します)
|
|
||||||
> - または **WSL (Linux)** を使用する
|
|
||||||
>
|
|
||||||
> 純粋な Windows 環境でのビルドは、WASM ファイルパスの問題により失敗する可能性があります。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 前提条件
|
|
||||||
|
|
||||||
1. **Cloudflare アカウント**(基本的なデプロイには無料プランで十分です)
|
|
||||||
2. **Node.js 18以上**
|
|
||||||
3. **Wrangler CLI** のインストール(開発依存関係で問題ありません):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D wrangler
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Cloudflare へのログイン:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx wrangler login
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意:** 支払い方法の登録が必要なのは、ISR キャッシュのために R2 を有効にする場合のみです。基本的な Workers へのデプロイは無料です。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 1 — 依存関係のインストール
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 2 — 環境変数の設定
|
|
||||||
|
|
||||||
Cloudflare はローカルテスト用に別のファイルを使用します。
|
|
||||||
|
|
||||||
### 1) `.dev.vars` の作成(Cloudflare ローカルおよびデプロイ用)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .dev.vars
|
|
||||||
```
|
|
||||||
|
|
||||||
API キーと設定を入力してください。
|
|
||||||
|
|
||||||
### 2) `.env.local` も存在することを確認(通常の Next.js 開発用)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
同じ値を入力してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 3 — デプロイタイプの選択
|
|
||||||
|
|
||||||
### オプション A: R2 なしでのデプロイ(シンプル、無料)
|
|
||||||
|
|
||||||
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
|
|
||||||
|
|
||||||
**1. シンプルな `open-next.config.ts` を使用:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. シンプルな `wrangler.jsonc` を使用(r2_buckets なし):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ステップ 4** へ進んでください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
|
|
||||||
|
|
||||||
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
|
|
||||||
|
|
||||||
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
|
||||||
|
|
||||||
- **Storage & Databases → R2** へ移動
|
|
||||||
- **Create bucket** をクリック
|
|
||||||
- 名前を入力: `next-inc-cache`
|
|
||||||
|
|
||||||
**2. `open-next.config.ts` の設定:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
|
||||||
incrementalCache: r2IncrementalCache,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. `wrangler.jsonc` の設定(R2 あり):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"r2_buckets": [
|
|
||||||
{
|
|
||||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
|
||||||
"bucket_name": "next-inc-cache"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
|
||||||
|
|
||||||
初回デプロイの前に、workers.dev サブドメインが必要です。
|
|
||||||
|
|
||||||
**オプション 1: Cloudflare ダッシュボード経由(推奨)**
|
|
||||||
|
|
||||||
アクセス先: https://dash.cloudflare.com → Workers & Pages → Overview → Set up a subdomain
|
|
||||||
|
|
||||||
**オプション 2: デプロイ時**
|
|
||||||
|
|
||||||
`npm run deploy` を実行した際、Wrangler が以下のように尋ねてくる場合があります:
|
|
||||||
|
|
||||||
```
|
|
||||||
Would you like to register a workers.dev subdomain? (Y/n)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Y` を入力し、サブドメイン名を選択してください。
|
|
||||||
|
|
||||||
> **注意:** CI/CD や非対話型環境では、このプロンプトは表示されません。事前にダッシュボードで登録してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ステップ 5 — Cloudflare へのデプロイ
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
スクリプトの処理内容:
|
|
||||||
|
|
||||||
- Next.js アプリのビルド
|
|
||||||
- OpenNext を介した Cloudflare Worker への変換
|
|
||||||
- 静的アセットのアップロード
|
|
||||||
- Worker の公開
|
|
||||||
|
|
||||||
アプリは以下の URL で利用可能になります:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://<worker-name>.<your-subdomain>.workers.dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## よくある問題と解決策
|
|
||||||
|
|
||||||
### `You need to register a workers.dev subdomain`
|
|
||||||
|
|
||||||
**原因:** アカウントに workers.dev サブドメインが登録されていません。
|
|
||||||
|
|
||||||
**解決策:** https://dash.cloudflare.com → Workers & Pages → Set up a subdomain から登録してください。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `Please enable R2 through the Cloudflare Dashboard`
|
|
||||||
|
|
||||||
**原因:** `wrangler.jsonc` で R2 が設定されていますが、アカウントで R2 が有効になっていません。
|
|
||||||
|
|
||||||
**解決策:** R2 を有効にする(支払い方法が必要)か、オプション 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 あり** を選んでください。
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Dockerで実行する
|
|
||||||
|
|
||||||
ローカルで実行したいだけであれば、Dockerを使用するのが最も良い方法です。
|
|
||||||
|
|
||||||
まず、Dockerがまだインストールされていない場合はインストールしてください: [Dockerを入手](https://docs.docker.com/get-docker/)
|
|
||||||
|
|
||||||
次に、以下を実行します。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-e AI_PROVIDER=openai \
|
|
||||||
-e AI_MODEL=gpt-4o \
|
|
||||||
-e OPENAI_API_KEY=your_api_key \
|
|
||||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
または、envファイルを使用します。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env
|
|
||||||
# .envを構成に合わせて編集します
|
|
||||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
ブラウザで[http://localhost:3000](http://localhost:3000)を開きます。
|
|
||||||
|
|
||||||
環境変数は、お好みのAIプロバイダー設定に置き換えてください。利用可能なオプションについては、[AIプロバイダー](./ai-providers.md)を参照してください。
|
|
||||||
|
|
||||||
> **オフラインデプロイ:** `embed.diagrams.net`がブロックされている場合は、構成オプションについて[オフラインデプロイ](./offline-deployment.md)を参照してください。
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# オフラインデプロイ
|
|
||||||
|
|
||||||
`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。
|
|
||||||
|
|
||||||
**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。
|
|
||||||
|
|
||||||
## Docker Compose のセットアップ
|
|
||||||
|
|
||||||
1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。
|
|
||||||
2. `docker-compose.yml` を作成します。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
drawio:
|
|
||||||
image: jgraph/drawio:latest
|
|
||||||
ports: ["8080:8080"]
|
|
||||||
next-ai-draw-io:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
|
||||||
ports: ["3000:3000"]
|
|
||||||
env_file: .env
|
|
||||||
depends_on: [drawio]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。
|
|
||||||
|
|
||||||
## 設定と重要な警告
|
|
||||||
|
|
||||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**
|
|
||||||
|
|
||||||
| シナリオ | URL の値 |
|
|
||||||
|----------|-----------|
|
|
||||||
| ローカルホスト | `http://localhost:8080` |
|
|
||||||
| リモート/サーバー | `http://YOUR_SERVER_IP:8080` |
|
|
||||||
|
|
||||||
**`http://drawio:8080` のような Docker 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
/**
|
|
||||||
* EdgeOne Pages Edge Function for OpenAI-compatible Chat Completions API
|
|
||||||
*
|
|
||||||
* This endpoint provides an OpenAI-compatible API that can be used with
|
|
||||||
* AI SDK's createOpenAI({ baseURL: '/api/edgeai' })
|
|
||||||
*
|
|
||||||
* Uses EdgeOne Edge AI's AI.chatCompletions() which now supports native tool calling.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
// EdgeOne Pages global AI object
|
|
||||||
declare const AI: {
|
|
||||||
chatCompletions(options: {
|
|
||||||
model: string
|
|
||||||
messages: Array<{ role: string; content: string | null }>
|
|
||||||
stream?: boolean
|
|
||||||
max_tokens?: number
|
|
||||||
temperature?: number
|
|
||||||
tools?: any
|
|
||||||
tool_choice?: any
|
|
||||||
}): Promise<ReadableStream<Uint8Array> | any>
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageItemSchema = z
|
|
||||||
.object({
|
|
||||||
role: z.enum(["user", "assistant", "system", "tool", "function"]),
|
|
||||||
content: z.string().nullable().optional(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
|
|
||||||
const messageSchema = z
|
|
||||||
.object({
|
|
||||||
messages: z.array(messageItemSchema),
|
|
||||||
model: z.string().optional(),
|
|
||||||
stream: z.boolean().optional(),
|
|
||||||
tools: z.any().optional(),
|
|
||||||
tool_choice: z.any().optional(),
|
|
||||||
functions: z.any().optional(),
|
|
||||||
function_call: z.any().optional(),
|
|
||||||
temperature: z.number().optional(),
|
|
||||||
top_p: z.number().optional(),
|
|
||||||
max_tokens: z.number().optional(),
|
|
||||||
presence_penalty: z.number().optional(),
|
|
||||||
frequency_penalty: z.number().optional(),
|
|
||||||
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
|
||||||
response_format: z.any().optional(),
|
|
||||||
seed: z.number().optional(),
|
|
||||||
user: z.string().optional(),
|
|
||||||
n: z.number().int().optional(),
|
|
||||||
logit_bias: z.record(z.string(), z.number()).optional(),
|
|
||||||
parallel_tool_calls: z.boolean().optional(),
|
|
||||||
stream_options: z.any().optional(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
|
|
||||||
// Model configuration
|
|
||||||
const ALLOWED_MODELS = [
|
|
||||||
"@tx/deepseek-ai/deepseek-v32",
|
|
||||||
"@tx/deepseek-ai/deepseek-r1-0528",
|
|
||||||
"@tx/deepseek-ai/deepseek-v3-0324",
|
|
||||||
]
|
|
||||||
|
|
||||||
const MODEL_ALIASES: Record<string, string> = {
|
|
||||||
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
|
|
||||||
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
|
|
||||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
|
||||||
}
|
|
||||||
|
|
||||||
const CORS_HEADERS = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create standardized response with CORS headers
|
|
||||||
*/
|
|
||||||
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...CORS_HEADERS,
|
|
||||||
...extraHeaders,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle OPTIONS request for CORS preflight
|
|
||||||
*/
|
|
||||||
function handleOptionsRequest(): Response {
|
|
||||||
return new Response(null, {
|
|
||||||
headers: {
|
|
||||||
...CORS_HEADERS,
|
|
||||||
"Access-Control-Max-Age": "86400",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function onRequest({ request, env: _env }: any) {
|
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return handleOptionsRequest()
|
|
||||||
}
|
|
||||||
|
|
||||||
request.headers.delete("accept-encoding")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const json = await request.clone().json()
|
|
||||||
const parseResult = messageSchema.safeParse(json)
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message: parseResult.error.message,
|
|
||||||
type: "invalid_request_error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
|
||||||
parseResult.data
|
|
||||||
|
|
||||||
// Validate messages
|
|
||||||
const userMessages = messages.filter(
|
|
||||||
(message) => message.role === "user",
|
|
||||||
)
|
|
||||||
if (!userMessages.length) {
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message: "No user message found",
|
|
||||||
type: "invalid_request_error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve model
|
|
||||||
const requestedModel = model || ALLOWED_MODELS[0]
|
|
||||||
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
|
||||||
|
|
||||||
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message: `Invalid model: ${requestedModel}.`,
|
|
||||||
type: "invalid_request_error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
429,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isStream = !!stream
|
|
||||||
|
|
||||||
// Non-streaming: return mock response for validation
|
|
||||||
// AI.chatCompletions doesn't support non-streaming mode
|
|
||||||
if (!isStream) {
|
|
||||||
const mockResponse = {
|
|
||||||
id: `chatcmpl-${Date.now()}`,
|
|
||||||
object: "chat.completion",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: selectedModel,
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
message: {
|
|
||||||
role: "assistant",
|
|
||||||
content: "OK",
|
|
||||||
},
|
|
||||||
finish_reason: "stop",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
usage: {
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 1,
|
|
||||||
total_tokens: 11,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return createResponse(mockResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build AI.chatCompletions options for streaming
|
|
||||||
const aiOptions: any = {
|
|
||||||
...extraParams,
|
|
||||||
model: selectedModel,
|
|
||||||
messages,
|
|
||||||
stream: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tools if provided
|
|
||||||
if (tools && tools.length > 0) {
|
|
||||||
aiOptions.tools = tools
|
|
||||||
}
|
|
||||||
if (tool_choice !== undefined) {
|
|
||||||
aiOptions.tool_choice = tool_choice
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiResponse = await AI.chatCompletions(aiOptions)
|
|
||||||
|
|
||||||
// Streaming response
|
|
||||||
return new Response(aiResponse, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream; charset=utf-8",
|
|
||||||
"Cache-Control": "no-cache, no-store, no-transform",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
Connection: "keep-alive",
|
|
||||||
...CORS_HEADERS,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
|
||||||
// Handle EdgeOne specific errors
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(error.message)
|
|
||||||
if (message.code === 14020) {
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message:
|
|
||||||
"The daily public quota has been exhausted. After deployment, you can enjoy a personal daily exclusive quota.",
|
|
||||||
type: "rate_limit_error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
429,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return createResponse(
|
|
||||||
{ error: { message: error.message, type: "api_error" } },
|
|
||||||
500,
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// Not a JSON error message
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("[EdgeOne] AI error:", error.message)
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message: error.message || "AI service error",
|
|
||||||
type: "api_error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("[EdgeOne] Request error:", error.message)
|
|
||||||
return createResponse(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
message: "Request processing failed",
|
|
||||||
type: "server_error",
|
|
||||||
details: error.message,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"nodeFunctionsConfig": {
|
|
||||||
"maxDuration": 120
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,11 +37,10 @@ mac:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
|
hardenedRuntime: true
|
||||||
# to properly sign nested bundles with --deep flag for bundled draw.io files
|
|
||||||
identity: null
|
|
||||||
hardenedRuntime: false
|
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
|
entitlements: resources/entitlements.mac.plist
|
||||||
|
entitlementsInherit: resources/entitlements.mac.plist
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
contents:
|
contents:
|
||||||
|
|||||||
35
electron/electron.d.ts
vendored
35
electron/electron.d.ts
vendored
@@ -25,25 +25,6 @@ interface ApplyPresetResult {
|
|||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Proxy configuration interface */
|
|
||||||
interface ProxyConfig {
|
|
||||||
httpProxy?: string
|
|
||||||
httpsProxy?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of setting proxy */
|
|
||||||
interface SetProxyResult {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
devMode?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of setting user locale */
|
|
||||||
interface SetUserLocaleResult {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** Main window Electron API */
|
/** Main window Electron API */
|
||||||
@@ -64,14 +45,6 @@ declare global {
|
|||||||
openFile: () => Promise<string | null>
|
openFile: () => Promise<string | null>
|
||||||
/** Save data to file via save dialog */
|
/** Save data to file via save dialog */
|
||||||
saveFile: (data: string) => Promise<boolean>
|
saveFile: (data: string) => Promise<boolean>
|
||||||
/** Get proxy configuration */
|
|
||||||
getProxy: () => Promise<ProxyConfig>
|
|
||||||
/** Set proxy configuration (saves and restarts server) */
|
|
||||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
|
||||||
/** Get user's preferred locale */
|
|
||||||
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
|
|
||||||
/** Set user's preferred locale */
|
|
||||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
/** Settings window Electron API */
|
||||||
@@ -98,10 +71,4 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export { ConfigPreset, ApplyPresetResult }
|
||||||
ConfigPreset,
|
|
||||||
ApplyPresetResult,
|
|
||||||
ProxyConfig,
|
|
||||||
SetProxyResult,
|
|
||||||
SetUserLocaleResult,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import {
|
|||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import { showSettingsWindow } from "./settings-window"
|
import { showSettingsWindow } from "./settings-window"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and set the application menu with i18n support
|
* Build and set the application menu
|
||||||
*/
|
*/
|
||||||
export function buildAppMenu(): void {
|
export function buildAppMenu(): void {
|
||||||
const template = getMenuTemplate()
|
const template = getMenuTemplate()
|
||||||
@@ -26,22 +25,18 @@ export function buildAppMenu(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild the menu (call this when presets change or language changes)
|
* Rebuild the menu (call this when presets change)
|
||||||
*/
|
*/
|
||||||
export function rebuildAppMenu(): void {
|
export function rebuildAppMenu(): void {
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template with translations
|
* Get the menu template
|
||||||
*/
|
*/
|
||||||
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
// Get translations for preferred locale (saved preference or system default)
|
|
||||||
const locale = getPreferredLocale(app.getLocale())
|
|
||||||
const t = getMenuTranslations(locale)
|
|
||||||
|
|
||||||
const template: MenuItemConstructorOptions[] = []
|
const template: MenuItemConstructorOptions[] = []
|
||||||
|
|
||||||
// macOS app menu
|
// macOS app menu
|
||||||
@@ -49,10 +44,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
template.push({
|
template.push({
|
||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" }, // System-translated
|
{ role: "about" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: t.settings,
|
label: "Settings...",
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -60,26 +55,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "services" }, // System-translated
|
{ role: "services" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" }, // System-translated
|
{ role: "hide" },
|
||||||
{ role: "hideOthers" }, // System-translated
|
{ role: "hideOthers" },
|
||||||
{ role: "unhide" }, // System-translated
|
{ role: "unhide" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" }, // System-translated
|
{ role: "quit" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.file,
|
label: "File",
|
||||||
submenu: [
|
submenu: [
|
||||||
...(isMac
|
...(isMac
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: t.settings,
|
label: "Settings",
|
||||||
accelerator: "CmdOrCtrl+,",
|
accelerator: "CmdOrCtrl+,",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
@@ -88,76 +83,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
]),
|
]),
|
||||||
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
isMac ? { role: "close" } : { role: "quit" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.edit,
|
label: "Edit",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "undo" }, // System-translated
|
{ role: "undo" },
|
||||||
{ role: "redo" }, // System-translated
|
{ role: "redo" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "cut" }, // System-translated
|
{ role: "cut" },
|
||||||
{ role: "copy" }, // System-translated
|
{ role: "copy" },
|
||||||
{ role: "paste" }, // System-translated
|
{ role: "paste" },
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
role: "pasteAndMatchStyle",
|
role: "pasteAndMatchStyle",
|
||||||
} as MenuItemConstructorOptions, // System-translated
|
} as MenuItemConstructorOptions,
|
||||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
{ role: "delete" } as MenuItemConstructorOptions,
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
|
{ role: "delete" } as MenuItemConstructorOptions,
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
|
{ role: "selectAll" } as MenuItemConstructorOptions,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.view,
|
label: "View",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" }, // System-translated
|
{ role: "reload" },
|
||||||
{ role: "forceReload" }, // System-translated
|
{ role: "forceReload" },
|
||||||
{ role: "toggleDevTools" }, // System-translated
|
{ role: "toggleDevTools" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "resetZoom" }, // System-translated
|
{ role: "resetZoom" },
|
||||||
{ role: "zoomIn" }, // System-translated
|
{ role: "zoomIn" },
|
||||||
{ role: "zoomOut" }, // System-translated
|
{ role: "zoomOut" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "togglefullscreen" }, // System-translated
|
{ role: "togglefullscreen" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
// Configuration menu with presets
|
||||||
template.push(buildConfigMenu(t))
|
template.push(buildConfigMenu())
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.window,
|
label: "Window",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "minimize" }, // System-translated
|
{ role: "minimize" },
|
||||||
{ role: "zoom" }, // System-translated
|
{ role: "zoom" },
|
||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
{ role: "front" } as MenuItemConstructorOptions, // System-translated
|
{ role: "front" } as MenuItemConstructorOptions,
|
||||||
]
|
]
|
||||||
: [{ role: "close" } as MenuItemConstructorOptions]), // System-translated
|
: [{ role: "close" } as MenuItemConstructorOptions]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Help menu
|
// Help menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.help,
|
label: "Help",
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: t.documentation,
|
label: "Documentation",
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io",
|
"https://github.com/dayuanjiang/next-ai-draw-io",
|
||||||
@@ -165,7 +160,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t.reportIssue,
|
label: "Report Issue",
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await shell.openExternal(
|
await shell.openExternal(
|
||||||
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
|
||||||
@@ -181,9 +176,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
/**
|
/**
|
||||||
* Build the Configuration menu with presets
|
* Build the Configuration menu with presets
|
||||||
*/
|
*/
|
||||||
function buildConfigMenu(
|
function buildConfigMenu(): MenuItemConstructorOptions {
|
||||||
t: ReturnType<typeof getMenuTranslations>,
|
|
||||||
): MenuItemConstructorOptions {
|
|
||||||
const presets = getAllPresets()
|
const presets = getAllPresets()
|
||||||
const currentPresetId = getCurrentPresetId()
|
const currentPresetId = getCurrentPresetId()
|
||||||
|
|
||||||
@@ -223,11 +216,11 @@ function buildConfigMenu(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: t.configuration,
|
label: "Configuration",
|
||||||
submenu: [
|
submenu: [
|
||||||
...(presetItems.length > 0
|
...(presetItems.length > 0
|
||||||
? [
|
? [
|
||||||
{ label: t.switchPreset, enabled: false },
|
{ label: "Switch Preset", enabled: false },
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
...presetItems,
|
...presetItems,
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
{ type: "separator" } as MenuItemConstructorOptions,
|
||||||
@@ -236,8 +229,8 @@ function buildConfigMenu(
|
|||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
presetItems.length > 0
|
presetItems.length > 0
|
||||||
? t.managePresets
|
? "Manage Presets..."
|
||||||
: t.addConfigurationPreset,
|
: "Add Configuration Preset...",
|
||||||
click: () => {
|
click: () => {
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
const win = BrowserWindow.getFocusedWindow()
|
||||||
showSettingsWindow(win || undefined)
|
showSettingsWindow(win || undefined)
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ interface ConfigPresetsFile {
|
|||||||
version: 1
|
version: 1
|
||||||
currentPresetId: string | null
|
currentPresetId: string | null
|
||||||
presets: ConfigPreset[]
|
presets: ConfigPreset[]
|
||||||
userLocale?: "en" | "zh" | "ja"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
const CONFIG_FILE_NAME = "config-presets.json"
|
||||||
@@ -162,7 +161,6 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
userLocale: undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +181,6 @@ export function loadPresets(): ConfigPresetsFile {
|
|||||||
version: 1,
|
version: 1,
|
||||||
currentPresetId: null,
|
currentPresetId: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
userLocale: undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,10 +351,6 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|||||||
apiKey: "SILICONFLOW_API_KEY",
|
apiKey: "SILICONFLOW_API_KEY",
|
||||||
baseUrl: "SILICONFLOW_BASE_URL",
|
baseUrl: "SILICONFLOW_BASE_URL",
|
||||||
},
|
},
|
||||||
modelscope: {
|
|
||||||
apiKey: "MODELSCOPE_API_KEY",
|
|
||||||
baseUrl: "MODELSCOPE_BASE_URL",
|
|
||||||
},
|
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||||
// bedrock and ollama don't use API keys in the same way
|
// bedrock and ollama don't use API keys in the same way
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
bedrock: { apiKey: "", baseUrl: "" },
|
||||||
@@ -465,21 +458,3 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user's preferred locale from config
|
|
||||||
* Returns undefined if not set
|
|
||||||
*/
|
|
||||||
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.userLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user's preferred locale in config
|
|
||||||
*/
|
|
||||||
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
|
|
||||||
const data = loadPresets()
|
|
||||||
data.userLocale = locale === null ? undefined : locale
|
|
||||||
savePresets(data)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getCurrentPresetEnv } from "./config-manager"
|
|||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
import { applyProxyToEnv } from "./proxy-manager"
|
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||||
import { createWindow, getMainWindow } from "./window-manager"
|
import { createWindow, getMainWindow } from "./window-manager"
|
||||||
|
|
||||||
@@ -25,9 +24,6 @@ if (!gotTheLock) {
|
|||||||
// Load environment variables from .env files
|
// Load environment variables from .env files
|
||||||
loadEnvFile()
|
loadEnvFile()
|
||||||
|
|
||||||
// Apply proxy settings from saved config
|
|
||||||
applyProxyToEnv()
|
|
||||||
|
|
||||||
// Apply saved preset environment variables (overrides .env)
|
// Apply saved preset environment variables (overrides .env)
|
||||||
const presetEnv = getCurrentPresetEnv()
|
const presetEnv = getCurrentPresetEnv()
|
||||||
for (const [key, value] of Object.entries(presetEnv)) {
|
for (const [key, value] of Object.entries(presetEnv)) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||||
import { rebuildAppMenu } from "./app-menu"
|
|
||||||
import {
|
import {
|
||||||
applyPresetToEnv,
|
applyPresetToEnv,
|
||||||
type ConfigPreset,
|
type ConfigPreset,
|
||||||
@@ -8,18 +7,10 @@ import {
|
|||||||
getAllPresets,
|
getAllPresets,
|
||||||
getCurrentPreset,
|
getCurrentPreset,
|
||||||
getCurrentPresetId,
|
getCurrentPresetId,
|
||||||
getUserLocale,
|
|
||||||
setCurrentPreset,
|
setCurrentPreset,
|
||||||
setUserLocale,
|
|
||||||
updatePreset,
|
updatePreset,
|
||||||
} from "./config-manager"
|
} from "./config-manager"
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import {
|
|
||||||
applyProxyToEnv,
|
|
||||||
getProxyConfig,
|
|
||||||
type ProxyConfig,
|
|
||||||
saveProxyConfig,
|
|
||||||
} from "./proxy-manager"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Allowed configuration keys for presets
|
||||||
@@ -218,68 +209,4 @@ export function registerIpcHandlers(): void {
|
|||||||
return setCurrentPreset(id)
|
return setCurrentPreset(id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Proxy Settings ====================
|
|
||||||
|
|
||||||
ipcMain.handle("get-proxy", () => {
|
|
||||||
return getProxyConfig()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
|
|
||||||
try {
|
|
||||||
// Save config to file
|
|
||||||
saveProxyConfig(config)
|
|
||||||
|
|
||||||
// Apply to current process environment
|
|
||||||
applyProxyToEnv()
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
// In development, env vars are already applied
|
|
||||||
// Next.js dev server may need manual restart
|
|
||||||
return { success: true, devMode: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production: restart Next.js server to pick up new env vars
|
|
||||||
await restartNextServer()
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to apply proxy settings",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== User Locale ====================
|
|
||||||
|
|
||||||
ipcMain.handle("get-user-locale", () => {
|
|
||||||
return getUserLocale()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("set-user-locale", (_event, locale: string) => {
|
|
||||||
// Validate locale is one of the supported values
|
|
||||||
if (!["en", "zh", "ja"].includes(locale)) {
|
|
||||||
return { success: false, error: "Invalid locale" }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setUserLocale(locale as "en" | "zh" | "ja")
|
|
||||||
// Rebuild the menu to reflect the new locale
|
|
||||||
rebuildAppMenu()
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to set locale",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* Internationalization support for Electron menu
|
|
||||||
* Translations for menu labels that don't use Electron's built-in roles
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getUserLocale } from "./config-manager"
|
|
||||||
|
|
||||||
export type MenuLocale = "en" | "zh" | "ja"
|
|
||||||
|
|
||||||
export interface MenuTranslations {
|
|
||||||
// App menu (macOS only)
|
|
||||||
settings: string
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: string
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: string
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: string
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: string
|
|
||||||
switchPreset: string
|
|
||||||
managePresets: string
|
|
||||||
addConfigurationPreset: string
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: string
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: string
|
|
||||||
documentation: string
|
|
||||||
reportIssue: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
|
||||||
en: {
|
|
||||||
// App menu
|
|
||||||
settings: "Settings...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "File",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "Edit",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "View",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "Configuration",
|
|
||||||
switchPreset: "Switch Preset",
|
|
||||||
managePresets: "Manage Presets...",
|
|
||||||
addConfigurationPreset: "Add Configuration Preset...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "Window",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "Help",
|
|
||||||
documentation: "Documentation",
|
|
||||||
reportIssue: "Report Issue",
|
|
||||||
},
|
|
||||||
|
|
||||||
zh: {
|
|
||||||
// App menu
|
|
||||||
settings: "设置...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "文件",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "编辑",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "查看",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "配置",
|
|
||||||
switchPreset: "切换预设",
|
|
||||||
managePresets: "管理预设...",
|
|
||||||
addConfigurationPreset: "添加配置预设...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "窗口",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "帮助",
|
|
||||||
documentation: "文档",
|
|
||||||
reportIssue: "报告问题",
|
|
||||||
},
|
|
||||||
|
|
||||||
ja: {
|
|
||||||
// App menu
|
|
||||||
settings: "設定...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "ファイル",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "編集",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "表示",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "設定",
|
|
||||||
switchPreset: "プリセット切り替え",
|
|
||||||
managePresets: "プリセット管理...",
|
|
||||||
addConfigurationPreset: "設定プリセットを追加...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "ウインドウ",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "ヘルプ",
|
|
||||||
documentation: "ドキュメント",
|
|
||||||
reportIssue: "問題を報告",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get menu translations for a given locale
|
|
||||||
* Falls back to English if locale is not supported
|
|
||||||
*/
|
|
||||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
|
||||||
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
|
||||||
const normalized = locale.toLowerCase().split("-")[0]
|
|
||||||
|
|
||||||
if (normalized === "zh") return translations.zh
|
|
||||||
if (normalized === "ja") return translations.ja
|
|
||||||
return translations.en
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect system locale from Electron app
|
|
||||||
* Returns one of: "en", "zh", "ja"
|
|
||||||
*/
|
|
||||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
|
||||||
const normalized = appLocale.toLowerCase().split("-")[0]
|
|
||||||
|
|
||||||
if (normalized === "zh") return "zh"
|
|
||||||
if (normalized === "ja") return "ja"
|
|
||||||
return "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get locale from stored preference or system default
|
|
||||||
* Checks config file for user's language preference first
|
|
||||||
*/
|
|
||||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
|
||||||
// Try to get from saved preference first
|
|
||||||
const savedLocale = getUserLocale()
|
|
||||||
if (savedLocale) {
|
|
||||||
return savedLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to system locale
|
|
||||||
return detectSystemLocale(appLocale)
|
|
||||||
}
|
|
||||||
@@ -69,8 +69,6 @@ export async function startNextServer(): Promise<string> {
|
|||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "localhost",
|
HOSTNAME: "localhost",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
|
||||||
NODE_USE_ENV_PROXY: "1",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cache directory to a writable location (user's app data folder)
|
// Set cache directory to a writable location (user's app data folder)
|
||||||
@@ -87,13 +85,6 @@ export async function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: log proxy-related env vars
|
|
||||||
console.log("Proxy env vars being passed to server:", {
|
|
||||||
HTTP_PROXY: env.HTTP_PROXY || env.http_proxy || "not set",
|
|
||||||
HTTPS_PROXY: env.HTTPS_PROXY || env.https_proxy || "not set",
|
|
||||||
NODE_USE_ENV_PROXY: env.NODE_USE_ENV_PROXY || "not set",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use Electron's utilityProcess API for running Node.js in background
|
// Use Electron's utilityProcess API for running Node.js in background
|
||||||
// This is the recommended way to run Node.js code in Electron
|
// This is the recommended way to run Node.js code in Electron
|
||||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||||
@@ -123,41 +114,13 @@ export async function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the Next.js server process and wait for it to exit
|
* Stop the Next.js server process
|
||||||
*/
|
*/
|
||||||
export async function stopNextServer(): Promise<void> {
|
export function stopNextServer(): void {
|
||||||
if (serverProcess) {
|
if (serverProcess) {
|
||||||
console.log("Stopping Next.js server...")
|
console.log("Stopping Next.js server...")
|
||||||
|
|
||||||
// Create a promise that resolves when the process exits
|
|
||||||
const exitPromise = new Promise<void>((resolve) => {
|
|
||||||
const proc = serverProcess
|
|
||||||
if (!proc) {
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onExit = () => {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.once("exit", onExit)
|
|
||||||
|
|
||||||
// Timeout after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
proc.removeListener("exit", onExit)
|
|
||||||
resolve()
|
|
||||||
}, 5000)
|
|
||||||
})
|
|
||||||
|
|
||||||
serverProcess.kill()
|
serverProcess.kill()
|
||||||
serverProcess = null
|
serverProcess = null
|
||||||
|
|
||||||
// Wait for process to exit
|
|
||||||
await exitPromise
|
|
||||||
|
|
||||||
// Additional wait for OS to release port
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +150,8 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
|||||||
export async function restartNextServer(): Promise<string> {
|
export async function restartNextServer(): Promise<string> {
|
||||||
console.log("Restarting Next.js server...")
|
console.log("Restarting Next.js server...")
|
||||||
|
|
||||||
// Stop the current server and wait for it to exit
|
// Stop the current server
|
||||||
await stopNextServer()
|
stopNextServer()
|
||||||
|
|
||||||
// Wait for the port to be released
|
// Wait for the port to be released
|
||||||
await waitForServerStop()
|
await waitForServerStop()
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { app } from "electron"
|
|
||||||
import * as fs from "fs"
|
|
||||||
import * as path from "path"
|
|
||||||
import type { ProxyConfig } from "../electron.d"
|
|
||||||
|
|
||||||
export type { ProxyConfig }
|
|
||||||
|
|
||||||
const CONFIG_FILE = "proxy-config.json"
|
|
||||||
|
|
||||||
function getConfigPath(): string {
|
|
||||||
return path.join(app.getPath("userData"), CONFIG_FILE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load proxy configuration from JSON file
|
|
||||||
*/
|
|
||||||
export function loadProxyConfig(): ProxyConfig {
|
|
||||||
try {
|
|
||||||
const configPath = getConfigPath()
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
const data = fs.readFileSync(configPath, "utf-8")
|
|
||||||
return JSON.parse(data) as ProxyConfig
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load proxy config:", error)
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save proxy configuration to JSON file
|
|
||||||
*/
|
|
||||||
export function saveProxyConfig(config: ProxyConfig): void {
|
|
||||||
try {
|
|
||||||
const configPath = getConfigPath()
|
|
||||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save proxy config:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply proxy configuration to process.env
|
|
||||||
* Must be called BEFORE starting the Next.js server
|
|
||||||
*/
|
|
||||||
export function applyProxyToEnv(): void {
|
|
||||||
const config = loadProxyConfig()
|
|
||||||
|
|
||||||
if (config.httpProxy) {
|
|
||||||
process.env.HTTP_PROXY = config.httpProxy
|
|
||||||
process.env.http_proxy = config.httpProxy
|
|
||||||
} else {
|
|
||||||
delete process.env.HTTP_PROXY
|
|
||||||
delete process.env.http_proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.httpsProxy) {
|
|
||||||
process.env.HTTPS_PROXY = config.httpsProxy
|
|
||||||
process.env.https_proxy = config.httpsProxy
|
|
||||||
} else {
|
|
||||||
delete process.env.HTTPS_PROXY
|
|
||||||
delete process.env.https_proxy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current proxy configuration (from process.env)
|
|
||||||
*/
|
|
||||||
export function getProxyConfig(): ProxyConfig {
|
|
||||||
return {
|
|
||||||
httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || "",
|
|
||||||
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,14 +21,4 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
// File operations
|
// File operations
|
||||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||||
|
|
||||||
// Proxy settings
|
|
||||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
|
||||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
|
||||||
ipcRenderer.invoke("set-proxy", config),
|
|
||||||
|
|
||||||
// User locale settings
|
|
||||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
|
||||||
setUserLocale: (locale: string) =>
|
|
||||||
ipcRenderer.invoke("set-user-locale", locale),
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
<option value="openrouter">OpenRouter</option>
|
<option value="openrouter">OpenRouter</option>
|
||||||
<option value="deepseek">DeepSeek</option>
|
<option value="deepseek">DeepSeek</option>
|
||||||
<option value="siliconflow">SiliconFlow</option>
|
<option value="siliconflow">SiliconFlow</option>
|
||||||
<option value="modelscope">ModelScope</option>
|
|
||||||
<option value="ollama">Ollama (Local)</option>
|
<option value="ollama">Ollama (Local)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ function getProviderLabel(provider) {
|
|||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
deepseek: "DeepSeek",
|
deepseek: "DeepSeek",
|
||||||
siliconflow: "SiliconFlow",
|
siliconflow: "SiliconFlow",
|
||||||
modelscope: "ModelScope",
|
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
}
|
}
|
||||||
return labels[provider] || provider
|
return labels[provider] || provider
|
||||||
|
|||||||
24
env.example
24
env.example
@@ -1,6 +1,6 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -40,14 +40,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
|
# GOOGLE_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (for more/less thinking)
|
||||||
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
# GOOGLE_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (low/high)
|
||||||
|
|
||||||
# Google Vertex AI Configuration (Enterprise GCP)
|
|
||||||
# For enterprise users needing data residency, VPC Service Controls, or GCP integration
|
|
||||||
# GOOGLE_VERTEX_API_KEY= # Required: Express Mode API key
|
|
||||||
# GOOGLE_VERTEX_BASE_URL=https://... # Optional: Custom endpoint URL
|
|
||||||
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
|
||||||
# GOOGLE_VERTEX_THINKING_BUDGET=8192 # Optional: Gemini 2.5 thinking budget in tokens (1024-100000)
|
|
||||||
# GOOGLE_VERTEX_THINKING_LEVEL=high # Optional: Gemini 3 thinking level (minimal/low/medium/high)
|
|
||||||
|
|
||||||
# Azure OpenAI Configuration
|
# Azure OpenAI Configuration
|
||||||
# Configure endpoint using ONE of these methods:
|
# Configure endpoint using ONE of these methods:
|
||||||
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
|
||||||
@@ -80,10 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# SGLANG_API_KEY=your-sglang-api-key
|
# SGLANG_API_KEY=your-sglang-api-key
|
||||||
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
||||||
|
|
||||||
# ModelScope Configuration
|
|
||||||
# MODELSCOPE_API_KEY=ms-...
|
|
||||||
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
|
|
||||||
|
|
||||||
# ByteDance Doubao Configuration (via Volcengine)
|
# ByteDance Doubao Configuration (via Volcengine)
|
||||||
# DOUBAO_API_KEY=your-doubao-api-key
|
# DOUBAO_API_KEY=your-doubao-api-key
|
||||||
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
||||||
@@ -101,11 +89,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# LANGFUSE_SECRET_KEY=sk-lf-...
|
# LANGFUSE_SECRET_KEY=sk-lf-...
|
||||||
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
||||||
|
|
||||||
# Optional server-side multi-model configuration
|
|
||||||
# If set, points to a JSON file with server-provided models (see README for schema).
|
|
||||||
# Default: ./ai-models.json in project root
|
|
||||||
# AI_MODELS_CONFIG_PATH=/path/to/ai-models.json
|
|
||||||
|
|
||||||
# Temperature (Optional)
|
# Temperature (Optional)
|
||||||
# Controls randomness in AI responses. Lower = more deterministic.
|
# Controls randomness in AI responses. Lower = more deterministic.
|
||||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||||
@@ -129,8 +112,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Enabled by default. Set to "false" to disable.
|
# Enabled by default. Set to "false" to disable.
|
||||||
# ENABLE_PDF_INPUT=true
|
# ENABLE_PDF_INPUT=true
|
||||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
||||||
|
|
||||||
# Security Settings (Optional)
|
|
||||||
# Allow private/internal URLs for reverse proxy setups (default: true)
|
|
||||||
# Set to "false" to block private IPs, localhost, and internal hostnames
|
|
||||||
# ALLOW_PRIVATE_URLS=false
|
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import type { MutableRefObject } from "react"
|
import type { MutableRefObject } from "react"
|
||||||
import { useRef } from "react"
|
|
||||||
import type { DiagramOperation } from "@/components/chat/types"
|
|
||||||
import type {
|
|
||||||
ValidationState,
|
|
||||||
ValidationStatus,
|
|
||||||
} from "@/components/chat/ValidationCard"
|
|
||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
|
||||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
|
||||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
@@ -37,13 +29,11 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
|||||||
|
|
||||||
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||||
|
|
||||||
const MAX_VALIDATION_RETRIES = 3
|
interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
// Type for the validation function passed from useValidateDiagram hook
|
cell_id: string
|
||||||
type ValidateDiagramFn = (
|
new_xml?: string
|
||||||
imageData: string,
|
}
|
||||||
sessionId?: string,
|
|
||||||
) => Promise<ValidationResult>
|
|
||||||
|
|
||||||
interface UseDiagramToolHandlersParams {
|
interface UseDiagramToolHandlersParams {
|
||||||
partialXmlRef: MutableRefObject<string>
|
partialXmlRef: MutableRefObject<string>
|
||||||
@@ -52,14 +42,6 @@ interface UseDiagramToolHandlersParams {
|
|||||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
captureValidationPng?: () => Promise<string | null>
|
|
||||||
validateDiagram?: ValidateDiagramFn
|
|
||||||
enableVlmValidation?: boolean
|
|
||||||
sessionId?: string
|
|
||||||
onValidationStateChange?: (
|
|
||||||
toolCallId: string,
|
|
||||||
state: ValidationState,
|
|
||||||
) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,34 +58,7 @@ export function useDiagramToolHandlers({
|
|||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart,
|
||||||
onExport,
|
onExport,
|
||||||
captureValidationPng,
|
|
||||||
validateDiagram,
|
|
||||||
enableVlmValidation = true,
|
|
||||||
sessionId,
|
|
||||||
onValidationStateChange,
|
|
||||||
}: UseDiagramToolHandlersParams) {
|
}: UseDiagramToolHandlersParams) {
|
||||||
// Track validation retry count per tool call
|
|
||||||
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
|
|
||||||
|
|
||||||
// Helper to update validation state
|
|
||||||
const updateValidationState = (
|
|
||||||
toolCallId: string,
|
|
||||||
status: ValidationStatus,
|
|
||||||
options?: {
|
|
||||||
attempt?: number
|
|
||||||
maxAttempts?: number
|
|
||||||
result?: ValidationResult
|
|
||||||
error?: string
|
|
||||||
imageData?: string
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
if (onValidationStateChange) {
|
|
||||||
onValidationStateChange(toolCallId, {
|
|
||||||
status,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const handleToolCall = async (
|
const handleToolCall = async (
|
||||||
{ toolCall }: { toolCall: ToolCall },
|
{ toolCall }: { toolCall: ToolCall },
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
@@ -205,159 +160,7 @@ ${finalXml}
|
|||||||
// Success - diagram will be rendered by chat-message-display
|
// Success - diagram will be rendered by chat-message-display
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
"[display_diagram] Success! Adding tool output with state: output-available",
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VLM validation after successful display
|
|
||||||
if (
|
|
||||||
enableVlmValidation &&
|
|
||||||
captureValidationPng &&
|
|
||||||
validateDiagram
|
|
||||||
) {
|
|
||||||
let capturedPngData: string | null = null
|
|
||||||
try {
|
|
||||||
// Notify UI that we're starting capture
|
|
||||||
updateValidationState(toolCall.toolCallId, "capturing")
|
|
||||||
|
|
||||||
// Small delay (100ms) to allow diagram rendering to complete before capture.
|
|
||||||
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
capturedPngData = await captureValidationPng()
|
|
||||||
if (capturedPngData) {
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Captured PNG for validation",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryCount =
|
|
||||||
validationRetryCountRef.current.get(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
) || 0
|
|
||||||
|
|
||||||
// Notify UI that we're validating (include the image)
|
|
||||||
updateValidationState(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
"validating",
|
|
||||||
{
|
|
||||||
attempt: retryCount + 1,
|
|
||||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
|
||||||
imageData: capturedPngData,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await validateDiagram(
|
|
||||||
capturedPngData,
|
|
||||||
sessionId,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
if (retryCount < MAX_VALIDATION_RETRIES) {
|
|
||||||
validationRetryCountRef.current.set(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
retryCount + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
const feedback =
|
|
||||||
formatValidationFeedback(result)
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
|
||||||
result.issues,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify UI of validation failure (include the image)
|
|
||||||
updateValidationState(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
"failed",
|
|
||||||
{
|
|
||||||
attempt: retryCount + 1,
|
|
||||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
|
||||||
result,
|
|
||||||
imageData: capturedPngData,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
addToolOutput({
|
|
||||||
tool: "display_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
// Max retries reached - accept the diagram with warning
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
validationRetryCountRef.current.delete(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Notify UI that we're accepting with issues (include the image)
|
|
||||||
updateValidationState(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
"skipped",
|
|
||||||
{ result, imageData: capturedPngData },
|
|
||||||
)
|
|
||||||
|
|
||||||
addToolOutput({
|
|
||||||
tool: "display_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
output: "Diagram displayed (validation issues noted but max retries reached).",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Validation passed - clean up retry count
|
|
||||||
validationRetryCountRef.current.delete(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Validation passed!",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify UI of success (include the image)
|
|
||||||
// Use "success_with_warnings" if valid but has issues
|
|
||||||
const hasWarnings = result.issues.length > 0
|
|
||||||
updateValidationState(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
hasWarnings
|
|
||||||
? "success_with_warnings"
|
|
||||||
: "success",
|
|
||||||
{ result, imageData: capturedPngData },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// PNG capture failed - skip validation
|
|
||||||
updateValidationState(toolCall.toolCallId, "skipped")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// VLM validation error - log but don't block the user
|
|
||||||
console.warn(
|
|
||||||
"[display_diagram] VLM validation error:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
updateValidationState(toolCall.toolCallId, "error", {
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Validation failed",
|
|
||||||
imageData: capturedPngData || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Adding tool output with state: output-available",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import {
|
import {
|
||||||
createEmptyConfig,
|
createEmptyConfig,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
flattenModels,
|
flattenModels,
|
||||||
type ModelConfig,
|
type ModelConfig,
|
||||||
type MultiModelConfig,
|
type MultiModelConfig,
|
||||||
|
PROVIDER_INFO,
|
||||||
type ProviderConfig,
|
type ProviderConfig,
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
@@ -133,56 +133,14 @@ export interface UseModelConfigReturn {
|
|||||||
export function useModelConfig(): UseModelConfigReturn {
|
export function useModelConfig(): UseModelConfigReturn {
|
||||||
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
|
|
||||||
const [serverLoaded, setServerLoaded] = useState(false)
|
|
||||||
|
|
||||||
// Load client config on mount
|
// Load config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loaded = loadConfig()
|
const loaded = loadConfig()
|
||||||
setConfig(loaded)
|
setConfig(loaded)
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load server models on mount (if any)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
|
|
||||||
fetch("/api/server-models")
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error(
|
|
||||||
"Failed to load server models:",
|
|
||||||
res.status,
|
|
||||||
res.statusText,
|
|
||||||
)
|
|
||||||
throw new Error(`Request failed with status ${res.status}`)
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
const raw: FlattenedServerModel[] = data?.models || []
|
|
||||||
setServerModels(raw)
|
|
||||||
setServerLoaded(true)
|
|
||||||
|
|
||||||
// Auto-select default server model if no model is currently selected
|
|
||||||
setConfig((prev) => {
|
|
||||||
if (!prev.selectedModelId && raw.length > 0) {
|
|
||||||
const defaultModel = raw.find((m) => m.isDefault)
|
|
||||||
if (defaultModel) {
|
|
||||||
return { ...prev, selectedModelId: defaultModel.id }
|
|
||||||
}
|
|
||||||
// If no default marked, use first server model
|
|
||||||
return { ...prev, selectedModelId: raw[0].id }
|
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error while loading server models:", error)
|
|
||||||
setServerLoaded(true)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Save config whenever it changes (after initial load)
|
// Save config whenever it changes (after initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
@@ -191,33 +149,9 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
}, [config, isLoaded])
|
}, [config, isLoaded])
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const userModels = flattenModels(config)
|
const models = flattenModels(config)
|
||||||
|
|
||||||
const models: FlattenedModel[] = [
|
|
||||||
// Server models (read-only, credentials from env)
|
|
||||||
...serverModels.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
modelId: m.modelId,
|
|
||||||
provider: m.provider,
|
|
||||||
providerLabel: `Server · ${m.providerLabel}`,
|
|
||||||
apiKey: "",
|
|
||||||
baseUrl: undefined,
|
|
||||||
awsAccessKeyId: undefined,
|
|
||||||
awsSecretAccessKey: undefined,
|
|
||||||
awsRegion: undefined,
|
|
||||||
awsSessionToken: undefined,
|
|
||||||
validated: true,
|
|
||||||
source: "server" as const,
|
|
||||||
isDefault: m.isDefault,
|
|
||||||
apiKeyEnv: m.apiKeyEnv,
|
|
||||||
baseUrlEnv: m.baseUrlEnv,
|
|
||||||
})),
|
|
||||||
// User models from local configuration
|
|
||||||
...userModels,
|
|
||||||
]
|
|
||||||
|
|
||||||
const selectedModel = config.selectedModelId
|
const selectedModel = config.selectedModelId
|
||||||
? models.find((m) => m.id === config.selectedModelId)
|
? findModelById(config, config.selectedModelId)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -349,7 +283,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
isLoaded: isLoaded && serverLoaded,
|
isLoaded,
|
||||||
models,
|
models,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedModelId: config.selectedModelId,
|
selectedModelId: config.selectedModelId,
|
||||||
@@ -381,10 +315,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: string
|
awsSecretAccessKey: string
|
||||||
awsRegion: string
|
awsRegion: string
|
||||||
awsSessionToken: string
|
awsSessionToken: string
|
||||||
// Selected model ID (for server model lookup)
|
|
||||||
selectedModelId: string
|
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
vertexApiKey: string
|
|
||||||
} {
|
} {
|
||||||
const empty = {
|
const empty = {
|
||||||
accessCode: "",
|
accessCode: "",
|
||||||
@@ -396,8 +326,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
selectedModelId: "",
|
|
||||||
vertexApiKey: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === "undefined") return empty
|
if (typeof window === "undefined") return empty
|
||||||
@@ -420,8 +348,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegion: "",
|
awsRegion: "",
|
||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
selectedModelId: "",
|
|
||||||
vertexApiKey: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,32 +358,12 @@ export function getSelectedAIConfig(): {
|
|||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
// No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)
|
// No selected model = use server default
|
||||||
if (!config.selectedModelId) {
|
if (!config.selectedModelId) {
|
||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-side model selection (id = "server:<name-slug>:<modelId>")
|
// Find selected model
|
||||||
// Provider is resolved server-side via findServerModelById()
|
|
||||||
if (config.selectedModelId.startsWith("server:")) {
|
|
||||||
const parts = config.selectedModelId.split(":")
|
|
||||||
const nameSlug = parts[1] || ""
|
|
||||||
const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs
|
|
||||||
|
|
||||||
return {
|
|
||||||
...empty,
|
|
||||||
accessCode,
|
|
||||||
// Note: nameSlug is NOT the provider, but we send it for backwards compat
|
|
||||||
// Server uses selectedModelId to lookup the actual provider
|
|
||||||
aiProvider: nameSlug,
|
|
||||||
aiBaseUrl: "",
|
|
||||||
aiApiKey: "",
|
|
||||||
aiModel: modelId,
|
|
||||||
selectedModelId: config.selectedModelId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find selected user-defined model
|
|
||||||
const model = findModelById(config, config.selectedModelId)
|
const model = findModelById(config, config.selectedModelId)
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return { ...empty, accessCode }
|
return { ...empty, accessCode }
|
||||||
@@ -474,8 +380,5 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||||
awsRegion: model.awsRegion || "",
|
awsRegion: model.awsRegion || "",
|
||||||
awsSessionToken: model.awsSessionToken || "",
|
awsSessionToken: model.awsSessionToken || "",
|
||||||
selectedModelId: config.selectedModelId || "",
|
|
||||||
// Vertex AI credentials (Express Mode)
|
|
||||||
vertexApiKey: model.vertexApiKey || "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,322 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
|
||||||
import {
|
|
||||||
type ChatSession,
|
|
||||||
createEmptySession,
|
|
||||||
deleteSession as deleteSessionFromDB,
|
|
||||||
enforceSessionLimit,
|
|
||||||
extractTitle,
|
|
||||||
getAllSessionMetadata,
|
|
||||||
getSession,
|
|
||||||
isIndexedDBAvailable,
|
|
||||||
migrateFromLocalStorage,
|
|
||||||
type SessionMetadata,
|
|
||||||
type StoredMessage,
|
|
||||||
saveSession,
|
|
||||||
} from "@/lib/session-storage"
|
|
||||||
|
|
||||||
export interface SessionData {
|
|
||||||
messages: StoredMessage[]
|
|
||||||
xmlSnapshots: [number, string][]
|
|
||||||
diagramXml: string
|
|
||||||
thumbnailDataUrl?: string
|
|
||||||
diagramHistory?: { svg: string; xml: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSessionManagerReturn {
|
|
||||||
// State
|
|
||||||
sessions: SessionMetadata[]
|
|
||||||
currentSessionId: string | null
|
|
||||||
currentSession: ChatSession | null
|
|
||||||
isLoading: boolean
|
|
||||||
isAvailable: boolean
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
switchSession: (id: string) => Promise<SessionData | null>
|
|
||||||
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
|
||||||
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
|
||||||
saveCurrentSession: (
|
|
||||||
data: SessionData,
|
|
||||||
forSessionId?: string | null,
|
|
||||||
) => Promise<void>
|
|
||||||
refreshSessions: () => Promise<void>
|
|
||||||
clearCurrentSession: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseSessionManagerOptions {
|
|
||||||
/** Session ID from URL param - if provided, load this session; if null, start blank */
|
|
||||||
initialSessionId?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSessionManager(
|
|
||||||
options: UseSessionManagerOptions = {},
|
|
||||||
): UseSessionManagerReturn {
|
|
||||||
const { initialSessionId } = options
|
|
||||||
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [isAvailable, setIsAvailable] = useState(false)
|
|
||||||
|
|
||||||
const isInitializedRef = useRef(false)
|
|
||||||
// Sequence guard for URL changes - prevents out-of-order async resolution
|
|
||||||
const urlChangeSequenceRef = useRef(0)
|
|
||||||
|
|
||||||
// Load sessions list
|
|
||||||
const refreshSessions = useCallback(async () => {
|
|
||||||
if (!isIndexedDBAvailable()) return
|
|
||||||
try {
|
|
||||||
const metadata = await getAllSessionMetadata()
|
|
||||||
setSessions(metadata)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to refresh sessions:", error)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initialize on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitializedRef.current) return
|
|
||||||
isInitializedRef.current = true
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
if (!isIndexedDBAvailable()) {
|
|
||||||
setIsAvailable(false)
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsAvailable(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Run migration first (one-time conversion from localStorage)
|
|
||||||
await migrateFromLocalStorage()
|
|
||||||
|
|
||||||
// Load sessions list
|
|
||||||
const metadata = await getAllSessionMetadata()
|
|
||||||
setSessions(metadata)
|
|
||||||
|
|
||||||
// Only load a session if initialSessionId is provided (from URL param)
|
|
||||||
if (initialSessionId) {
|
|
||||||
const session = await getSession(initialSessionId)
|
|
||||||
if (session) {
|
|
||||||
setCurrentSession(session)
|
|
||||||
setCurrentSessionId(session.id)
|
|
||||||
}
|
|
||||||
// If session not found, stay in blank state (URL has invalid session ID)
|
|
||||||
}
|
|
||||||
// If no initialSessionId, start with blank state (no auto-restore)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize session manager:", error)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init()
|
|
||||||
}, [initialSessionId])
|
|
||||||
|
|
||||||
// Handle URL session ID changes after initialization
|
|
||||||
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
|
||||||
// when clearCurrentSession() is called before URL updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInitializedRef.current) return // Wait for initial load
|
|
||||||
if (!isAvailable) return
|
|
||||||
|
|
||||||
// Increment sequence to invalidate any pending async operations
|
|
||||||
urlChangeSequenceRef.current++
|
|
||||||
const currentSequence = urlChangeSequenceRef.current
|
|
||||||
|
|
||||||
async function handleSessionIdChange() {
|
|
||||||
if (initialSessionId) {
|
|
||||||
// URL has session ID - load it
|
|
||||||
const session = await getSession(initialSessionId)
|
|
||||||
|
|
||||||
// Check if this request is still the latest (sequence guard)
|
|
||||||
// If not, a newer URL change happened while we were loading
|
|
||||||
if (currentSequence !== urlChangeSequenceRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
// Only update if the session is different from current
|
|
||||||
setCurrentSessionId((current) => {
|
|
||||||
if (current !== session.id) {
|
|
||||||
setCurrentSession(session)
|
|
||||||
return session.id
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Removed: else clause that clears session
|
|
||||||
// Clearing is now handled explicitly by clearCurrentSession()
|
|
||||||
// This prevents race conditions when URL update is async
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSessionIdChange()
|
|
||||||
}, [initialSessionId, isAvailable])
|
|
||||||
|
|
||||||
// Refresh sessions on window focus (multi-tab sync)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleFocus = () => {
|
|
||||||
refreshSessions()
|
|
||||||
}
|
|
||||||
window.addEventListener("focus", handleFocus)
|
|
||||||
return () => window.removeEventListener("focus", handleFocus)
|
|
||||||
}, [refreshSessions])
|
|
||||||
|
|
||||||
// Switch to a different session
|
|
||||||
const switchSession = useCallback(
|
|
||||||
async (id: string): Promise<SessionData | null> => {
|
|
||||||
if (id === currentSessionId) return null
|
|
||||||
|
|
||||||
// Save current session first if it has messages
|
|
||||||
if (currentSession && currentSession.messages.length > 0) {
|
|
||||||
await saveSession(currentSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the target session
|
|
||||||
const session = await getSession(id)
|
|
||||||
if (!session) {
|
|
||||||
console.error("Session not found:", id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
setCurrentSession(session)
|
|
||||||
setCurrentSessionId(session.id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages: session.messages,
|
|
||||||
xmlSnapshots: session.xmlSnapshots,
|
|
||||||
diagramXml: session.diagramXml,
|
|
||||||
thumbnailDataUrl: session.thumbnailDataUrl,
|
|
||||||
diagramHistory: session.diagramHistory,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentSessionId, currentSession],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delete a session
|
|
||||||
const deleteSession = useCallback(
|
|
||||||
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
|
|
||||||
const wasCurrentSession = id === currentSessionId
|
|
||||||
await deleteSessionFromDB(id)
|
|
||||||
|
|
||||||
// If deleting current session, clear state (caller will show new empty session)
|
|
||||||
if (wasCurrentSession) {
|
|
||||||
setCurrentSession(null)
|
|
||||||
setCurrentSessionId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshSessions()
|
|
||||||
|
|
||||||
return { wasCurrentSession }
|
|
||||||
},
|
|
||||||
[currentSessionId, refreshSessions],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save current session data (debounced externally by caller)
|
|
||||||
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
|
||||||
const saveCurrentSession = useCallback(
|
|
||||||
async (
|
|
||||||
data: SessionData,
|
|
||||||
forSessionId?: string | null,
|
|
||||||
): Promise<void> => {
|
|
||||||
// If forSessionId is provided, verify it matches current session
|
|
||||||
// This prevents stale debounced saves from overwriting a newly switched session
|
|
||||||
if (
|
|
||||||
forSessionId !== undefined &&
|
|
||||||
forSessionId !== currentSessionId
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSession) {
|
|
||||||
// Create a new session if none exists
|
|
||||||
const newSession: ChatSession = {
|
|
||||||
...createEmptySession(),
|
|
||||||
messages: data.messages,
|
|
||||||
xmlSnapshots: data.xmlSnapshots,
|
|
||||||
diagramXml: data.diagramXml,
|
|
||||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
|
||||||
diagramHistory: data.diagramHistory,
|
|
||||||
title: extractTitle(data.messages),
|
|
||||||
}
|
|
||||||
await saveSession(newSession)
|
|
||||||
await enforceSessionLimit()
|
|
||||||
setCurrentSession(newSession)
|
|
||||||
setCurrentSessionId(newSession.id)
|
|
||||||
await refreshSessions()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing session
|
|
||||||
const updatedSession: ChatSession = {
|
|
||||||
...currentSession,
|
|
||||||
messages: data.messages,
|
|
||||||
xmlSnapshots: data.xmlSnapshots,
|
|
||||||
diagramXml: data.diagramXml,
|
|
||||||
thumbnailDataUrl:
|
|
||||||
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
|
||||||
diagramHistory:
|
|
||||||
data.diagramHistory ?? currentSession.diagramHistory,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
// Update title if it's still default and we have messages
|
|
||||||
title:
|
|
||||||
currentSession.title === "New Chat" &&
|
|
||||||
data.messages.length > 0
|
|
||||||
? extractTitle(data.messages)
|
|
||||||
: currentSession.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveSession(updatedSession)
|
|
||||||
setCurrentSession(updatedSession)
|
|
||||||
|
|
||||||
// Update sessions list metadata
|
|
||||||
setSessions((prev) =>
|
|
||||||
prev.map((s) =>
|
|
||||||
s.id === updatedSession.id
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
title: updatedSession.title,
|
|
||||||
updatedAt: updatedSession.updatedAt,
|
|
||||||
messageCount: updatedSession.messages.length,
|
|
||||||
hasDiagram:
|
|
||||||
!!updatedSession.diagramXml &&
|
|
||||||
updatedSession.diagramXml.trim().length > 0,
|
|
||||||
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
|
|
||||||
}
|
|
||||||
: s,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[currentSession, currentSessionId, refreshSessions],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Clear current session state (for starting fresh without loading another session)
|
|
||||||
const clearCurrentSession = useCallback(() => {
|
|
||||||
setCurrentSession(null)
|
|
||||||
setCurrentSessionId(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessions,
|
|
||||||
currentSessionId,
|
|
||||||
currentSession,
|
|
||||||
isLoading,
|
|
||||||
isAvailable,
|
|
||||||
switchSession,
|
|
||||||
deleteSession,
|
|
||||||
saveCurrentSession,
|
|
||||||
refreshSessions,
|
|
||||||
clearCurrentSession,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for VLM-based diagram validation using AI SDK's useObject.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { experimental_useObject as useObject } from "@ai-sdk/react"
|
|
||||||
import { useCallback, useRef } from "react"
|
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
|
||||||
import {
|
|
||||||
type ValidationResult,
|
|
||||||
ValidationResultSchema,
|
|
||||||
} from "@/lib/validation-schema"
|
|
||||||
|
|
||||||
export type { ValidationResult }
|
|
||||||
|
|
||||||
// Default valid result for fallback cases
|
|
||||||
const DEFAULT_VALID_RESULT: ValidationResult = {
|
|
||||||
valid: true,
|
|
||||||
issues: [],
|
|
||||||
suggestions: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseValidateDiagramOptions {
|
|
||||||
onSuccess?: (result: ValidationResult) => void
|
|
||||||
onError?: (error: Error) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track pending validation promises for imperative API
|
|
||||||
type PendingValidation = {
|
|
||||||
resolve: (result: ValidationResult) => void
|
|
||||||
reject: (error: Error) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|
||||||
const { onSuccess, onError } = options
|
|
||||||
const pendingValidationRef = useRef<PendingValidation | null>(null)
|
|
||||||
|
|
||||||
const { object, submit, isLoading, error, stop } = useObject({
|
|
||||||
api: getApiEndpoint("/api/validate-diagram"),
|
|
||||||
schema: ValidationResultSchema,
|
|
||||||
onFinish: ({
|
|
||||||
object,
|
|
||||||
error: finishError,
|
|
||||||
}: {
|
|
||||||
object: ValidationResult | undefined
|
|
||||||
error: Error | undefined
|
|
||||||
}) => {
|
|
||||||
if (finishError) {
|
|
||||||
console.error(
|
|
||||||
"[useValidateDiagram] Validation error:",
|
|
||||||
finishError,
|
|
||||||
)
|
|
||||||
onError?.(finishError)
|
|
||||||
pendingValidationRef.current?.reject(finishError)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (object) {
|
|
||||||
const result = object as ValidationResult
|
|
||||||
onSuccess?.(result)
|
|
||||||
pendingValidationRef.current?.resolve(result)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
console.error("[useValidateDiagram] Stream error:", err)
|
|
||||||
onError?.(err)
|
|
||||||
pendingValidationRef.current?.reject(err)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a diagram image.
|
|
||||||
* Returns a promise that resolves with the validation result.
|
|
||||||
*/
|
|
||||||
const validate = useCallback(
|
|
||||||
async (
|
|
||||||
imageData: string,
|
|
||||||
sessionId?: string,
|
|
||||||
): Promise<ValidationResult> => {
|
|
||||||
// Reject any pending validation to prevent promise leaks
|
|
||||||
if (pendingValidationRef.current) {
|
|
||||||
pendingValidationRef.current.reject(
|
|
||||||
new Error("Validation superseded by new request"),
|
|
||||||
)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Store the promise handlers
|
|
||||||
pendingValidationRef.current = { resolve, reject }
|
|
||||||
|
|
||||||
// Submit the validation request
|
|
||||||
submit({ imageData, sessionId })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[submit],
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate with fallback - returns default valid result on error.
|
|
||||||
* Use this to avoid blocking the user on validation failures.
|
|
||||||
*/
|
|
||||||
const validateWithFallback = useCallback(
|
|
||||||
async (
|
|
||||||
imageData: string,
|
|
||||||
sessionId?: string,
|
|
||||||
): Promise<ValidationResult> => {
|
|
||||||
try {
|
|
||||||
return await validate(imageData, sessionId)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[useValidateDiagram] Validation failed, using fallback:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
return DEFAULT_VALID_RESULT
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[validate],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Validation functions
|
|
||||||
validate,
|
|
||||||
validateWithFallback,
|
|
||||||
stop,
|
|
||||||
|
|
||||||
// State
|
|
||||||
isValidating: isLoading,
|
|
||||||
partialResult: object as ValidationResult | undefined,
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
lib/ai-config.ts
Normal file
26
lib/ai-config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { STORAGE_KEYS } from "./storage"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI configuration from localStorage.
|
||||||
|
* Returns API keys and settings for custom AI providers.
|
||||||
|
* Used to override server defaults when user provides their own API key.
|
||||||
|
*/
|
||||||
|
export function getAIConfig() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return {
|
||||||
|
accessCode: "",
|
||||||
|
aiProvider: "",
|
||||||
|
aiBaseUrl: "",
|
||||||
|
aiApiKey: "",
|
||||||
|
aiModel: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessCode: localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||||
|
aiProvider: localStorage.getItem(STORAGE_KEYS.aiProvider) || "",
|
||||||
|
aiBaseUrl: localStorage.getItem(STORAGE_KEYS.aiBaseUrl) || "",
|
||||||
|
aiApiKey: localStorage.getItem(STORAGE_KEYS.aiApiKey) || "",
|
||||||
|
aiModel: localStorage.getItem(STORAGE_KEYS.aiModel) || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,24 @@ import { azure, createAzure } from "@ai-sdk/azure"
|
|||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
import { createGateway, gateway } from "@ai-sdk/gateway"
|
import { createGateway, gateway } from "@ai-sdk/gateway"
|
||||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
|
||||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
export type { ProviderName }
|
export type ProviderName =
|
||||||
|
| "bedrock"
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "google"
|
||||||
|
| "azure"
|
||||||
|
| "ollama"
|
||||||
|
| "openrouter"
|
||||||
|
| "deepseek"
|
||||||
|
| "siliconflow"
|
||||||
|
| "sglang"
|
||||||
|
| "gateway"
|
||||||
|
| "doubao"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
@@ -30,13 +40,6 @@ export interface ClientOverrides {
|
|||||||
awsSecretAccessKey?: string | null
|
awsSecretAccessKey?: string | null
|
||||||
awsRegion?: string | null
|
awsRegion?: string | null
|
||||||
awsSessionToken?: string | null
|
awsSessionToken?: string | null
|
||||||
// Vertex AI config
|
|
||||||
vertexApiKey?: string | null // Express Mode API key
|
|
||||||
// Custom headers (e.g., for EdgeOne cookie auth)
|
|
||||||
headers?: Record<string, string>
|
|
||||||
// Custom env var names for server models (allows multiple API keys per provider)
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers that can be used with client-provided API keys
|
// Providers that can be used with client-provided API keys
|
||||||
@@ -44,7 +47,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"google",
|
"google",
|
||||||
"vertexai",
|
|
||||||
"azure",
|
"azure",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
@@ -52,9 +54,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"siliconflow",
|
"siliconflow",
|
||||||
"sglang",
|
"sglang",
|
||||||
"gateway",
|
"gateway",
|
||||||
"edgeone",
|
|
||||||
"doubao",
|
"doubao",
|
||||||
"modelscope",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -69,62 +69,6 @@ const ANTHROPIC_BETA_HEADERS = {
|
|||||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve baseURL based on whether user is providing their own API key.
|
|
||||||
* When user provides their own API key, we should NOT fall back to server's
|
|
||||||
* baseURL environment variable - user credentials should only be sent to
|
|
||||||
* user-specified endpoints or official provider endpoints.
|
|
||||||
*
|
|
||||||
* @param userApiKey - User-provided API key (if any)
|
|
||||||
* @param userBaseUrl - User-provided base URL (if any)
|
|
||||||
* @param serverBaseUrl - Server's base URL from environment variable
|
|
||||||
* @param defaultBaseUrl - Provider's official/default base URL (optional)
|
|
||||||
* @returns The resolved base URL to use
|
|
||||||
*/
|
|
||||||
export function resolveBaseURL(
|
|
||||||
userApiKey: string | null | undefined,
|
|
||||||
userBaseUrl: string | null | undefined,
|
|
||||||
serverBaseUrl: string | undefined,
|
|
||||||
defaultBaseUrl?: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (userApiKey) {
|
|
||||||
// User provides their own API key - only use user's baseUrl or default
|
|
||||||
return userBaseUrl || defaultBaseUrl || undefined
|
|
||||||
}
|
|
||||||
// No user API key - fall back to server config
|
|
||||||
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve API key from custom env var name or default env var.
|
|
||||||
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
|
|
||||||
*
|
|
||||||
* Priority:
|
|
||||||
* 1. User-provided API key (overrides.apiKey)
|
|
||||||
* 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
|
|
||||||
* 3. Default provider env var (defaultEnvVar)
|
|
||||||
*/
|
|
||||||
function resolveApiKey(
|
|
||||||
overrides: ClientOverrides | undefined,
|
|
||||||
defaultEnvVar: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (overrides?.apiKey) return overrides.apiKey
|
|
||||||
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
|
|
||||||
return process.env[defaultEnvVar]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve base URL from custom env var name or default env var.
|
|
||||||
* Supports multiple base URLs per provider via ai-models.json baseUrlEnv config.
|
|
||||||
*/
|
|
||||||
function resolveBaseUrlEnv(
|
|
||||||
overrides: ClientOverrides | undefined,
|
|
||||||
defaultEnvVar: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv]
|
|
||||||
return process.env[defaultEnvVar]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse integer from environment variable with validation
|
* Safely parse integer from environment variable with validation
|
||||||
*/
|
*/
|
||||||
@@ -159,8 +103,6 @@ function parseIntSafe(
|
|||||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||||
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
|
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
|
||||||
* - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000)
|
|
||||||
* - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high)
|
|
||||||
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
|
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
|
||||||
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
|
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
|
||||||
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
|
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
|
||||||
@@ -325,46 +267,7 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vertexai": {
|
|
||||||
const thinkingBudget = parseIntSafe(
|
|
||||||
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
|
|
||||||
"GOOGLE_VERTEX_THINKING_BUDGET",
|
|
||||||
1024,
|
|
||||||
100000,
|
|
||||||
)
|
|
||||||
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
|
|
||||||
|
|
||||||
if (
|
|
||||||
modelId &&
|
|
||||||
(modelId.includes("gemini-2") ||
|
|
||||||
modelId.includes("gemini-3") ||
|
|
||||||
modelId.includes("gemini2") ||
|
|
||||||
modelId.includes("gemini3"))
|
|
||||||
) {
|
|
||||||
const thinkingConfig: Record<string, any> = {
|
|
||||||
includeThoughts: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGemini3 =
|
|
||||||
modelId?.includes("gemini-3") ||
|
|
||||||
modelId?.includes("gemini3")
|
|
||||||
const isGemini25 =
|
|
||||||
modelId?.includes("2.5") || modelId?.includes("2-5")
|
|
||||||
|
|
||||||
if (isGemini3 && thinkingLevel) {
|
|
||||||
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
|
|
||||||
thinkingConfig.thinkingLevel = thinkingLevel as
|
|
||||||
| "minimal"
|
|
||||||
| "low"
|
|
||||||
| "medium"
|
|
||||||
| "high"
|
|
||||||
} else if (isGemini25 && thinkingBudget) {
|
|
||||||
thinkingConfig.thinkingBudget = thinkingBudget
|
|
||||||
}
|
|
||||||
options.google = { thinkingConfig }
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
|
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
|
||||||
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
|
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
|
||||||
@@ -446,7 +349,6 @@ function buildProviderOptions(
|
|||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "sglang":
|
case "sglang":
|
||||||
case "gateway":
|
case "gateway":
|
||||||
case "modelscope":
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
@@ -466,7 +368,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
vertexai: "GOOGLE_VERTEX_API_KEY",
|
|
||||||
azure: "AZURE_API_KEY",
|
azure: "AZURE_API_KEY",
|
||||||
ollama: null, // No credentials needed for local Ollama
|
ollama: null, // No credentials needed for local Ollama
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
@@ -474,9 +375,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
siliconflow: "SILICONFLOW_API_KEY",
|
siliconflow: "SILICONFLOW_API_KEY",
|
||||||
sglang: "SGLANG_API_KEY",
|
sglang: "SGLANG_API_KEY",
|
||||||
gateway: "AI_GATEWAY_API_KEY",
|
gateway: "AI_GATEWAY_API_KEY",
|
||||||
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
|
||||||
doubao: "DOUBAO_API_KEY",
|
doubao: "DOUBAO_API_KEY",
|
||||||
modelscope: "MODELSCOPE_API_KEY",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -514,15 +413,9 @@ function detectProvider(): ProviderName | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that required API keys are present for the selected provider
|
* Validate that required API keys are present for the selected provider
|
||||||
* @param provider - The provider to validate
|
|
||||||
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
|
|
||||||
*/
|
*/
|
||||||
function validateProviderCredentials(
|
function validateProviderCredentials(provider: ProviderName): void {
|
||||||
provider: ProviderName,
|
const requiredVar = PROVIDER_ENV_VARS[provider]
|
||||||
customApiKeyEnv?: string,
|
|
||||||
): void {
|
|
||||||
// Use custom env var name if provided, otherwise use default
|
|
||||||
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
|
||||||
if (requiredVar && !process.env[requiredVar]) {
|
if (requiredVar && !process.env[requiredVar]) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||||
@@ -547,7 +440,7 @@ function validateProviderCredentials(
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
|
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
|
||||||
* - AI_MODEL: The model ID/name for the selected provider
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -562,23 +455,15 @@ function validateProviderCredentials(
|
|||||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1)
|
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
||||||
* - SGLANG_API_KEY: SGLang API key
|
* - SGLANG_API_KEY: SGLang API key
|
||||||
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
||||||
* - MODELSCOPE_API_KEY: ModelScope API key
|
|
||||||
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
|
|
||||||
*/
|
*/
|
||||||
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||||
// If a custom baseUrl is provided, an API key MUST also be provided.
|
// If a custom baseUrl is provided, an API key MUST also be provided.
|
||||||
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
||||||
// Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime)
|
if (overrides?.baseUrl && !overrides?.apiKey) {
|
||||||
if (
|
|
||||||
overrides?.baseUrl &&
|
|
||||||
!overrides?.apiKey &&
|
|
||||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
|
||||||
overrides?.provider !== "edgeone"
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`API key is required when using a custom base URL. ` +
|
`API key is required when using a custom base URL. ` +
|
||||||
`Please provide your own API key in Settings.`,
|
`Please provide your own API key in Settings.`,
|
||||||
@@ -586,11 +471,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if client is providing their own provider override
|
// Check if client is providing their own provider override
|
||||||
const isClientOverride = !!(
|
const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
|
||||||
overrides?.provider &&
|
|
||||||
(overrides?.apiKey ||
|
|
||||||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use client override if provided, otherwise fall back to env vars
|
// Use client override if provided, otherwise fall back to env vars
|
||||||
const modelId = overrides?.modelId || process.env.AI_MODEL
|
const modelId = overrides?.modelId || process.env.AI_MODEL
|
||||||
@@ -646,7 +527,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
`- SGLANG_API_KEY for SGLang\n` +
|
`- SGLANG_API_KEY for SGLang\n` +
|
||||||
`- MODELSCOPE_API_KEY for ModelScope\n` +
|
|
||||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -660,7 +540,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
// Only validate server credentials if client isn't providing their own API key
|
// Only validate server credentials if client isn't providing their own API key
|
||||||
if (!isClientOverride) {
|
if (!isClientOverride) {
|
||||||
validateProviderCredentials(provider, overrides?.apiKeyEnv)
|
validateProviderCredentials(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
||||||
@@ -683,8 +563,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
const bedrockProvider = hasClientCredentials
|
const bedrockProvider = hasClientCredentials
|
||||||
? createAmazonBedrock({
|
? createAmazonBedrock({
|
||||||
region: bedrockRegion,
|
region: bedrockRegion,
|
||||||
accessKeyId: overrides.awsAccessKeyId as string,
|
accessKeyId: overrides.awsAccessKeyId!,
|
||||||
secretAccessKey: overrides.awsSecretAccessKey as string,
|
secretAccessKey: overrides.awsSecretAccessKey!,
|
||||||
...(overrides?.awsSessionToken && {
|
...(overrides?.awsSessionToken && {
|
||||||
sessionToken: overrides.awsSessionToken,
|
sessionToken: overrides.awsSessionToken,
|
||||||
}),
|
}),
|
||||||
@@ -710,16 +590,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "openai": {
|
case "openai": {
|
||||||
const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
||||||
overrides,
|
|
||||||
"OPENAI_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
if (baseURL) {
|
if (baseURL) {
|
||||||
// Custom base URL = third-party proxy, use Chat Completions API
|
// Custom base URL = third-party proxy, use Chat Completions API
|
||||||
// for compatibility (most proxies don't support /responses endpoint)
|
// for compatibility (most proxies don't support /responses endpoint)
|
||||||
@@ -737,17 +609,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "anthropic": {
|
case "anthropic": {
|
||||||
const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl ||
|
||||||
"ANTHROPIC_BASE_URL",
|
process.env.ANTHROPIC_BASE_URL ||
|
||||||
)
|
"https://api.anthropic.com/v1"
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://api.anthropic.com/v1",
|
|
||||||
)
|
|
||||||
const customProvider = createAnthropic({
|
const customProvider = createAnthropic({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -760,19 +626,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "google": {
|
case "google": {
|
||||||
const apiKey = resolveApiKey(
|
const apiKey =
|
||||||
overrides,
|
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
||||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
|
||||||
)
|
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
|
||||||
overrides,
|
|
||||||
"GOOGLE_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customGoogle = createGoogleGenerativeAI({
|
const customGoogle = createGoogleGenerativeAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -784,42 +640,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vertexai": {
|
|
||||||
// Express Mode: Use API key for authentication
|
|
||||||
const vertexApiKey =
|
|
||||||
overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
|
|
||||||
|
|
||||||
if (!vertexApiKey) {
|
|
||||||
throw new Error(
|
|
||||||
"Vertex AI requires an API key for Express Mode. " +
|
|
||||||
"Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support custom base URL from env or client override
|
|
||||||
const baseURL =
|
|
||||||
overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL
|
|
||||||
|
|
||||||
const vertexProvider = createVertex({
|
|
||||||
apiKey: vertexApiKey,
|
|
||||||
...(baseURL && { baseURL }),
|
|
||||||
})
|
|
||||||
model = vertexProvider(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure": {
|
case "azure": {
|
||||||
const apiKey = resolveApiKey(overrides, "AZURE_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL")
|
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
|
||||||
const baseURL = resolveBaseURL(
|
const resourceName = process.env.AZURE_RESOURCE_NAME
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
// Only use server's resourceName if user is NOT providing their own API key
|
|
||||||
const resourceName = overrides?.apiKey
|
|
||||||
? undefined
|
|
||||||
: process.env.AZURE_RESOURCE_NAME
|
|
||||||
// Azure requires either baseURL or resourceName to construct the endpoint
|
// Azure requires either baseURL or resourceName to construct the endpoint
|
||||||
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
|
||||||
if (baseURL || resourceName || overrides?.apiKey) {
|
if (baseURL || resourceName || overrides?.apiKey) {
|
||||||
@@ -848,16 +673,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case "openrouter": {
|
case "openrouter": {
|
||||||
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
|
||||||
"OPENROUTER_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
const openrouter = createOpenRouter({
|
const openrouter = createOpenRouter({
|
||||||
apiKey,
|
apiKey,
|
||||||
...(baseURL && { baseURL }),
|
...(baseURL && { baseURL }),
|
||||||
@@ -867,16 +685,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
|
||||||
overrides,
|
|
||||||
"DEEPSEEK_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
const customDeepSeek = createDeepSeek({
|
const customDeepSeek = createDeepSeek({
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -890,17 +700,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "siliconflow": {
|
case "siliconflow": {
|
||||||
const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl ||
|
||||||
"SILICONFLOW_BASE_URL",
|
process.env.SILICONFLOW_BASE_URL ||
|
||||||
)
|
"https://api.siliconflow.com/v1"
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://api.siliconflow.cn/v1",
|
|
||||||
)
|
|
||||||
const siliconflowProvider = createOpenAI({
|
const siliconflowProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -910,20 +714,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "sglang": {
|
case "sglang": {
|
||||||
const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
|
||||||
overrides,
|
|
||||||
"SGLANG_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
|
|
||||||
const sglangProvider = createOpenAI({
|
const sglangProvider = createOpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
...(baseURL && { baseURL }),
|
baseURL,
|
||||||
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
||||||
fetch: async (url, options) => {
|
fetch: async (url, options) => {
|
||||||
const response = await fetch(url, options)
|
const response = await fetch(url, options)
|
||||||
@@ -980,7 +776,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`data: ${JSON.stringify(data)}\n\n`,
|
`data: ${JSON.stringify(data)}\n\n`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (_e) {
|
} catch (e) {
|
||||||
// If parsing fails, forward the original message to avoid breaking the stream.
|
// If parsing fails, forward the original message to avoid breaking the stream.
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
@@ -1027,16 +823,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
// Vercel AI Gateway - unified access to multiple AI providers
|
// Vercel AI Gateway - unified access to multiple AI providers
|
||||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
// See: https://vercel.com/ai-gateway
|
// See: https://vercel.com/ai-gateway
|
||||||
const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
|
||||||
"AI_GATEWAY_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
)
|
|
||||||
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
||||||
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
||||||
if (baseURL || overrides?.apiKey) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
@@ -1051,77 +840,23 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "edgeone": {
|
|
||||||
// EdgeOne Pages Edge AI - uses OpenAI-compatible API
|
|
||||||
// AI SDK appends /chat/completions to baseURL
|
|
||||||
// /api/edgeai + /chat/completions = /api/edgeai/chat/completions
|
|
||||||
const baseURL = overrides?.baseUrl || "/api/edgeai"
|
|
||||||
const edgeoneProvider = createOpenAI({
|
|
||||||
apiKey: "edgeone", // Dummy key - EdgeOne doesn't require API key
|
|
||||||
baseURL,
|
|
||||||
// Pass cookies for EdgeOne Pages authentication (eo_token, eo_time)
|
|
||||||
...(overrides?.headers && { headers: overrides.headers }),
|
|
||||||
})
|
|
||||||
model = edgeoneProvider.chat(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY")
|
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const baseURL =
|
||||||
overrides,
|
overrides?.baseUrl ||
|
||||||
"DOUBAO_BASE_URL",
|
process.env.DOUBAO_BASE_URL ||
|
||||||
)
|
"https://ark.cn-beijing.volces.com/api/v3"
|
||||||
const baseURL = resolveBaseURL(
|
const doubaoProvider = createDeepSeek({
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://ark.cn-beijing.volces.com/api/v3",
|
|
||||||
)
|
|
||||||
const lowerModelId = modelId.toLowerCase()
|
|
||||||
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
|
||||||
if (
|
|
||||||
lowerModelId.includes("deepseek") ||
|
|
||||||
lowerModelId.includes("kimi")
|
|
||||||
) {
|
|
||||||
const doubaoProvider = createDeepSeek({
|
|
||||||
apiKey,
|
|
||||||
baseURL,
|
|
||||||
})
|
|
||||||
model = doubaoProvider(modelId)
|
|
||||||
} else {
|
|
||||||
const doubaoProvider = createOpenAI({
|
|
||||||
apiKey,
|
|
||||||
baseURL,
|
|
||||||
})
|
|
||||||
model = doubaoProvider.chat(modelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "modelscope": {
|
|
||||||
const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY")
|
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
|
||||||
overrides,
|
|
||||||
"MODELSCOPE_BASE_URL",
|
|
||||||
)
|
|
||||||
const baseURL = resolveBaseURL(
|
|
||||||
overrides?.apiKey,
|
|
||||||
overrides?.baseUrl,
|
|
||||||
serverBaseUrl,
|
|
||||||
"https://api-inference.modelscope.cn/v1",
|
|
||||||
)
|
|
||||||
const modelscopeProvider = createOpenAI({
|
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
})
|
})
|
||||||
model = modelscopeProvider.chat(modelId)
|
model = doubaoProvider(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, doubao`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,58 +881,3 @@ export function supportsPromptCaching(modelId: string): boolean {
|
|||||||
modelId.startsWith("eu.anthropic")
|
modelId.startsWith("eu.anthropic")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model supports image/vision input.
|
|
||||||
* Some models silently drop image parts without error (AI SDK warning only).
|
|
||||||
*/
|
|
||||||
export function supportsImageInput(modelId: string): boolean {
|
|
||||||
const lowerModelId = modelId.toLowerCase()
|
|
||||||
|
|
||||||
// Helper to check if model has vision capability indicator
|
|
||||||
const hasVisionIndicator =
|
|
||||||
lowerModelId.includes("vision") || lowerModelId.includes("vl")
|
|
||||||
|
|
||||||
// Models that DON'T support image/vision input (unless vision variant)
|
|
||||||
// Kimi K2 models don't support images
|
|
||||||
if (lowerModelId.includes("kimi") && !hasVisionIndicator) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepSeek text models (not vision variants)
|
|
||||||
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Qwen text models (not vision variants like qwen-vl)
|
|
||||||
if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: assume model supports images
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the AI model for diagram validation.
|
|
||||||
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
|
||||||
* Throws if the model doesn't support image input.
|
|
||||||
*/
|
|
||||||
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
|
||||||
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
|
|
||||||
|
|
||||||
if (!modelId) {
|
|
||||||
throw new Error(
|
|
||||||
"No validation model configured. Set VALIDATION_MODEL or AI_MODEL.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!supportsImageInput(modelId)) {
|
|
||||||
throw new Error(
|
|
||||||
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { model } = getAIModel({ modelId })
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
// Shared helper functions for chat route
|
|
||||||
// Exported for testing
|
|
||||||
|
|
||||||
// File upload limits (must match client-side)
|
|
||||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
|
||||||
export const MAX_FILES = 5
|
|
||||||
|
|
||||||
// Helper function to validate file parts in messages
|
|
||||||
export function validateFileParts(messages: any[]): {
|
|
||||||
valid: boolean
|
|
||||||
error?: string
|
|
||||||
} {
|
|
||||||
const lastMessage = messages[messages.length - 1]
|
|
||||||
const fileParts =
|
|
||||||
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
|
||||||
|
|
||||||
if (fileParts.length > MAX_FILES) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const filePart of fileParts) {
|
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
|
||||||
if (filePart.url?.startsWith("data:")) {
|
|
||||||
const base64Data = filePart.url.split(",")[1]
|
|
||||||
if (base64Data) {
|
|
||||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
|
||||||
if (sizeInBytes > MAX_FILE_SIZE) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to check if diagram is minimal/empty
|
|
||||||
export function isMinimalDiagram(xml: string): boolean {
|
|
||||||
const stripped = xml.replace(/\s/g, "")
|
|
||||||
return !stripped.includes('id="2"')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to replace historical tool call XML with placeholders
|
|
||||||
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
|
||||||
// Also fixes invalid/undefined inputs from interrupted streaming
|
|
||||||
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
|
||||||
return messages.map((msg) => {
|
|
||||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
const replacedContent = msg.content
|
|
||||||
.map((part: any) => {
|
|
||||||
if (part.type === "tool-call") {
|
|
||||||
const toolName = part.toolName
|
|
||||||
// Fix invalid/undefined inputs from interrupted streaming
|
|
||||||
if (
|
|
||||||
!part.input ||
|
|
||||||
typeof part.input !== "object" ||
|
|
||||||
Object.keys(part.input).length === 0
|
|
||||||
) {
|
|
||||||
// Skip tool calls with invalid inputs entirely
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
toolName === "display_diagram" ||
|
|
||||||
toolName === "edit_diagram"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
input: {
|
|
||||||
placeholder:
|
|
||||||
"[XML content replaced - see current diagram XML in system context]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return part
|
|
||||||
})
|
|
||||||
.filter(Boolean) // Remove null entries (invalid tool calls)
|
|
||||||
return { ...msg, content: replacedContent }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types and utilities for VLM-based diagram validation.
|
|
||||||
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Re-export types from the schema file (single source of truth)
|
|
||||||
export type { ValidationIssue, ValidationResult } from "./validation-schema"
|
|
||||||
|
|
||||||
import type { ValidationResult } from "./validation-schema"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format validation feedback for display to the AI model.
|
|
||||||
* This creates a human-readable error message that guides the AI to fix issues.
|
|
||||||
*
|
|
||||||
* @param result - The validation result from VLM
|
|
||||||
* @returns Formatted string for tool error output
|
|
||||||
*/
|
|
||||||
export function formatValidationFeedback(result: ValidationResult): string {
|
|
||||||
// If validation passed with no issues, return empty string
|
|
||||||
if (result.valid && result.issues.length === 0) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = []
|
|
||||||
|
|
||||||
lines.push("DIAGRAM VISUAL VALIDATION FAILED")
|
|
||||||
lines.push("")
|
|
||||||
|
|
||||||
// Group issues by severity
|
|
||||||
const criticalIssues = result.issues.filter(
|
|
||||||
(i) => i.severity === "critical",
|
|
||||||
)
|
|
||||||
const warnings = result.issues.filter((i) => i.severity === "warning")
|
|
||||||
|
|
||||||
if (criticalIssues.length > 0) {
|
|
||||||
lines.push("Critical Issues (must fix):")
|
|
||||||
for (const issue of criticalIssues) {
|
|
||||||
lines.push(` - [${issue.type}] ${issue.description}`)
|
|
||||||
}
|
|
||||||
lines.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
|
||||||
lines.push("Warnings:")
|
|
||||||
for (const issue of warnings) {
|
|
||||||
lines.push(` - [${issue.type}] ${issue.description}`)
|
|
||||||
}
|
|
||||||
lines.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.suggestions.length > 0) {
|
|
||||||
lines.push("Suggestions to fix:")
|
|
||||||
for (const suggestion of result.suggestions) {
|
|
||||||
lines.push(` - ${suggestion}`)
|
|
||||||
}
|
|
||||||
lines.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(
|
|
||||||
"Please regenerate the diagram with corrected layout to fix these visual issues.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
@@ -28,8 +28,7 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow"
|
||||||
"modelscope": "ModelScope"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
@@ -47,13 +46,11 @@
|
|||||||
"copyResponse": "Copy response",
|
"copyResponse": "Copy response",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"failedToCopy": "Failed to copy",
|
"failedToCopy": "Failed to copy",
|
||||||
"failedToCopyDetail": "Failed to copy message. Please copy manually or check clipboard permissions.",
|
|
||||||
"goodResponse": "Good response",
|
"goodResponse": "Good response",
|
||||||
"badResponse": "Bad response",
|
"badResponse": "Bad response",
|
||||||
"clickToEdit": "Click to edit",
|
"clickToEdit": "Click to edit",
|
||||||
"editMessage": "Edit message",
|
"editMessage": "Edit message",
|
||||||
"saveAndSubmit": "Save & Submit",
|
"saveAndSubmit": "Save & Submit"
|
||||||
"ExtractURL": "Extract from URL"
|
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "Create diagrams with AI",
|
"title": "Create diagrams with AI",
|
||||||
@@ -100,26 +97,14 @@
|
|||||||
"switchTo": "Switch to",
|
"switchTo": "Switch to",
|
||||||
"minimal": "Minimal",
|
"minimal": "Minimal",
|
||||||
"sketch": "Sketch",
|
"sketch": "Sketch",
|
||||||
|
"closeProtection": "Close Protection",
|
||||||
|
"closeProtectionDescription": "Show confirmation when leaving the page.",
|
||||||
"diagramStyle": "Diagram Style",
|
"diagramStyle": "Diagram Style",
|
||||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||||
"sendShortcut": "Send Shortcut",
|
|
||||||
"sendShortcutDescription": "Choose how to send messages.",
|
|
||||||
"enterToSend": "Enter to send",
|
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
|
|
||||||
"diagramActions": "Diagram Actions",
|
"diagramActions": "Diagram Actions",
|
||||||
"diagramActionsDescription": "Manage diagram history and exports",
|
"diagramActionsDescription": "Manage diagram history and exports",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"download": "Download",
|
"download": "Download"
|
||||||
"proxy": "Proxy Settings",
|
|
||||||
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
|
|
||||||
"httpProxy": "HTTP Proxy",
|
|
||||||
"httpsProxy": "HTTPS Proxy",
|
|
||||||
"applyProxy": "Apply",
|
|
||||||
"proxyApplied": "Proxy settings applied",
|
|
||||||
"diagramValidation": "Diagram Validation (Experimental)",
|
|
||||||
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"disabled": "Disabled"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "Save Diagram",
|
"title": "Save Diagram",
|
||||||
@@ -131,8 +116,7 @@
|
|||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG Image",
|
"png": "PNG Image",
|
||||||
"svg": "SVG Image"
|
"svg": "SVG Image"
|
||||||
},
|
}
|
||||||
"savedSuccessfully": "Saved successfully!"
|
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Diagram History",
|
"title": "Diagram History",
|
||||||
@@ -157,7 +141,6 @@
|
|||||||
"invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.",
|
"invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.",
|
||||||
"networkError": "Network error. Please check your connection.",
|
"networkError": "Network error. Please check your connection.",
|
||||||
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
|
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
|
||||||
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
|
|
||||||
"validationFailed": "Diagram validation failed. Please try regenerating.",
|
"validationFailed": "Diagram validation failed. Please try regenerating.",
|
||||||
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
|
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
|
||||||
"failedToProcess": "Failed to process diagram. Please try regenerating.",
|
"failedToProcess": "Failed to process diagram. Please try regenerating.",
|
||||||
@@ -166,9 +149,7 @@
|
|||||||
"failedToRestore": "Failed to restore from localStorage",
|
"failedToRestore": "Failed to restore from localStorage",
|
||||||
"failedToPersist": "Failed to persist state before unload",
|
"failedToPersist": "Failed to persist state before unload",
|
||||||
"failedToExport": "Error fetching chart data",
|
"failedToExport": "Error fetching chart data",
|
||||||
"failedToLoadExample": "Error loading example image",
|
"failedToLoadExample": "Error loading example image"
|
||||||
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
|
|
||||||
"storageUpdateFailed": "Chat cleared but browser storage could not be updated"
|
|
||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "Daily Quota Reached",
|
"dailyLimit": "Daily Quota Reached",
|
||||||
@@ -200,35 +181,11 @@
|
|||||||
"chars": "chars",
|
"chars": "chars",
|
||||||
"removeFile": "Remove file"
|
"removeFile": "Remove file"
|
||||||
},
|
},
|
||||||
"url": {
|
|
||||||
"title": "Extract Content from URL",
|
|
||||||
"description": "Paste a URL to extract and analyze its content",
|
|
||||||
"Extracting": "Extracting...",
|
|
||||||
"extract": "Extract",
|
|
||||||
"Cancel": "Cancel",
|
|
||||||
"enterUrl": "Please enter a URL",
|
|
||||||
"invalidFormat": "Invalid URL format"
|
|
||||||
},
|
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "Thinking...",
|
"thinking": "Thinking...",
|
||||||
"thoughtFor": "Thought for {duration} seconds",
|
"thoughtFor": "Thought for {duration} seconds",
|
||||||
"thoughtBrief": "Thought for a few seconds"
|
"thoughtBrief": "Thought for a few seconds"
|
||||||
},
|
},
|
||||||
"dev": {
|
|
||||||
"title": "Dev: XML Streaming Simulator",
|
|
||||||
"preset": "Preset:",
|
|
||||||
"selectPreset": "Select a preset...",
|
|
||||||
"clear": "Clear",
|
|
||||||
"placeholder": "Paste mxCell XML here or select a preset...",
|
|
||||||
"interval": "Interval:",
|
|
||||||
"chars": "Chars:",
|
|
||||||
"streaming": "Streaming...",
|
|
||||||
"simulate": "Simulate",
|
|
||||||
"stop": "Stop",
|
|
||||||
"testQuotaToast": "Test Quota Toast",
|
|
||||||
"simulatingMessage": "[Dev] Simulating XML streaming",
|
|
||||||
"successMessage": "Successfully displayed the diagram."
|
|
||||||
},
|
|
||||||
"about": {
|
"about": {
|
||||||
"modelChange": "Model Change & Usage Limits",
|
"modelChange": "Model Change & Usage Limits",
|
||||||
"walletCrying": "(Or: Why My Wallet is Crying)",
|
"walletCrying": "(Or: Why My Wallet is Crying)",
|
||||||
@@ -236,40 +193,6 @@
|
|||||||
"contactMe": "Contact Me",
|
"contactMe": "Contact Me",
|
||||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
||||||
},
|
},
|
||||||
"sessionHistory": {
|
|
||||||
"tooltip": "Chat History",
|
|
||||||
"newChat": "New Chat",
|
|
||||||
"empty": "No chat history yet",
|
|
||||||
"emptyHint": "Start a conversation to begin",
|
|
||||||
"today": "Today",
|
|
||||||
"yesterday": "Yesterday",
|
|
||||||
"thisWeek": "This Week",
|
|
||||||
"earlier": "Earlier",
|
|
||||||
"deleteTitle": "Delete this chat?",
|
|
||||||
"deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.",
|
|
||||||
"recentChats": "Recent Chats",
|
|
||||||
"justNow": "Just now",
|
|
||||||
"searchPlaceholder": "Search chats...",
|
|
||||||
"noResults": "No chats found"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"title": "Validate Diagram",
|
|
||||||
"capturing": "Capturing",
|
|
||||||
"validating": "Validating",
|
|
||||||
"validatingWithAttempt": "Validating ({attempt}/{max})",
|
|
||||||
"valid": "Valid",
|
|
||||||
"validWithWarnings": "Valid with Warnings",
|
|
||||||
"issuesFound": "Issues Found",
|
|
||||||
"error": "Error",
|
|
||||||
"skipped": "Skipped",
|
|
||||||
"capturedScreenshot": "Captured Screenshot:",
|
|
||||||
"issuesFoundLabel": "Issues Found:",
|
|
||||||
"suggestions": "Suggestions:",
|
|
||||||
"passedValidation": "Diagram passed visual validation - no issues detected.",
|
|
||||||
"improvementRequested": "Improvement requested - check the new diagram below",
|
|
||||||
"improveWithSuggestions": "Improve with Suggestions",
|
|
||||||
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
|
|
||||||
},
|
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AI Model Configuration",
|
"title": "AI Model Configuration",
|
||||||
"description": "Configure multiple AI providers and models",
|
"description": "Configure multiple AI providers and models",
|
||||||
@@ -302,7 +225,6 @@
|
|||||||
"enterSecretKey": "Enter your secret access key",
|
"enterSecretKey": "Enter your secret access key",
|
||||||
"baseUrl": "Base URL",
|
"baseUrl": "Base URL",
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
|
||||||
"customEndpoint": "Custom endpoint URL",
|
"customEndpoint": "Custom endpoint URL",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"customModelId": "Custom model ID...",
|
"customModelId": "Custom model ID...",
|
||||||
@@ -328,13 +250,10 @@
|
|||||||
"noModelsFound": "No models found.",
|
"noModelsFound": "No models found.",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"serverDefault": "Server Default",
|
"serverDefault": "Server Default",
|
||||||
"serverModels": "Server Models",
|
|
||||||
"userModels": "User Models",
|
|
||||||
"configureModels": "Configure Models...",
|
"configureModels": "Configure Models...",
|
||||||
"onlyVerifiedShown": "Only verified models are shown",
|
"onlyVerifiedShown": "Only verified models are shown",
|
||||||
"showUnvalidatedModels": "Show unvalidated models",
|
"showUnvalidatedModels": "Show unvalidated models",
|
||||||
"allModelsShown": "All models are shown (including unvalidated)",
|
"allModelsShown": "All models are shown (including unvalidated)",
|
||||||
"unvalidatedModelWarning": "This model has not been validated",
|
"unvalidatedModelWarning": "This model has not been validated"
|
||||||
"serverDefaultModel": "Server default model"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow"
|
||||||
"modelscope": "ModelScope"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
@@ -47,13 +46,11 @@
|
|||||||
"copyResponse": "応答をコピー",
|
"copyResponse": "応答をコピー",
|
||||||
"copied": "コピーしました!",
|
"copied": "コピーしました!",
|
||||||
"failedToCopy": "コピーに失敗しました",
|
"failedToCopy": "コピーに失敗しました",
|
||||||
"failedToCopyDetail": "メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。",
|
|
||||||
"goodResponse": "良い応答",
|
"goodResponse": "良い応答",
|
||||||
"badResponse": "悪い応答",
|
"badResponse": "悪い応答",
|
||||||
"clickToEdit": "クリックして編集",
|
"clickToEdit": "クリックして編集",
|
||||||
"editMessage": "メッセージを編集",
|
"editMessage": "メッセージを編集",
|
||||||
"saveAndSubmit": "保存して送信",
|
"saveAndSubmit": "保存して送信"
|
||||||
"ExtractURL": "URLから抽出"
|
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "AI でダイアグラムを作成",
|
"title": "AI でダイアグラムを作成",
|
||||||
@@ -100,26 +97,14 @@
|
|||||||
"switchTo": "切り替え",
|
"switchTo": "切り替え",
|
||||||
"minimal": "ミニマル",
|
"minimal": "ミニマル",
|
||||||
"sketch": "スケッチ",
|
"sketch": "スケッチ",
|
||||||
|
"closeProtection": "ページ離脱確認",
|
||||||
|
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
|
||||||
"diagramStyle": "ダイアグラムスタイル",
|
"diagramStyle": "ダイアグラムスタイル",
|
||||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||||
"sendShortcut": "送信ショートカット",
|
|
||||||
"sendShortcutDescription": "メッセージの送信方法を選択します。",
|
|
||||||
"enterToSend": "Enterで送信",
|
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
|
|
||||||
"diagramActions": "ダイアグラム操作",
|
"diagramActions": "ダイアグラム操作",
|
||||||
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
||||||
"history": "履歴",
|
"history": "履歴",
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード"
|
||||||
"proxy": "プロキシ設定",
|
|
||||||
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
|
|
||||||
"httpProxy": "HTTP プロキシ",
|
|
||||||
"httpsProxy": "HTTPS プロキシ",
|
|
||||||
"applyProxy": "適用",
|
|
||||||
"proxyApplied": "プロキシ設定が適用されました",
|
|
||||||
"diagramValidation": "ダイアグラム検証(実験的)",
|
|
||||||
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
|
|
||||||
"enabled": "有効",
|
|
||||||
"disabled": "無効"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "ダイアグラムを保存",
|
"title": "ダイアグラムを保存",
|
||||||
@@ -131,8 +116,7 @@
|
|||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 画像",
|
"png": "PNG 画像",
|
||||||
"svg": "SVG 画像"
|
"svg": "SVG 画像"
|
||||||
},
|
}
|
||||||
"savedSuccessfully": "保存完了!"
|
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "ダイアグラム履歴",
|
"title": "ダイアグラム履歴",
|
||||||
@@ -157,7 +141,6 @@
|
|||||||
"invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。",
|
"invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。",
|
||||||
"networkError": "ネットワークエラー。接続を確認してください。",
|
"networkError": "ネットワークエラー。接続を確認してください。",
|
||||||
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
|
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
|
||||||
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
|
|
||||||
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
|
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
|
||||||
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
|
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
|
||||||
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
|
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
|
||||||
@@ -166,9 +149,7 @@
|
|||||||
"failedToRestore": "localStorage からの復元に失敗しました",
|
"failedToRestore": "localStorage からの復元に失敗しました",
|
||||||
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
|
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
|
||||||
"failedToExport": "チャートデータの取得エラー",
|
"failedToExport": "チャートデータの取得エラー",
|
||||||
"failedToLoadExample": "例の画像の読み込みエラー",
|
"failedToLoadExample": "例の画像の読み込みエラー"
|
||||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
|
||||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
|
|
||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "1日の割当量に達しました",
|
"dailyLimit": "1日の割当量に達しました",
|
||||||
@@ -200,35 +181,11 @@
|
|||||||
"chars": "文字",
|
"chars": "文字",
|
||||||
"removeFile": "ファイルを削除"
|
"removeFile": "ファイルを削除"
|
||||||
},
|
},
|
||||||
"url": {
|
|
||||||
"title": "URLからコンテンツを抽出",
|
|
||||||
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
|
|
||||||
"Extracting": "抽出中...",
|
|
||||||
"extract": "抽出",
|
|
||||||
"Cancel": "キャンセル",
|
|
||||||
"enterUrl": "URLを入力してください",
|
|
||||||
"invalidFormat": "無効なURL形式です"
|
|
||||||
},
|
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "考え中...",
|
"thinking": "考え中...",
|
||||||
"thoughtFor": "{duration} 秒考えました",
|
"thoughtFor": "{duration} 秒考えました",
|
||||||
"thoughtBrief": "数秒考えました"
|
"thoughtBrief": "数秒考えました"
|
||||||
},
|
},
|
||||||
"dev": {
|
|
||||||
"title": "開発:XMLストリーミングシミュレーター",
|
|
||||||
"preset": "プリセット:",
|
|
||||||
"selectPreset": "プリセットを選択...",
|
|
||||||
"clear": "クリア",
|
|
||||||
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
|
|
||||||
"interval": "間隔:",
|
|
||||||
"chars": "文字:",
|
|
||||||
"streaming": "ストリーミング中...",
|
|
||||||
"simulate": "シミュレート",
|
|
||||||
"stop": "停止",
|
|
||||||
"testQuotaToast": "クォータトーストをテスト",
|
|
||||||
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
|
|
||||||
"successMessage": "ダイアグラムの表示に成功しました。"
|
|
||||||
},
|
|
||||||
"about": {
|
"about": {
|
||||||
"modelChange": "モデル変更と利用制限について",
|
"modelChange": "モデル変更と利用制限について",
|
||||||
"walletCrying": "(別名:お財布が悲鳴を上げています)",
|
"walletCrying": "(別名:お財布が悲鳴を上げています)",
|
||||||
@@ -236,40 +193,6 @@
|
|||||||
"contactMe": "お問い合わせ",
|
"contactMe": "お問い合わせ",
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
||||||
},
|
},
|
||||||
"sessionHistory": {
|
|
||||||
"tooltip": "チャット履歴",
|
|
||||||
"newChat": "新しいチャット",
|
|
||||||
"empty": "チャット履歴はまだありません",
|
|
||||||
"emptyHint": "会話を始めてください",
|
|
||||||
"today": "今日",
|
|
||||||
"yesterday": "昨日",
|
|
||||||
"thisWeek": "今週",
|
|
||||||
"earlier": "それ以前",
|
|
||||||
"deleteTitle": "このチャットを削除しますか?",
|
|
||||||
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
|
|
||||||
"recentChats": "最近のチャット",
|
|
||||||
"justNow": "たった今",
|
|
||||||
"searchPlaceholder": "チャットを検索...",
|
|
||||||
"noResults": "チャットが見つかりません"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"title": "ダイアグラムを検証",
|
|
||||||
"capturing": "キャプチャ中",
|
|
||||||
"validating": "検証中",
|
|
||||||
"validatingWithAttempt": "検証中 ({attempt}/{max})",
|
|
||||||
"valid": "有効",
|
|
||||||
"validWithWarnings": "有効(警告あり)",
|
|
||||||
"issuesFound": "問題が見つかりました",
|
|
||||||
"error": "エラー",
|
|
||||||
"skipped": "スキップ",
|
|
||||||
"capturedScreenshot": "キャプチャした画像:",
|
|
||||||
"issuesFoundLabel": "検出された問題:",
|
|
||||||
"suggestions": "提案:",
|
|
||||||
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
|
|
||||||
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
|
|
||||||
"improveWithSuggestions": "提案で改善",
|
|
||||||
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
|
|
||||||
},
|
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AIモデル設定",
|
"title": "AIモデル設定",
|
||||||
"description": "複数のAIプロバイダーとモデルを設定",
|
"description": "複数のAIプロバイダーとモデルを設定",
|
||||||
@@ -302,7 +225,6 @@
|
|||||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||||
"baseUrl": "ベース URL",
|
"baseUrl": "ベース URL",
|
||||||
"optional": "(オプション)",
|
"optional": "(オプション)",
|
||||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
|
||||||
"customEndpoint": "カスタムエンドポイント URL",
|
"customEndpoint": "カスタムエンドポイント URL",
|
||||||
"models": "モデル",
|
"models": "モデル",
|
||||||
"customModelId": "カスタムモデル ID...",
|
"customModelId": "カスタムモデル ID...",
|
||||||
@@ -328,13 +250,10 @@
|
|||||||
"noModelsFound": "モデルが見つかりません。",
|
"noModelsFound": "モデルが見つかりません。",
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
"serverDefault": "サーバーデフォルト",
|
"serverDefault": "サーバーデフォルト",
|
||||||
"serverModels": "サーバーモデル",
|
|
||||||
"userModels": "ユーザーモデル",
|
|
||||||
"configureModels": "モデルを設定...",
|
"configureModels": "モデルを設定...",
|
||||||
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
||||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
"unvalidatedModelWarning": "このモデルは検証されていません"
|
||||||
"serverDefaultModel": "サーバーデフォルトモデル"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow"
|
||||||
"modelscope": "ModelScope"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的图表或上传文件...",
|
"placeholder": "描述您的图表或上传文件...",
|
||||||
@@ -47,13 +46,11 @@
|
|||||||
"copyResponse": "复制响应",
|
"copyResponse": "复制响应",
|
||||||
"copied": "已复制!",
|
"copied": "已复制!",
|
||||||
"failedToCopy": "复制失败",
|
"failedToCopy": "复制失败",
|
||||||
"failedToCopyDetail": "复制消息失败。请手动复制或检查剪贴板权限。",
|
|
||||||
"goodResponse": "有帮助",
|
"goodResponse": "有帮助",
|
||||||
"badResponse": "无帮助",
|
"badResponse": "无帮助",
|
||||||
"clickToEdit": "点击编辑",
|
"clickToEdit": "点击编辑",
|
||||||
"editMessage": "编辑消息",
|
"editMessage": "编辑消息",
|
||||||
"saveAndSubmit": "保存并提交",
|
"saveAndSubmit": "保存并提交"
|
||||||
"ExtractURL": "从 URL 提取"
|
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "用 AI 创建图表",
|
"title": "用 AI 创建图表",
|
||||||
@@ -100,26 +97,14 @@
|
|||||||
"switchTo": "切换到",
|
"switchTo": "切换到",
|
||||||
"minimal": "简约",
|
"minimal": "简约",
|
||||||
"sketch": "草图",
|
"sketch": "草图",
|
||||||
|
"closeProtection": "关闭确认",
|
||||||
|
"closeProtectionDescription": "离开页面时显示确认。",
|
||||||
"diagramStyle": "图表样式",
|
"diagramStyle": "图表样式",
|
||||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||||
"sendShortcut": "发送快捷键",
|
|
||||||
"sendShortcutDescription": "选择发送消息的方式。",
|
|
||||||
"enterToSend": "回车发送",
|
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
|
|
||||||
"diagramActions": "图表操作",
|
"diagramActions": "图表操作",
|
||||||
"diagramActionsDescription": "管理图表历史记录和导出",
|
"diagramActionsDescription": "管理图表历史记录和导出",
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
"download": "下载",
|
"download": "下载"
|
||||||
"proxy": "代理设置",
|
|
||||||
"proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)",
|
|
||||||
"httpProxy": "HTTP 代理",
|
|
||||||
"httpsProxy": "HTTPS 代理",
|
|
||||||
"applyProxy": "应用",
|
|
||||||
"proxyApplied": "代理设置已应用",
|
|
||||||
"diagramValidation": "图表验证(实验性)",
|
|
||||||
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
|
||||||
"enabled": "已启用",
|
|
||||||
"disabled": "已禁用"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "保存图表",
|
"title": "保存图表",
|
||||||
@@ -131,8 +116,7 @@
|
|||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 图片",
|
"png": "PNG 图片",
|
||||||
"svg": "SVG 图片"
|
"svg": "SVG 图片"
|
||||||
},
|
}
|
||||||
"savedSuccessfully": "保存成功!"
|
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "图表历史",
|
"title": "图表历史",
|
||||||
@@ -157,7 +141,6 @@
|
|||||||
"invalidAccessCode": "无效或缺少访问码。请在设置中配置。",
|
"invalidAccessCode": "无效或缺少访问码。请在设置中配置。",
|
||||||
"networkError": "网络错误。请检查您的连接。",
|
"networkError": "网络错误。请检查您的连接。",
|
||||||
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
|
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
|
||||||
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
|
|
||||||
"validationFailed": "图表验证失败。请尝试重新生成。",
|
"validationFailed": "图表验证失败。请尝试重新生成。",
|
||||||
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
|
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
|
||||||
"failedToProcess": "无法处理图表。请尝试重新生成。",
|
"failedToProcess": "无法处理图表。请尝试重新生成。",
|
||||||
@@ -166,9 +149,7 @@
|
|||||||
"failedToRestore": "无法从 localStorage 恢复",
|
"failedToRestore": "无法从 localStorage 恢复",
|
||||||
"failedToPersist": "卸载前无法持久化状态",
|
"failedToPersist": "卸载前无法持久化状态",
|
||||||
"failedToExport": "获取图表数据时出错",
|
"failedToExport": "获取图表数据时出错",
|
||||||
"failedToLoadExample": "加载示例图片时出错",
|
"failedToLoadExample": "加载示例图片时出错"
|
||||||
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
|
|
||||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
|
|
||||||
},
|
},
|
||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "已达每日配额",
|
"dailyLimit": "已达每日配额",
|
||||||
@@ -200,35 +181,11 @@
|
|||||||
"chars": "字符",
|
"chars": "字符",
|
||||||
"removeFile": "移除文件"
|
"removeFile": "移除文件"
|
||||||
},
|
},
|
||||||
"url": {
|
|
||||||
"title": "从 URL 提取内容",
|
|
||||||
"description": "粘贴 URL 以提取和分析其内容",
|
|
||||||
"Extracting": "提取中...",
|
|
||||||
"extract": "提取",
|
|
||||||
"Cancel": "取消",
|
|
||||||
"enterUrl": "请输入 URL",
|
|
||||||
"invalidFormat": "URL 格式无效"
|
|
||||||
},
|
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "思考中...",
|
"thinking": "思考中...",
|
||||||
"thoughtFor": "思考了 {duration} 秒",
|
"thoughtFor": "思考了 {duration} 秒",
|
||||||
"thoughtBrief": "思考了几秒钟"
|
"thoughtBrief": "思考了几秒钟"
|
||||||
},
|
},
|
||||||
"dev": {
|
|
||||||
"title": "开发:XML 流式模拟器",
|
|
||||||
"preset": "预设:",
|
|
||||||
"selectPreset": "选择预设...",
|
|
||||||
"clear": "清除",
|
|
||||||
"placeholder": "在此粘贴 mxCell XML 或选择预设...",
|
|
||||||
"interval": "间隔:",
|
|
||||||
"chars": "字符:",
|
|
||||||
"streaming": "流式传输中...",
|
|
||||||
"simulate": "模拟",
|
|
||||||
"stop": "停止",
|
|
||||||
"testQuotaToast": "测试配额提示",
|
|
||||||
"simulatingMessage": "[开发] 模拟 XML 流式传输",
|
|
||||||
"successMessage": "成功显示图表。"
|
|
||||||
},
|
|
||||||
"about": {
|
"about": {
|
||||||
"modelChange": "模型变更与用量限制",
|
"modelChange": "模型变更与用量限制",
|
||||||
"walletCrying": "(别名:我的钱包顶不住了)",
|
"walletCrying": "(别名:我的钱包顶不住了)",
|
||||||
@@ -236,40 +193,6 @@
|
|||||||
"contactMe": "联系我",
|
"contactMe": "联系我",
|
||||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
||||||
},
|
},
|
||||||
"sessionHistory": {
|
|
||||||
"tooltip": "聊天历史",
|
|
||||||
"newChat": "新对话",
|
|
||||||
"empty": "暂无聊天记录",
|
|
||||||
"emptyHint": "开始对话吧",
|
|
||||||
"today": "今天",
|
|
||||||
"yesterday": "昨天",
|
|
||||||
"thisWeek": "本周",
|
|
||||||
"earlier": "更早",
|
|
||||||
"deleteTitle": "删除此对话?",
|
|
||||||
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
|
|
||||||
"recentChats": "最近对话",
|
|
||||||
"justNow": "刚刚",
|
|
||||||
"searchPlaceholder": "搜索对话...",
|
|
||||||
"noResults": "未找到对话"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"title": "验证图表",
|
|
||||||
"capturing": "截图中",
|
|
||||||
"validating": "验证中",
|
|
||||||
"validatingWithAttempt": "验证中 ({attempt}/{max})",
|
|
||||||
"valid": "通过",
|
|
||||||
"validWithWarnings": "通过(有警告)",
|
|
||||||
"issuesFound": "发现问题",
|
|
||||||
"error": "错误",
|
|
||||||
"skipped": "已跳过",
|
|
||||||
"capturedScreenshot": "截图预览:",
|
|
||||||
"issuesFoundLabel": "发现的问题:",
|
|
||||||
"suggestions": "建议:",
|
|
||||||
"passedValidation": "图表通过视觉验证 - 未发现问题。",
|
|
||||||
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
|
|
||||||
"improveWithSuggestions": "根据建议改进",
|
|
||||||
"regenerateWithFeedback": "使用验证反馈重新生成图表"
|
|
||||||
},
|
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AI 模型配置",
|
"title": "AI 模型配置",
|
||||||
"description": "配置多个 AI 提供商和模型",
|
"description": "配置多个 AI 提供商和模型",
|
||||||
@@ -302,7 +225,6 @@
|
|||||||
"enterSecretKey": "输入您的 Secret Key",
|
"enterSecretKey": "输入您的 Secret Key",
|
||||||
"baseUrl": "基础 URL",
|
"baseUrl": "基础 URL",
|
||||||
"optional": "(可选)",
|
"optional": "(可选)",
|
||||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
|
||||||
"customEndpoint": "自定义端点 URL",
|
"customEndpoint": "自定义端点 URL",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自定义模型 ID...",
|
"customModelId": "自定义模型 ID...",
|
||||||
@@ -328,13 +250,10 @@
|
|||||||
"noModelsFound": "未找到模型。",
|
"noModelsFound": "未找到模型。",
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
"serverDefault": "服务器默认",
|
"serverDefault": "服务器默认",
|
||||||
"serverModels": "服务器模型",
|
|
||||||
"userModels": "用户模型",
|
|
||||||
"configureModels": "配置模型...",
|
"configureModels": "配置模型...",
|
||||||
"onlyVerifiedShown": "仅显示已验证的模型",
|
"onlyVerifiedShown": "仅显示已验证的模型",
|
||||||
"showUnvalidatedModels": "显示未验证的模型",
|
"showUnvalidatedModels": "显示未验证的模型",
|
||||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||||
"unvalidatedModelWarning": "此模型尚未验证",
|
"unvalidatedModelWarning": "此模型尚未验证"
|
||||||
"serverDefaultModel": "服务器默认模型"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { z } from "zod"
|
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
|
||||||
import { PROVIDER_INFO } from "@/lib/types/model-config"
|
|
||||||
|
|
||||||
export const ProviderNameSchema: z.ZodType<ProviderName> = z
|
|
||||||
.string()
|
|
||||||
.refine((val): val is ProviderName => val in PROVIDER_INFO, {
|
|
||||||
message: "Invalid provider name",
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ServerProviderSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
provider: ProviderNameSchema,
|
|
||||||
models: z.array(z.string().min(1)),
|
|
||||||
// Optional: custom environment variable name for API key
|
|
||||||
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
|
|
||||||
apiKeyEnv: z.string().min(1).optional(),
|
|
||||||
// Optional: custom environment variable name for base URL
|
|
||||||
baseUrlEnv: z.string().min(1).optional(),
|
|
||||||
// Optional: mark the first model in this provider as the default
|
|
||||||
default: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ServerModelsConfigSchema = z.object({
|
|
||||||
providers: z.array(ServerProviderSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ServerProviderConfig = z.infer<typeof ServerProviderSchema>
|
|
||||||
export type ServerModelsConfig = z.infer<typeof ServerModelsConfigSchema>
|
|
||||||
|
|
||||||
export interface FlattenedServerModel {
|
|
||||||
id: string // "server:<slugified-name>:<modelId>" - name ensures uniqueness for multiple API keys per provider
|
|
||||||
modelId: string
|
|
||||||
provider: ProviderName
|
|
||||||
providerLabel: string
|
|
||||||
isDefault: boolean
|
|
||||||
// Custom env var names for credentials (optional)
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert provider name to URL-safe slug for use in model ID
|
|
||||||
* e.g., "OpenAI Production" → "openai-production"
|
|
||||||
*/
|
|
||||||
function slugify(name: string): string {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfigPath(): string {
|
|
||||||
const custom = process.env.AI_MODELS_CONFIG_PATH
|
|
||||||
if (custom && custom.trim().length > 0) return custom
|
|
||||||
return path.join(process.cwd(), "ai-models.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
|
||||||
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
|
||||||
const envConfig = process.env.AI_MODELS_CONFIG
|
|
||||||
if (envConfig && envConfig.trim().length > 0) {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(envConfig)
|
|
||||||
return ServerModelsConfigSchema.parse(json)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
"[server-model-config] Failed to parse AI_MODELS_CONFIG:",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: ai-models.json file
|
|
||||||
const configPath = getConfigPath()
|
|
||||||
try {
|
|
||||||
const jsonStr = await fs.readFile(configPath, "utf8")
|
|
||||||
const json = JSON.parse(jsonStr)
|
|
||||||
return ServerModelsConfigSchema.parse(json)
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "ENOENT") {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
"[server-model-config] Failed to load ai-models.json:",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadFlattenedServerModels(): Promise<
|
|
||||||
FlattenedServerModel[]
|
|
||||||
> {
|
|
||||||
const cfg = await loadRawServerModelsConfig()
|
|
||||||
if (!cfg) return []
|
|
||||||
|
|
||||||
const defaultProvider = process.env.AI_PROVIDER as ProviderName | undefined
|
|
||||||
const defaultModelId = process.env.AI_MODEL
|
|
||||||
|
|
||||||
const flattened: FlattenedServerModel[] = []
|
|
||||||
|
|
||||||
for (const p of cfg.providers) {
|
|
||||||
const providerLabel =
|
|
||||||
p.name || PROVIDER_INFO[p.provider]?.label || p.provider
|
|
||||||
|
|
||||||
// Use slugified name for unique ID (supports multiple API keys per provider)
|
|
||||||
const nameSlug = slugify(p.name)
|
|
||||||
|
|
||||||
for (const modelId of p.models) {
|
|
||||||
const id = `server:${nameSlug}:${modelId}`
|
|
||||||
|
|
||||||
// Default model priority:
|
|
||||||
// 1. From ai-models.json: first model of provider with default: true
|
|
||||||
// 2. From env vars: AI_MODEL matches (legacy behavior)
|
|
||||||
const isDefault =
|
|
||||||
(p.default === true && modelId === p.models[0]) ||
|
|
||||||
(!!defaultModelId &&
|
|
||||||
modelId === defaultModelId &&
|
|
||||||
(!defaultProvider || defaultProvider === p.provider))
|
|
||||||
|
|
||||||
flattened.push({
|
|
||||||
id,
|
|
||||||
modelId,
|
|
||||||
provider: p.provider,
|
|
||||||
providerLabel,
|
|
||||||
isDefault,
|
|
||||||
apiKeyEnv: p.apiKeyEnv,
|
|
||||||
baseUrlEnv: p.baseUrlEnv,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return flattened
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a server model by its ID (format: "server:<slugified-name>:<modelId>")
|
|
||||||
* Returns the model config including apiKeyEnv/baseUrlEnv if configured
|
|
||||||
*/
|
|
||||||
export async function findServerModelById(
|
|
||||||
modelId: string,
|
|
||||||
): Promise<FlattenedServerModel | null> {
|
|
||||||
if (!modelId.startsWith("server:")) return null
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
return models.find((m) => m.id === modelId) || null
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
|
||||||
import { nanoid } from "nanoid"
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const DB_NAME = "next-ai-drawio"
|
|
||||||
const DB_VERSION = 1
|
|
||||||
const STORE_NAME = "sessions"
|
|
||||||
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
|
||||||
const MAX_SESSIONS = 50
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface ChatSession {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
messages: StoredMessage[]
|
|
||||||
xmlSnapshots: [number, string][]
|
|
||||||
diagramXml: string
|
|
||||||
thumbnailDataUrl?: string // Small PNG preview of the diagram
|
|
||||||
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoredMessage {
|
|
||||||
id: string
|
|
||||||
role: "user" | "assistant" | "system"
|
|
||||||
parts: Array<{ type: string; [key: string]: unknown }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionMetadata {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
messageCount: number
|
|
||||||
hasDiagram: boolean
|
|
||||||
thumbnailDataUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatSessionDB extends DBSchema {
|
|
||||||
sessions: {
|
|
||||||
key: string
|
|
||||||
value: ChatSession
|
|
||||||
indexes: { "by-updated": number }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database singleton
|
|
||||||
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
|
||||||
|
|
||||||
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
|
||||||
if (!dbPromise) {
|
|
||||||
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
|
|
||||||
upgrade(db, oldVersion) {
|
|
||||||
if (oldVersion < 1) {
|
|
||||||
const store = db.createObjectStore(STORE_NAME, {
|
|
||||||
keyPath: "id",
|
|
||||||
})
|
|
||||||
store.createIndex("by-updated", "updatedAt")
|
|
||||||
}
|
|
||||||
// Future migrations: if (oldVersion < 2) { ... }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return dbPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if IndexedDB is available
|
|
||||||
export function isIndexedDBAvailable(): boolean {
|
|
||||||
if (typeof window === "undefined") return false
|
|
||||||
try {
|
|
||||||
return "indexedDB" in window && window.indexedDB !== null
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if IndexedDB is actually usable (not just present).
|
|
||||||
export async function isIndexedDBUsable(): Promise<boolean> {
|
|
||||||
if (!isIndexedDBAvailable()) return false
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
db.close()
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRUD Operations
|
|
||||||
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
|
||||||
if (!isIndexedDBAvailable()) return []
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
const tx = db.transaction(STORE_NAME, "readonly")
|
|
||||||
const index = tx.store.index("by-updated")
|
|
||||||
const metadata: SessionMetadata[] = []
|
|
||||||
|
|
||||||
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
|
||||||
let cursor = await index.openCursor(null, "prev") // newest first
|
|
||||||
while (cursor) {
|
|
||||||
const s = cursor.value
|
|
||||||
metadata.push({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title,
|
|
||||||
createdAt: s.createdAt,
|
|
||||||
updatedAt: s.updatedAt,
|
|
||||||
messageCount: s.messages.length,
|
|
||||||
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
|
||||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
|
||||||
})
|
|
||||||
cursor = await cursor.continue()
|
|
||||||
}
|
|
||||||
return metadata
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get session metadata:", error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSession(id: string): Promise<ChatSession | null> {
|
|
||||||
if (!isIndexedDBAvailable()) return null
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
return (await db.get(STORE_NAME, id)) || null
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get session:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveSession(session: ChatSession): Promise<boolean> {
|
|
||||||
if (!isIndexedDBAvailable()) return false
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
await db.put(STORE_NAME, session)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
// Handle quota exceeded
|
|
||||||
if (
|
|
||||||
error instanceof DOMException &&
|
|
||||||
error.name === "QuotaExceededError"
|
|
||||||
) {
|
|
||||||
console.warn("Storage quota exceeded, deleting oldest session...")
|
|
||||||
await deleteOldestSession()
|
|
||||||
// Retry once
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
await db.put(STORE_NAME, session)
|
|
||||||
return true
|
|
||||||
} catch (retryError) {
|
|
||||||
console.error(
|
|
||||||
"Failed to save session after cleanup:",
|
|
||||||
retryError,
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("Failed to save session:", error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSession(id: string): Promise<void> {
|
|
||||||
if (!isIndexedDBAvailable()) return
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
await db.delete(STORE_NAME, id)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete session:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSessionCount(): Promise<number> {
|
|
||||||
if (!isIndexedDBAvailable()) return 0
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
return await db.count(STORE_NAME)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get session count:", error)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteOldestSession(): Promise<void> {
|
|
||||||
if (!isIndexedDBAvailable()) return
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
|
||||||
const index = tx.store.index("by-updated")
|
|
||||||
const cursor = await index.openCursor()
|
|
||||||
if (cursor) {
|
|
||||||
await cursor.delete()
|
|
||||||
}
|
|
||||||
await tx.done
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete oldest session:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce max sessions limit
|
|
||||||
export async function enforceSessionLimit(): Promise<void> {
|
|
||||||
const count = await getSessionCount()
|
|
||||||
if (count > MAX_SESSIONS) {
|
|
||||||
const toDelete = count - MAX_SESSIONS
|
|
||||||
for (let i = 0; i < toDelete; i++) {
|
|
||||||
await deleteOldestSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Create a new empty session
|
|
||||||
export function createEmptySession(): ChatSession {
|
|
||||||
return {
|
|
||||||
id: nanoid(),
|
|
||||||
title: "New Chat",
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
messages: [],
|
|
||||||
xmlSnapshots: [],
|
|
||||||
diagramXml: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Extract title from first user message (truncated to reasonable length)
|
|
||||||
const MAX_TITLE_LENGTH = 100
|
|
||||||
|
|
||||||
export function extractTitle(messages: StoredMessage[]): string {
|
|
||||||
const firstUserMessage = messages.find((m) => m.role === "user")
|
|
||||||
if (!firstUserMessage) return "New Chat"
|
|
||||||
|
|
||||||
const textPart = firstUserMessage.parts.find((p) => p.type === "text")
|
|
||||||
if (!textPart || typeof textPart.text !== "string") return "New Chat"
|
|
||||||
|
|
||||||
const text = textPart.text.trim()
|
|
||||||
if (!text) return "New Chat"
|
|
||||||
|
|
||||||
// Truncate long titles
|
|
||||||
if (text.length > MAX_TITLE_LENGTH) {
|
|
||||||
return text.slice(0, MAX_TITLE_LENGTH).trim() + "..."
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Sanitize UIMessage to StoredMessage
|
|
||||||
export function sanitizeMessage(message: unknown): StoredMessage | null {
|
|
||||||
if (!message || typeof message !== "object") return null
|
|
||||||
|
|
||||||
const msg = message as Record<string, unknown>
|
|
||||||
if (!msg.id || !msg.role) return null
|
|
||||||
|
|
||||||
const role = msg.role as string
|
|
||||||
if (!["user", "assistant", "system"].includes(role)) return null
|
|
||||||
|
|
||||||
// Extract parts, removing streaming state artifacts
|
|
||||||
let parts: Array<{ type: string; [key: string]: unknown }> = []
|
|
||||||
if (Array.isArray(msg.parts)) {
|
|
||||||
parts = msg.parts.map((part: unknown) => {
|
|
||||||
if (!part || typeof part !== "object") return { type: "unknown" }
|
|
||||||
const p = part as Record<string, unknown>
|
|
||||||
// Remove streaming-related fields
|
|
||||||
const { isStreaming, streamingState, ...cleanPart } = p
|
|
||||||
return cleanPart as { type: string; [key: string]: unknown }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: msg.id as string,
|
|
||||||
role: role as "user" | "assistant" | "system",
|
|
||||||
parts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeMessages(messages: unknown[]): StoredMessage[] {
|
|
||||||
return messages
|
|
||||||
.map(sanitizeMessage)
|
|
||||||
.filter((m): m is StoredMessage => m !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migration from localStorage
|
|
||||||
export async function migrateFromLocalStorage(): Promise<string | null> {
|
|
||||||
if (typeof window === "undefined") return null
|
|
||||||
if (!isIndexedDBAvailable()) return null
|
|
||||||
|
|
||||||
// Check if already migrated
|
|
||||||
if (localStorage.getItem(MIGRATION_FLAG)) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const savedMessages = localStorage.getItem("next-ai-draw-io-messages")
|
|
||||||
const savedSnapshots = localStorage.getItem(
|
|
||||||
"next-ai-draw-io-xml-snapshots",
|
|
||||||
)
|
|
||||||
const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml")
|
|
||||||
|
|
||||||
let newSessionId: string | null = null
|
|
||||||
let migrationSucceeded = false
|
|
||||||
|
|
||||||
if (savedMessages) {
|
|
||||||
const messages = JSON.parse(savedMessages)
|
|
||||||
if (Array.isArray(messages) && messages.length > 0) {
|
|
||||||
const sanitized = sanitizeMessages(messages)
|
|
||||||
const session: ChatSession = {
|
|
||||||
id: nanoid(),
|
|
||||||
title: extractTitle(sanitized),
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
messages: sanitized,
|
|
||||||
xmlSnapshots: savedSnapshots
|
|
||||||
? JSON.parse(savedSnapshots)
|
|
||||||
: [],
|
|
||||||
diagramXml: savedXml || "",
|
|
||||||
}
|
|
||||||
const saved = await saveSession(session)
|
|
||||||
if (saved) {
|
|
||||||
// Verify the session was actually written
|
|
||||||
const verified = await getSession(session.id)
|
|
||||||
if (verified) {
|
|
||||||
newSessionId = session.id
|
|
||||||
migrationSucceeded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Empty array or invalid data - nothing to migrate, mark as success
|
|
||||||
migrationSucceeded = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No data to migrate - mark as success
|
|
||||||
migrationSucceeded = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only clean up old data if migration succeeded
|
|
||||||
if (migrationSucceeded) {
|
|
||||||
localStorage.setItem(MIGRATION_FLAG, "true")
|
|
||||||
localStorage.removeItem("next-ai-draw-io-messages")
|
|
||||||
localStorage.removeItem("next-ai-draw-io-xml-snapshots")
|
|
||||||
localStorage.removeItem("next-ai-draw-io-diagram-xml")
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Migration to IndexedDB failed - keeping localStorage data for retry",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newSessionId
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Migration failed:", error)
|
|
||||||
// Don't mark as migrated - allow retry on next load
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* SSRF (Server-Side Request Forgery) protection utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL points to private/internal network
|
|
||||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
|
||||||
*/
|
|
||||||
export function isPrivateUrl(urlString: string): boolean {
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString)
|
|
||||||
const hostname = url.hostname.toLowerCase()
|
|
||||||
|
|
||||||
// Block localhost
|
|
||||||
if (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "127.0.0.1" ||
|
|
||||||
hostname === "::1"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block AWS/cloud metadata endpoints
|
|
||||||
if (
|
|
||||||
hostname === "169.254.169.254" ||
|
|
||||||
hostname === "metadata.google.internal"
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for private IPv4 ranges
|
|
||||||
const ipv4Match = hostname.match(
|
|
||||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
|
||||||
)
|
|
||||||
if (ipv4Match) {
|
|
||||||
const [, a, b] = ipv4Match.map(Number)
|
|
||||||
if (a === 10) return true // 10.0.0.0/8
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
|
||||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
|
||||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
|
||||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block common internal hostnames
|
|
||||||
if (
|
|
||||||
hostname.endsWith(".local") ||
|
|
||||||
hostname.endsWith(".internal") ||
|
|
||||||
hostname.endsWith(".localhost")
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
return true // Invalid URL - block it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether private URLs are allowed (defaults to true)
|
|
||||||
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
|
||||||
*/
|
|
||||||
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
// Centralized localStorage keys for quota tracking and settings
|
// Centralized localStorage keys
|
||||||
// Chat data is now stored in IndexedDB via session-storage.ts
|
// Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
|
||||||
|
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
|
// Chat data
|
||||||
|
messages: "next-ai-draw-io-messages",
|
||||||
|
xmlSnapshots: "next-ai-draw-io-xml-snapshots",
|
||||||
|
diagramXml: "next-ai-draw-io-diagram-xml",
|
||||||
|
sessionId: "next-ai-draw-io-session-id",
|
||||||
|
|
||||||
// Quota tracking
|
// Quota tracking
|
||||||
requestCount: "next-ai-draw-io-request-count",
|
requestCount: "next-ai-draw-io-request-count",
|
||||||
requestDate: "next-ai-draw-io-request-date",
|
requestDate: "next-ai-draw-io-request-date",
|
||||||
@@ -12,6 +18,7 @@ export const STORAGE_KEYS = {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
accessCode: "next-ai-draw-io-access-code",
|
accessCode: "next-ai-draw-io-access-code",
|
||||||
|
closeProtection: "next-ai-draw-io-close-protection",
|
||||||
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
accessCodeRequired: "next-ai-draw-io-access-code-required",
|
||||||
aiProvider: "next-ai-draw-io-ai-provider",
|
aiProvider: "next-ai-draw-io-ai-provider",
|
||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
@@ -21,10 +28,4 @@ export const STORAGE_KEYS = {
|
|||||||
// Multi-model configuration
|
// Multi-model configuration
|
||||||
modelConfigs: "next-ai-draw-io-model-configs",
|
modelConfigs: "next-ai-draw-io-model-configs",
|
||||||
selectedModelId: "next-ai-draw-io-selected-model-id",
|
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||||
|
|
||||||
// Chat input preferences
|
|
||||||
sendShortcut: "next-ai-draw-io-send-shortcut",
|
|
||||||
|
|
||||||
// Diagram validation
|
|
||||||
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
|||||||
**Operations:**
|
**Operations:**
|
||||||
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
||||||
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
- **delete**: Remove a cell. Only cell_id is needed.
|
||||||
|
|
||||||
**Input Format:**
|
**Input Format:**
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
@@ -301,9 +301,9 @@ Add new shape:
|
|||||||
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Delete container (children & edges auto-deleted):
|
Delete cell:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}
|
{"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**Error Recovery:**
|
**Error Recovery:**
|
||||||
|
|||||||
39
lib/token-counter.ts
Normal file
39
lib/token-counter.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Token counting utilities using js-tiktoken
|
||||||
|
*
|
||||||
|
* Uses cl100k_base encoding (GPT-4) which is close to Claude's tokenization.
|
||||||
|
* This is a pure JavaScript implementation, no WASM required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encodingForModel } from "js-tiktoken"
|
||||||
|
import { DEFAULT_SYSTEM_PROMPT, EXTENDED_SYSTEM_PROMPT } from "./system-prompts"
|
||||||
|
|
||||||
|
const encoder = encodingForModel("gpt-4o")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of tokens in a text string
|
||||||
|
* @param text - The text to count tokens for
|
||||||
|
* @returns The number of tokens
|
||||||
|
*/
|
||||||
|
export function countTextTokens(text: string): number {
|
||||||
|
return encoder.encode(text).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get token counts for the system prompts
|
||||||
|
* Useful for debugging and optimizing prompt sizes
|
||||||
|
* @returns Object with token counts for default and extended prompts
|
||||||
|
*/
|
||||||
|
export function getSystemPromptTokenCounts(): {
|
||||||
|
default: number
|
||||||
|
extended: number
|
||||||
|
additions: number
|
||||||
|
} {
|
||||||
|
const defaultTokens = countTextTokens(DEFAULT_SYSTEM_PROMPT)
|
||||||
|
const extendedTokens = countTextTokens(EXTENDED_SYSTEM_PROMPT)
|
||||||
|
return {
|
||||||
|
default: defaultTokens,
|
||||||
|
extended: extendedTokens,
|
||||||
|
additions: extendedTokens - defaultTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,14 @@ export type ProviderName =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "google"
|
| "google"
|
||||||
| "vertexai"
|
|
||||||
| "azure"
|
| "azure"
|
||||||
| "bedrock"
|
| "bedrock"
|
||||||
| "ollama"
|
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
| "sglang"
|
| "sglang"
|
||||||
| "gateway"
|
| "gateway"
|
||||||
| "edgeone"
|
|
||||||
| "doubao"
|
| "doubao"
|
||||||
| "modelscope"
|
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -37,9 +33,6 @@ export interface ProviderConfig {
|
|||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
awsSessionToken?: string // Optional, for temporary credentials
|
awsSessionToken?: string // Optional, for temporary credentials
|
||||||
// Vertex AI specific fields
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
|
|
||||||
models: ModelConfig[]
|
models: ModelConfig[]
|
||||||
validated?: boolean // Has API key been validated
|
validated?: boolean // Has API key been validated
|
||||||
}
|
}
|
||||||
@@ -54,7 +47,7 @@ export interface MultiModelConfig {
|
|||||||
|
|
||||||
// Flattened model for dropdown display
|
// Flattened model for dropdown display
|
||||||
export interface FlattenedModel {
|
export interface FlattenedModel {
|
||||||
id: string // Model config UUID or synthetic server ID (e.g., "server:provider:modelId")
|
id: string // Model config UUID
|
||||||
modelId: string // Actual model ID
|
modelId: string // Actual model ID
|
||||||
provider: ProviderName
|
provider: ProviderName
|
||||||
providerLabel: string // Provider display name
|
providerLabel: string // Provider display name
|
||||||
@@ -65,17 +58,7 @@ export interface FlattenedModel {
|
|||||||
awsSecretAccessKey?: string
|
awsSecretAccessKey?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
awsSessionToken?: string
|
awsSessionToken?: string
|
||||||
// Vertex AI specific fields
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
|
|
||||||
validated?: boolean // Has this model been validated
|
validated?: boolean // Has this model been validated
|
||||||
// Source of this model config: user-defined (client) or server-defined
|
|
||||||
source?: "user" | "server"
|
|
||||||
// Whether this model is the server default (matches AI_MODEL env var)
|
|
||||||
isDefault?: boolean
|
|
||||||
// Custom env var names for server models (allows multiple API keys per provider)
|
|
||||||
apiKeyEnv?: string
|
|
||||||
baseUrlEnv?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider metadata
|
// Provider metadata
|
||||||
@@ -83,61 +66,33 @@ export const PROVIDER_INFO: Record<
|
|||||||
ProviderName,
|
ProviderName,
|
||||||
{ label: string; defaultBaseUrl?: string }
|
{ label: string; defaultBaseUrl?: string }
|
||||||
> = {
|
> = {
|
||||||
openai: {
|
openai: { label: "OpenAI" },
|
||||||
label: "OpenAI",
|
|
||||||
defaultBaseUrl: "https://api.openai.com/v1",
|
|
||||||
},
|
|
||||||
anthropic: {
|
anthropic: {
|
||||||
label: "Anthropic",
|
label: "Anthropic",
|
||||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||||
},
|
},
|
||||||
google: {
|
google: { label: "Google" },
|
||||||
label: "Google",
|
azure: { label: "Azure OpenAI" },
|
||||||
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
||||||
},
|
|
||||||
vertexai: { label: "Google Vertex AI" },
|
|
||||||
azure: {
|
|
||||||
label: "Azure OpenAI",
|
|
||||||
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
|
|
||||||
},
|
|
||||||
bedrock: { label: "Amazon Bedrock" },
|
bedrock: { label: "Amazon Bedrock" },
|
||||||
ollama: {
|
openrouter: { label: "OpenRouter" },
|
||||||
label: "Ollama",
|
deepseek: { label: "DeepSeek" },
|
||||||
defaultBaseUrl: "http://localhost:11434",
|
|
||||||
},
|
|
||||||
openrouter: {
|
|
||||||
label: "OpenRouter",
|
|
||||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
},
|
|
||||||
deepseek: {
|
|
||||||
label: "DeepSeek",
|
|
||||||
defaultBaseUrl: "https://api.deepseek.com/v1",
|
|
||||||
},
|
|
||||||
siliconflow: {
|
siliconflow: {
|
||||||
label: "SiliconFlow",
|
label: "SiliconFlow",
|
||||||
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
defaultBaseUrl: "https://api.siliconflow.com/v1",
|
||||||
},
|
},
|
||||||
sglang: {
|
sglang: {
|
||||||
label: "SGLang",
|
label: "SGLang",
|
||||||
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
||||||
},
|
},
|
||||||
gateway: {
|
gateway: { label: "AI Gateway" },
|
||||||
label: "AI Gateway",
|
|
||||||
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
|
|
||||||
},
|
|
||||||
edgeone: { label: "EdgeOne Pages" },
|
|
||||||
doubao: {
|
doubao: {
|
||||||
label: "Doubao (ByteDance)",
|
label: "Doubao (ByteDance)",
|
||||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
},
|
},
|
||||||
modelscope: {
|
|
||||||
label: "ModelScope",
|
|
||||||
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
||||||
openai: [
|
openai: [
|
||||||
"gpt-5.2-pro",
|
"gpt-5.2-pro",
|
||||||
"gpt-5.2-chat-latest",
|
"gpt-5.2-chat-latest",
|
||||||
@@ -190,17 +145,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
// Legacy
|
// Legacy
|
||||||
"gemini-pro",
|
"gemini-pro",
|
||||||
],
|
],
|
||||||
vertexai: [
|
|
||||||
// Gemini 2.5 series
|
|
||||||
"gemini-2.5-pro",
|
|
||||||
"gemini-2.5-flash",
|
|
||||||
// Gemini 2.0 series
|
|
||||||
"gemini-2.0-flash",
|
|
||||||
"gemini-2.0-flash-exp",
|
|
||||||
// Gemini 1.5 series
|
|
||||||
"gemini-1.5-pro",
|
|
||||||
"gemini-1.5-flash",
|
|
||||||
],
|
|
||||||
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||||
bedrock: [
|
bedrock: [
|
||||||
// Anthropic Claude
|
// Anthropic Claude
|
||||||
@@ -275,7 +219,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"anthropic/claude-3-5-sonnet",
|
"anthropic/claude-3-5-sonnet",
|
||||||
"google/gemini-2.0-flash",
|
"google/gemini-2.0-flash",
|
||||||
],
|
],
|
||||||
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
|
|
||||||
doubao: [
|
doubao: [
|
||||||
// ByteDance Doubao models
|
// ByteDance Doubao models
|
||||||
"doubao-1.5-thinking-pro-250415",
|
"doubao-1.5-thinking-pro-250415",
|
||||||
@@ -285,17 +228,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"doubao-pro-32k-241215",
|
"doubao-pro-32k-241215",
|
||||||
"doubao-pro-256k-241215",
|
"doubao-pro-256k-241215",
|
||||||
],
|
],
|
||||||
modelscope: [
|
|
||||||
// Qwen
|
|
||||||
"Qwen/Qwen2.5-72B-Instruct",
|
|
||||||
"Qwen/Qwen2.5-32B-Instruct",
|
|
||||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
|
||||||
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
|
||||||
"Qwen/Qwen3-32B",
|
|
||||||
// DeepSeek
|
|
||||||
"deepseek-ai/DeepSeek-R1-0528",
|
|
||||||
"deepseek-ai/DeepSeek-V3.2",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to generate UUID
|
// Helper to generate UUID
|
||||||
@@ -332,7 +264,7 @@ export function createModelConfig(modelId: string): ModelConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all models as flattened list for dropdown (user-defined only)
|
// Get all models as flattened list for dropdown
|
||||||
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
||||||
const models: FlattenedModel[] = []
|
const models: FlattenedModel[] = []
|
||||||
|
|
||||||
@@ -354,12 +286,7 @@ export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
|||||||
awsSecretAccessKey: provider.awsSecretAccessKey,
|
awsSecretAccessKey: provider.awsSecretAccessKey,
|
||||||
awsRegion: provider.awsRegion,
|
awsRegion: provider.awsRegion,
|
||||||
awsSessionToken: provider.awsSessionToken,
|
awsSessionToken: provider.awsSessionToken,
|
||||||
// Vertex AI fields
|
|
||||||
vertexApiKey: provider.vertexApiKey,
|
|
||||||
|
|
||||||
validated: model.validated,
|
validated: model.validated,
|
||||||
source: "user",
|
|
||||||
isDefault: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
export interface UrlData {
|
|
||||||
url: string
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
charCount: number
|
|
||||||
isExtracting: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlResponseSchema = z.object({
|
|
||||||
title: z.string().default("Untitled"),
|
|
||||||
content: z.string(),
|
|
||||||
charCount: z.number().int().nonnegative(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function extractUrlContent(url: string): Promise<UrlData> {
|
|
||||||
const response = await fetch("/api/parse-url", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ url }),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Try to parse JSON once
|
|
||||||
const raw = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ error: "Unexpected non-JSON response" }))
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message =
|
|
||||||
typeof raw === "object" && raw && "error" in raw
|
|
||||||
? String((raw as any).error)
|
|
||||||
: "Failed to extract URL content"
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = UrlResponseSchema.safeParse(raw)
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new Error("Malformed response from URL extraction API")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
title: parsed.data.title,
|
|
||||||
content: parsed.data.content,
|
|
||||||
charCount: parsed.data.charCount,
|
|
||||||
isExtracting: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
105
lib/utils.ts
105
lib/utils.ts
@@ -1,33 +1,11 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import * as pako from "pako"
|
import * as pako from "pako"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import type { DiagramOperation } from "@/components/chat/types"
|
|
||||||
|
|
||||||
export type { DiagramOperation }
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Diagram Constants
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum length for a "real" diagram XML (not just empty template).
|
|
||||||
* Empty mxfile templates are ~147-300 chars; real diagrams are larger.
|
|
||||||
*/
|
|
||||||
export const MIN_REAL_DIAGRAM_LENGTH = 300
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if diagram XML represents a real diagram (not just empty template).
|
|
||||||
* @param xml - The diagram XML string to check
|
|
||||||
* @returns true if the XML is a real diagram with content
|
|
||||||
*/
|
|
||||||
export function isRealDiagram(xml: string | undefined | null): boolean {
|
|
||||||
return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// XML Validation/Fix Constants
|
// XML Validation/Fix Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -476,6 +454,12 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
// ID-based Diagram Operations
|
// ID-based Diagram Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationError {
|
export interface OperationError {
|
||||||
type: "update" | "add" | "delete"
|
type: "update" | "add" | "delete"
|
||||||
cellId: string
|
cellId: string
|
||||||
@@ -649,77 +633,32 @@ export function applyDiagramOperations(
|
|||||||
// Add to map
|
// Add to map
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.operation === "delete") {
|
} else if (op.operation === "delete") {
|
||||||
// Protect root cells from deletion
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (op.cell_id === "0" || op.cell_id === "1") {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "delete",
|
type: "delete",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `Cannot delete root cell "${op.cell_id}"`,
|
message: `Cell with id="${op.cell_id}" not found`,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
// Check for edges referencing this cell (warning only, still delete)
|
||||||
if (!existingCell) {
|
const referencingEdges = root.querySelectorAll(
|
||||||
// Cell not found - might have been cascade-deleted by a previous operation
|
`mxCell[source="${op.cell_id}"], mxCell[target="${op.cell_id}"]`,
|
||||||
// Skip silently instead of erroring (AI may redundantly list children/edges)
|
)
|
||||||
continue
|
if (referencingEdges.length > 0) {
|
||||||
}
|
const edgeIds = Array.from(referencingEdges)
|
||||||
|
.map((e) => e.getAttribute("id"))
|
||||||
// Cascade delete: collect all cells to delete (children + edges + self)
|
.join(", ")
|
||||||
const cellsToDelete = new Set<string>()
|
console.warn(
|
||||||
|
`[applyDiagramOperations] Deleting cell "${op.cell_id}" which is referenced by edges: ${edgeIds}`,
|
||||||
// Recursive function to find all descendants
|
|
||||||
const collectDescendants = (cellId: string) => {
|
|
||||||
if (cellsToDelete.has(cellId)) return
|
|
||||||
cellsToDelete.add(cellId)
|
|
||||||
|
|
||||||
// Find children (cells where parent === cellId)
|
|
||||||
const children = root.querySelectorAll(
|
|
||||||
`mxCell[parent="${cellId}"]`,
|
|
||||||
)
|
|
||||||
children.forEach((child) => {
|
|
||||||
const childId = child.getAttribute("id")
|
|
||||||
if (childId && childId !== "0" && childId !== "1") {
|
|
||||||
collectDescendants(childId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect the target cell and all its descendants
|
|
||||||
collectDescendants(op.cell_id)
|
|
||||||
|
|
||||||
// Find edges referencing any of the cells to be deleted
|
|
||||||
// Also recursively collect children of those edges (e.g., edge labels)
|
|
||||||
for (const cellId of cellsToDelete) {
|
|
||||||
const referencingEdges = root.querySelectorAll(
|
|
||||||
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
|
|
||||||
)
|
|
||||||
referencingEdges.forEach((edge) => {
|
|
||||||
const edgeId = edge.getAttribute("id")
|
|
||||||
// Protect root cells from being added via edge references
|
|
||||||
if (edgeId && edgeId !== "0" && edgeId !== "1") {
|
|
||||||
// Recurse to collect edge's children (like labels)
|
|
||||||
collectDescendants(edgeId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log what will be deleted
|
|
||||||
if (cellsToDelete.size > 1) {
|
|
||||||
console.log(
|
|
||||||
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all collected cells
|
// Remove the node
|
||||||
for (const cellId of cellsToDelete) {
|
existingCell.parentNode?.removeChild(existingCell)
|
||||||
const cell = cellMap.get(cellId)
|
cellMap.delete(op.cell_id)
|
||||||
if (cell) {
|
|
||||||
cell.parentNode?.removeChild(cell)
|
|
||||||
cellMap.delete(cellId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* VLM system prompt for diagram validation.
|
|
||||||
* Note: Response parsing is now handled via AI SDK's structured outputs (generateObject with schema).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const VALIDATION_SYSTEM_PROMPT = `You are a diagram quality validator. Analyze the rendered diagram image for visual issues.
|
|
||||||
|
|
||||||
Evaluate the diagram for the following issues:
|
|
||||||
|
|
||||||
1. **Overlapping elements** (critical): Shapes covering each other inappropriately, making content unreadable
|
|
||||||
2. **Edge routing issues** (critical): Lines/arrows crossing through shapes that are not their source or target
|
|
||||||
3. **Text readability** (warning): Labels cut off, overlapping, or too small to read
|
|
||||||
4. **Layout quality** (warning): Poor spacing, misalignment, or cramped elements
|
|
||||||
5. **Rendering errors** (critical): Incomplete, corrupted, or missing visual elements
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Set "valid" to true ONLY if there are no critical issues
|
|
||||||
- Be specific about which elements have problems (e.g., "The 'Login' box overlaps with 'Register' box")
|
|
||||||
- Provide actionable suggestions (e.g., "Move the Login box 50 pixels to the left")
|
|
||||||
- Minor cosmetic issues (slight misalignment, non-uniform spacing) should be warnings, not critical
|
|
||||||
- Empty diagrams or diagrams with only 1-2 elements should pass unless they have obvious errors
|
|
||||||
- If the diagram looks generally acceptable, set valid to true even with minor warnings`
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared validation schema for VLM-based diagram validation.
|
|
||||||
* This file can be safely imported on both client and server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
// Schema for structured validation output
|
|
||||||
export const ValidationResultSchema = z.object({
|
|
||||||
valid: z.boolean().describe("True if there are no critical issues"),
|
|
||||||
issues: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
type: z
|
|
||||||
.enum([
|
|
||||||
"overlap",
|
|
||||||
"edge_routing",
|
|
||||||
"text",
|
|
||||||
"layout",
|
|
||||||
"rendering",
|
|
||||||
])
|
|
||||||
.describe("Type of visual issue"),
|
|
||||||
severity: z
|
|
||||||
.enum(["critical", "warning"])
|
|
||||||
.describe("Severity level"),
|
|
||||||
description: z
|
|
||||||
.string()
|
|
||||||
.describe("Clear description of the issue"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.describe("List of visual issues found"),
|
|
||||||
suggestions: z
|
|
||||||
.array(z.string())
|
|
||||||
.describe("Actionable suggestions to fix issues"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ValidationResult = z.infer<typeof ValidationResultSchema>
|
|
||||||
export type ValidationIssue = ValidationResult["issues"][number]
|
|
||||||
8719
package-lock.json
generated
8719
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.12",
|
"version": "0.4.7",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -24,11 +24,8 @@
|
|||||||
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml",
|
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml",
|
||||||
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
||||||
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
||||||
"dist:win:build": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win --publish never",
|
|
||||||
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
||||||
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux",
|
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
|
||||||
"test": "vitest",
|
|
||||||
"test:e2e": "playwright test"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
@@ -37,21 +34,18 @@
|
|||||||
"@ai-sdk/deepseek": "^2.0.0",
|
"@ai-sdk/deepseek": "^2.0.0",
|
||||||
"@ai-sdk/gateway": "^3.0.0",
|
"@ai-sdk/gateway": "^3.0.0",
|
||||||
"@ai-sdk/google": "^3.0.0",
|
"@ai-sdk/google": "^3.0.0",
|
||||||
"@ai-sdk/google-vertex": "^4.0.16",
|
|
||||||
"@ai-sdk/openai": "^3.0.0",
|
"@ai-sdk/openai": "^3.0.0",
|
||||||
"@ai-sdk/react": "^3.0.1",
|
"@ai-sdk/react": "^3.0.1",
|
||||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
"@extractus/article-extractor": "^8.0.18",
|
|
||||||
"@formatjs/intl-localematcher": "^0.7.2",
|
"@formatjs/intl-localematcher": "^0.7.2",
|
||||||
"@langfuse/client": "^4.4.9",
|
"@langfuse/client": "^4.4.9",
|
||||||
"@langfuse/otel": "^4.4.4",
|
"@langfuse/otel": "^4.4.4",
|
||||||
"@langfuse/tracing": "^4.4.9",
|
"@langfuse/tracing": "^4.4.9",
|
||||||
"@next/third-parties": "^16.0.6",
|
"@next/third-parties": "^16.0.6",
|
||||||
"@opennextjs/cloudflare": "1.14.8",
|
"@opennextjs/cloudflare": "1.14.7",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
|
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -70,14 +64,14 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"idb": "^8.0.3",
|
"js-tiktoken": "^1.0.21",
|
||||||
|
"jsdom": "^27.0.0",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"nanoid": "^5.0.0",
|
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.7",
|
||||||
"ollama-ai-provider-v2": "^2.0.0",
|
"ollama-ai-provider-v2": "^1.5.4",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"react": "^19.1.2",
|
"react": "^19.1.2",
|
||||||
@@ -91,15 +85,9 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"turndown": "^7.2.0",
|
|
||||||
"unpdf": "^1.4.0",
|
"unpdf": "^1.4.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
|
|
||||||
"lightningcss": "^1.30.2",
|
|
||||||
"lightningcss-linux-x64-gnu": "^1.30.2"
|
|
||||||
},
|
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,jsx,tsx,json,css}": [
|
"*.{js,ts,jsx,tsx,json,css}": [
|
||||||
"biome check --write --no-errors-on-unmatched",
|
"biome check --write --no-errors-on-unmatched",
|
||||||
@@ -109,20 +97,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "^2.3.10",
|
"@biomejs/biome": "^2.3.10",
|
||||||
"@playwright/test": "^1.57.0",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@testing-library/react": "^16.3.1",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/negotiator": "^0.6.4",
|
"@types/negotiator": "^0.6.4",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/turndown": "^5.0.6",
|
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
@@ -131,15 +112,12 @@
|
|||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite-tsconfig-paths": "^6.0.3",
|
|
||||||
"vitest": "^4.0.16",
|
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "^4.60.0"
|
"wrangler": "4.54.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@openrouter/ai-sdk-provider": {
|
"@openrouter/ai-sdk-provider": {
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "next-ai-drawio",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "AI-powered Draw.io diagram generation with real-time browser preview. Create flowcharts, architecture diagrams, and more through natural language.",
|
|
||||||
"author": {
|
|
||||||
"name": "DayuanJiang"
|
|
||||||
},
|
|
||||||
"repository": "https://github.com/DayuanJiang/next-ai-draw-io",
|
|
||||||
"homepage": "https://next-ai-drawio.jiang.jp",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"drawio": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["@next-ai-drawio/mcp-server@latest"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# Next AI Draw.io - Claude Code Plugin
|
|
||||||
|
|
||||||
AI-powered Draw.io diagram generation with real-time browser preview for Claude Code.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### From Plugin Directory (Coming Soon)
|
|
||||||
|
|
||||||
Once approved, install via:
|
|
||||||
```
|
|
||||||
/plugin install next-ai-drawio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude --plugin-dir /path/to/packages/claude-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
Or add the MCP server directly:
|
|
||||||
```bash
|
|
||||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
|
|
||||||
- **Version History**: Restore previous diagram versions with visual thumbnails
|
|
||||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
|
||||||
- **Export**: Save diagrams as `.drawio` files
|
|
||||||
- **Self-contained**: Embedded server, no external dependencies required
|
|
||||||
|
|
||||||
## Use Case Examples
|
|
||||||
|
|
||||||
### 1. Create Architecture Diagrams
|
|
||||||
|
|
||||||
```
|
|
||||||
Generate an AWS architecture diagram with Lambda, API Gateway, DynamoDB,
|
|
||||||
and S3 for a serverless REST API
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Flowchart Generation
|
|
||||||
|
|
||||||
```
|
|
||||||
Create a flowchart showing the CI/CD pipeline: code commit -> build ->
|
|
||||||
test -> staging deploy -> production deploy with approval gates
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. System Design Documentation
|
|
||||||
|
|
||||||
```
|
|
||||||
Design a microservices e-commerce system with user service, product catalog,
|
|
||||||
shopping cart, order processing, and payment gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Cloud Architecture (AWS/GCP/Azure)
|
|
||||||
|
|
||||||
```
|
|
||||||
Generate a GCP architecture diagram with Cloud Run, Cloud SQL, and
|
|
||||||
Cloud Storage for a web application
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Sequence Diagrams
|
|
||||||
|
|
||||||
```
|
|
||||||
Create a sequence diagram showing OAuth 2.0 authorization code flow
|
|
||||||
between user, client app, auth server, and resource server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Tools
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `start_session` | Opens browser with real-time diagram preview |
|
|
||||||
| `create_new_diagram` | Create a new diagram from XML |
|
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations |
|
|
||||||
| `get_diagram` | Get the current diagram XML |
|
|
||||||
| `export_diagram` | Save diagram to a `.drawio` file |
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
```
|
|
||||||
Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Ask Claude to create a diagram
|
|
||||||
2. Claude calls `start_session` to open a browser window
|
|
||||||
3. Claude generates diagram XML and sends it to the browser
|
|
||||||
4. You see the diagram update in real-time!
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
|
||||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- [Homepage](https://next-ai-drawio.jiang.jp)
|
|
||||||
- [GitHub Repository](https://github.com/DayuanJiang/next-ai-draw-io)
|
|
||||||
- [MCP Server Documentation](https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Apache-2.0
|
|
||||||
@@ -64,24 +64,6 @@ Add to Cursor MCP config (`~/.cursor/mcp.json`):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cline (VS Code Extension)
|
|
||||||
|
|
||||||
1. Click the **MCP Servers** icon in Cline's top menu bar
|
|
||||||
2. Select the **Configure** tab
|
|
||||||
3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`
|
|
||||||
4. Add the drawio server:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"drawio": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["@next-ai-drawio/mcp-server@latest"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Claude Code CLI
|
### Claude Code CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -108,14 +90,14 @@ Use the standard MCP configuration with:
|
|||||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||||
- **Export**: Save diagrams as `.drawio` files
|
- **Export**: Save diagrams as `.drawio` files
|
||||||
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
|
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `start_session` | Opens browser with real-time diagram preview |
|
| `start_session` | Opens browser with real-time diagram preview |
|
||||||
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
| `display_diagram` | Create a new diagram from XML |
|
||||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
||||||
| `get_diagram` | Get the current diagram XML |
|
| `get_diagram` | Get the current diagram XML |
|
||||||
| `export_diagram` | Save diagram to a `.drawio` file |
|
| `export_diagram` | Save diagram to a `.drawio` file |
|
||||||
@@ -148,33 +130,6 @@ Use the standard MCP configuration with:
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
|
|
||||||
|
|
||||||
### Private Deployment (Self-hosted draw.io)
|
|
||||||
|
|
||||||
For security-sensitive environments that require private deployment of draw.io:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"drawio": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["@next-ai-drawio/mcp-server@latest"],
|
|
||||||
"env": {
|
|
||||||
"DRAWIO_BASE_URL": "https://drawio.your-company.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can deploy your own draw.io instance using the official Docker image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 8080:8080 jgraph/drawio
|
|
||||||
```
|
|
||||||
|
|
||||||
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
24
packages/mcp-server/package-lock.json
generated
24
packages/mcp-server/package-lock.json
generated
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.12",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.12",
|
"version": "0.1.6",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"linkedom": "^0.18.0",
|
"linkedom": "^0.18.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"next-ai-drawio-mcp": "dist/index.js"
|
"next-ai-drawio-mcp": "dist/index.js"
|
||||||
@@ -481,9 +481,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.25.2",
|
"version": "1.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||||
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
|
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
@@ -520,9 +520,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.6",
|
"version": "24.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
||||||
"integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2051,9 +2051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.5",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user