Compare commits

..
Author SHA1 Message Date
dayuan.jiang cef3a8de8f chore: add type check and unit test git hooks 2026-01-05 01:12:53 +09:00
dayuan.jiang fc2086e408 docs: run both unit and e2e tests before PR 2026-01-05 01:06:40 +09:00
dayuan.jiang 6fc09d899c test: remove flaky XML paste test 2026-01-05 01:00:06 +09:00
dayuan.jiang 129b74a1b0 fix: make persistence tests more reliable
- Remove expectBeforeAndAfterReload from mocked API tests
- Add explicit test.step() for before/after reload checks
- Add retry config for flaky clipboard tests
- Add sleep after reload for language persistence test
2026-01-05 00:53:48 +09:00
dayuan.jiang 828bf43e31 refactor: add shared fixtures and test.step() patterns
- Add tests/e2e/lib/fixtures.ts with shared test helpers
- Add tests/e2e/fixtures/diagrams.ts with XML test data
- Add expectBeforeAndAfterReload() helper for persistence tests
- Add test.step() for better test reporting in complex tests
- Consolidate mock helpers into fixtures module
- Reduce code duplication across 17 test files
2026-01-05 00:37:40 +09:00
dayuan.jiang 948cc4666d fix: improve E2E test assertions and remove silent skips
- Replace silent test.skip() with explicit conditional skips
- Add actual persistence assertion after page reload
- Use data-testid selector for new chat button test
2026-01-04 23:05:31 +09:00
dayuan.jiang d517268dbe fix: improve test infrastructure based on PR review
- Fix double build in CI: remove redundant build from playwright webServer
- Export chat helpers from shared module for proper unit testing
- Replace waitForTimeout with explicit waits in E2E tests
- Add data-testid attributes to settings and new chat buttons
- Add list reporter for CI to show failures in logs
- Add Playwright browser caching to speed up CI
- Add vitest coverage configuration
- Fix conditional test assertions to use test.skip() instead of silent pass
- Remove unused variables flagged by linter
2026-01-04 22:25:50 +09:00
dayuan.jiang d1e5fc1440 docs: add testing section to CONTRIBUTING.md 2026-01-04 21:24:59 +09:00
dayuan.jiang 6ce7e3378b refactor: extract shared test helpers and improve error assertions
- Create tests/e2e/lib/helpers.ts with shared SSE mock functions
- Add proper error UI assertions to error-handling.spec.ts
- Remove waitForTimeout calls in favor of real assertions
- Update 6 test files to use shared helpers
2026-01-04 21:14:08 +09:00
dayuan.jiang ddc35e1bb2 test: add comprehensive E2E tests for all major features
- Error handling tests (API errors, rate limits, network timeout, truncated XML)
- Multi-turn conversation tests (sequential requests, history preservation)
- File upload tests (upload button, file preview, sending with message)
- Theme switching tests (dark mode toggle, persistence, system preference)
- Language switching tests (EN/JA/ZH, persistence, locale URLs)
- Iframe interaction tests (draw.io loading, toolbar, diagram rendering)
- Copy/paste tests (chat input, XML input, special characters)
- History restore tests (new chat, persistence, browser navigation)
2026-01-04 20:47:31 +09:00
dayuan.jiang 92514ad6f5 test(e2e): add diagram generation tests with mocked AI responses
- Add tests for generate, edit, and append diagram operations
- Use SSE mocked responses matching AI SDK UI message stream format
- Generate mxCell XML directly in tests for deterministic assertions
- Tests verify tool card rendering and 'Complete' badge state
2026-01-04 20:28:42 +09:00
dayuan.jiang c2fbfc1a9d fix: resolve lint errors for CI 2026-01-04 20:06:45 +09:00
github-actions[bot] a80e0fca0a style: auto-format with Biome 2026-01-04 11:00:03 +00:00
dayuan.jiang f415d457d8 test: fix E2E test issues from review
Fixes based on Gemini and Codex review:
- Remove brittle nth(1) selector in keyboard tests
- Remove waitForTimeout(500) race condition
- Remove if(isVisible) silent skip patterns
- Add proper assertions instead of no-op checks
- Remove expect(count >= 0) that always passes
- Remove unused hasProviderUI variable

All 14 E2E tests and 39 unit tests pass.
2026-01-04 19:58:48 +09:00
dayuan.jiang ca86c9ebc6 test: add more E2E tests for UI components
- Chat panel tests (interactive elements, iframe)
- Settings tests (dark mode, language, draw.io theme)
- Save dialog tests (buttons exist)
- History dialog tests
- Model config tests
- Keyboard interaction tests
- Upload area tests

Total: 15 E2E tests, all passing
2026-01-04 19:46:37 +09:00
dayuan.jiang 74fbb629e7 test: add Vitest and Playwright testing infrastructure
- Add Vitest for unit tests (39 tests)
  - cached-responses.test.ts
  - ai-providers.test.ts
  - chat-helpers.test.ts
  - utils.test.ts
- Add Playwright for E2E tests (3 smoke tests)
  - Homepage load
  - Japanese locale
  - Settings dialog
- Add CI workflow (.github/workflows/test.yml)
- Add vitest.config.mts and playwright.config.ts
- Update .gitignore for test artifacts
2026-01-04 19:33:02 +09:00
144 changed files with 10714 additions and 22852 deletions
-19
View File
@@ -47,10 +47,6 @@ To run tests with UI mode:
npx playwright test --ui
```
## Before You Start
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
## Pull Requests
1. Create a feature branch
@@ -61,21 +57,6 @@ For **significant changes** (new features, architecture changes, large refactors
CI will run the full test suite on your PR.
## Using AI Tools
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
1. **Review the code** — understand what was generated, don't just commit blindly
2. **Write a PR description** — explain what changed and why
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
## Code Review
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
- **Valid suggestions**: Please address them in your code.
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
## Issues
Include steps to reproduce, expected vs actual behavior, and AI provider used.
+1 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["after 10am on the first day of the month"],
"schedule": ["after 10am on saturday"],
"timezone": "Asia/Tokyo",
"packageRules": [
{
@@ -13,7 +13,6 @@
{
"matchUpdateTypes": ["major"],
"matchPackagePatterns": ["*"],
"groupName": "major dependencies",
"automerge": false
},
{
+1 -3
View File
@@ -23,9 +23,7 @@ jobs:
node-version: '24'
- name: Run Biome format
# Pin to the version in package.json so CI matches local/pre-commit
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
+2
View File
@@ -40,3 +40,5 @@ jobs:
- name: Build
run: npm run build
- name: Security audit
run: npm audit --audit-level=high --omit=dev
+6 -73
View File
@@ -11,8 +11,7 @@ on:
required: false
jobs:
# Mac and Linux: Build and publish directly (no signing needed)
build-mac-linux:
build:
permissions:
contents: write
strategy:
@@ -21,9 +20,13 @@ jobs:
include:
- os: macos-latest
platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest
platform: linux
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -34,80 +37,10 @@ jobs:
node-version: 24
cache: "npm"
- name: Download draw.io static files for offline use
run: |
rm -rf public/drawio
git clone --depth 1 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
- name: Build and publish
- name: Build and publish Electron app
run: npm run dist:${{ matrix.platform }}
env:
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 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 }}
+6 -6
View File
@@ -11,10 +11,10 @@ jobs:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
@@ -32,10 +32,10 @@ jobs:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
@@ -44,7 +44,7 @@ jobs:
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v5
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
@@ -67,7 +67,7 @@ jobs:
CI: true
- name: Upload test results
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
-11
View File
@@ -56,8 +56,6 @@ push-via-ec2.sh
/dist-electron/
/release/
/electron-standalone/
# Draw.io static files (downloaded during CI build)
public/drawio/
*.dmg
*.exe
*.AppImage
@@ -70,12 +68,3 @@ CLAUDE.md
# edgeone
.edgeone
opencode.json
ai-models.json
# local backups
*.bak
.gstack/
# admin panel settings (contains secrets)
data/
-13
View File
@@ -9,7 +9,6 @@ WORKDIR /app
COPY package.json package-lock.json* ./
# Install dependencies
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
RUN npm install
# Stage 2: Build application
@@ -31,15 +30,6 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
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}
# Control sponsorship and self-hosting messaging in quota notifications.
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
ARG NEXT_PUBLIC_SELFHOSTED=""
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
# Build Next.js application (standalone mode)
RUN npm run build
@@ -61,9 +51,6 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Writable dir for admin panel settings (data/settings.json)
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
+19 -36
View File
@@ -19,7 +19,7 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -31,7 +31,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Table of Contents](#table-of-contents)
- [Examples](#examples)
- [Features](#features)
- [MCP Server](#mcp-server)
- [MCP Server (Preview)](#mcp-server-preview)
- [Claude Code CLI](#claude-code-cli)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
@@ -40,14 +40,11 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Installation](#installation)
- [Deployment](#deployment)
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
- [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support)
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
- [Admin Panel](#admin-panel)
- [How It Works](#how-it-works)
- [Support \& Contact](#support--contact)
- [FAQ](#faq)
- [Star History](#star-history)
## Examples
@@ -65,24 +62,24 @@ Here are some example prompts and their generated diagrams:
</tr>
<tr>
<td width="50%" valign="top">
<strong>RAG Technique Diagram</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
<strong>GCP architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
</td>
<td width="50%" valign="top">
<strong>Authentication using React and AWS</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
<strong>AWS architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>Open Innovation</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
<strong>Azure architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
</td>
<td width="50%" valign="top">
<strong>Cat sketch</strong><br />
<strong>Cat sketch prompt</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
</td>
@@ -101,7 +98,9 @@ Here are some example prompts and their generated diagrams:
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
## MCP Server
## MCP Server (Preview)
> **Preview Feature**: This feature is experimental and may not be stable.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
@@ -186,7 +185,7 @@ Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/doc
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
### Deploy on Vercel
### Deploy on Vercel (Recommended)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
@@ -202,18 +201,16 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
## Multi-Provider Support
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock (default)
- OpenAI
- Anthropic
- Google AI
- Google Vertex AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
@@ -222,16 +219,6 @@ 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.
### 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.
### Admin Panel
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
**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.
@@ -250,7 +237,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
## Support & Contact
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
**Special thanks to [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
@@ -258,10 +245,6 @@ For support or inquiries, please open an issue on the GitHub repository or conta
- Email: me[at]jiang.jp
## FAQ
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
+92 -78
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "关于 - Next AI Draw.io",
@@ -10,7 +10,18 @@ export const metadata: Metadata = {
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() {
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 (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -78,7 +89,7 @@ export default function AboutCN() {
<p>
{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -87,7 +98,7 @@ export default function AboutCN() {
</a>
{" "}
<span className="font-semibold text-amber-700">
glm-4.7
K2-thinking
</span>{" "}
{" "}
<span className="font-semibold text-amber-700">
@@ -97,21 +108,40 @@ export default function AboutCN() {
</p>
</div>
{/* Invite Poster */}
<div className="text-center mb-5">
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/volcengine-invite.png"
alt="火山引擎方舟 Coding Plan"
width={300}
height={400}
className="mx-auto rounded-lg"
/>
</a>
{/* 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 */}
@@ -175,109 +205,95 @@ export default function AboutCN() {
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ResNet50模型架构动画
Transformer连接器
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
<strong></strong>
<strong></strong>Transformer架构图
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50模型架构"
src="/animated_connectors.svg"
alt="带动画连接器的Transformer架构"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Diagram Grid */}
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG技术
GCP架构
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
<strong></strong> 使
<strong>GCP图标</strong>
GCP架构图
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/gcp_demo.svg"
alt="GCP架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
React和AWS认证流程
AWS架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
<strong></strong> 使
<strong>AWS图标</strong>
AWS架构图
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="认证架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/aws_demo.svg"
alt="AWS架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Scrum流程
Azure架构图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
<strong></strong> 使
<strong>Azure图标</strong>
Azure架构图
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="敏捷Scrum流程图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/azure_demo.svg"
alt="Azure架构图"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
<strong></strong>{" "}
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="开放式创新图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/cat_demo.svg"
alt="猫咪绘图"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
@@ -308,7 +324,7 @@ export default function AboutCN() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -323,13 +339,11 @@ export default function AboutCN() {
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>{" "}
@@ -343,7 +357,7 @@ export default function AboutCN() {
<p className="text-gray-700 mb-4 font-semibold">
{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
+95 -63
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "概要 - Next AI Draw.io",
@@ -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() {
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 (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -86,7 +97,7 @@ export default function AboutJA() {
<p>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -95,7 +106,7 @@ export default function AboutJA() {
</a>
{" "}
<span className="font-semibold text-amber-700">
glm-4.7
K2-thinking
</span>{" "}
使{" "}
<span className="font-semibold text-amber-700">
@@ -105,6 +116,42 @@ export default function AboutJA() {
</p>
</div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
使
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
/
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{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 */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
@@ -168,109 +215,96 @@ export default function AboutJA() {
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ResNet50モデルアーキテクチャアニメーション
Transformerコネクタ
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
<strong></strong>{" "}
<strong></strong>
Transformerアーキテクチャ図を作成してください
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50モデルアーキテクチャ"
src="/animated_connectors.svg"
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Diagram Grid */}
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG技術ダイアグラム
GCPアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
<strong></strong>{" "}
<strong>GCPアイコン</strong>
使GCPアーキテクチャ図を生成してください
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAGアーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/gcp_demo.svg"
alt="GCPアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ReactとAWSによる認証
AWSアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
<strong></strong>{" "}
<strong>AWSアイコン</strong>
使AWSアーキテクチャ図を生成してください
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="認証アーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/aws_demo.svg"
alt="AWSアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Azureアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
<strong></strong>{" "}
<strong>Azureアイコン</strong>
使Azureアーキテクチャ図を生成してください
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="アジャイルスクラム図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/azure_demo.svg"
alt="Azureアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
<strong></strong>{" "}
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="オープンイノベーション図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/cat_demo.svg"
alt="猫の絵"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
@@ -305,7 +339,7 @@ export default function AboutJA() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -320,13 +354,11 @@ export default function AboutJA() {
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>
@@ -340,7 +372,7 @@ export default function AboutJA() {
<p className="text-gray-700 mb-4 font-semibold">
APIトークン使用を支援してくださった{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
+97 -62
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "About - Next AI Draw.io",
@@ -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() {
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 (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -87,7 +98,7 @@ export default function About() {
Great news! Thanks to the generous
sponsorship from{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -96,7 +107,7 @@ export default function About() {
</a>
, the demo site now uses the powerful{" "}
<span className="font-semibold text-amber-700">
glm-4.7
K2-thinking
</span>{" "}
model for better diagram generation! Sign up
via the link to get{" "}
@@ -107,6 +118,42 @@ export default function About() {
</p>
</div>
{/* Usage Limits */}
<p className="text-sm text-gray-600 mb-3">
Please note the current usage limits:
</p>
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyRequestLimit)}
</p>
<p className="text-xs text-gray-500">
requests/day
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{formatNumber(dailyTokenLimit)}
</p>
<p className="text-xs text-gray-500">
tokens/day
</p>
</div>
<div className="text-center p-3 bg-white/60 rounded-lg">
<p className="text-lg font-bold text-amber-600">
{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 */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
@@ -182,109 +229,99 @@ export default function About() {
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
{/* Animated Transformer */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Animated ResNet50 Model Architecture
Animated Transformer Connectors
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
<strong>animated connector</strong> diagram of
transformer&apos;s architecture.
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="Architecture diagram for ResNet50 model"
src="/animated_connectors.svg"
alt="Transformer Architecture with Animated Connectors"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Diagram Grid */}
{/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG Technique Diagram
GCP Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
<strong>Prompt:</strong> Generate a GCP
architecture diagram with{" "}
<strong>GCP icons</strong>. Users connect to
a frontend hosted on an instance.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/gcp_demo.svg"
alt="GCP Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Authentication using React and AWS
AWS Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
<strong>Prompt:</strong> Generate an AWS
architecture diagram with{" "}
<strong>AWS icons</strong>. Users connect to
a frontend hosted on an instance.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="Authentication Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/aws_demo.svg"
alt="AWS Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Agile Scrum Process
Azure Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
<strong>Prompt:</strong> Generate an Azure
architecture diagram with{" "}
<strong>Azure icons</strong>. Users connect
to a frontend hosted on an instance.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="Agile Scrum Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/azure_demo.svg"
alt="Azure Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Open Innovation
Cat Sketch
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
<strong>Prompt:</strong> Draw a cute cat for
me.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="Open Innovation Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
src="/cat_demo.svg"
alt="Cat Drawing"
width={240}
height={240}
className="mx-auto"
/>
</div>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
@@ -321,7 +358,7 @@ export default function About() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -336,13 +373,11 @@ export default function About() {
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on
@@ -358,7 +393,7 @@ export default function About() {
<p className="text-gray-700 mb-4 font-semibold">
Special thanks to{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
-65
View File
@@ -1,65 +0,0 @@
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
export type SecretValue = { isSet: true; hint: string }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
}
export interface SettingState {
key: string
source: "file" | "env" | "default"
value: string | SecretValue | null
}
export type SettingsMap = Record<string, SettingState>
// Editable text of a saved setting; secrets have none (write-only)
export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
// Admin provider in client state. Secret fields hold either a masked
// marker (unchanged) or a plaintext string (new value).
export interface AdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string | SecretValue
baseUrl?: string
awsAccessKeyId?: string | SecretValue
awsSecretAccessKey?: string | SecretValue
awsRegion?: string
vertexApiKey?: string | SecretValue
models: string[]
isDefault?: boolean
}
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
provider: ProviderName
models: string[]
isDefault: boolean
}
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
const res = await fetch(getApiEndpoint(path), {
...init,
headers: {
...init?.headers,
"x-admin-password": pw,
...(init?.body ? { "Content-Type": "application/json" } : {}),
},
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.error || `Request failed (${res.status})`)
}
return data
}
-609
View File
@@ -1,609 +0,0 @@
import {
AlertCircle,
Check,
Loader2,
Plus,
Star,
Trash2,
X,
Zap,
} from "lucide-react"
import { useState } from "react"
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
import { ProviderLogo } from "@/components/provider-logo"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import {
FIXED_CRED_PROVIDERS,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
} from "./admin-shared"
import { SecretInput } from "./setting-field"
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
function ProviderDetail({
provider,
disabled,
password,
onUpdate,
onDelete,
}: {
provider: AdminProvider
disabled: boolean
password: string
onUpdate: (patch: Partial<AdminProvider>) => void
onDelete: () => void
}) {
const dict = useDictionary()
const [modelInput, setModelInput] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResults, setTestResults] = useState<
Record<string, { ok: boolean; message: string }>
>({})
const info = PROVIDER_INFO[provider.provider]
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
(m) => !provider.models.includes(m),
)
const addModel = (modelId: string) => {
const trimmed = modelId.trim()
if (!trimmed || provider.models.includes(trimmed)) return
onUpdate({ models: [...provider.models, trimmed] })
setModelInput("")
}
const testModel = async (modelId: string) => {
setTesting(modelId)
try {
const data = await adminFetch("/api/admin/test-model", password, {
method: "POST",
body: JSON.stringify({ provider, modelId }),
})
setTestResults((prev) => ({
...prev,
[modelId]: data.valid
? {
ok: true,
message: formatMessage(dict.admin.testOk, {
ms: data.responseTime,
}),
}
: {
ok: false,
message: data.error || dict.admin.testFailed,
},
}))
} catch (err) {
setTestResults((prev) => ({
...prev,
[modelId]: {
ok: false,
message:
err instanceof Error
? err.message
: dict.admin.testFailed,
},
}))
} finally {
setTesting(null)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={provider.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">{info.label}</h3>
<p className="text-xs text-muted-foreground">
{provider.models.length === 0
? dict.admin.noModelsConfigured
: formatMessage(
provider.models.length === 1
? dict.admin.modelCount
: dict.admin.modelCountPlural,
{ count: provider.models.length },
)}
</p>
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Star
className={cn(
"h-3.5 w-3.5",
provider.isDefault &&
"fill-amber-400 text-amber-400",
)}
aria-hidden="true"
/>
{dict.admin.default}
<Switch
checked={!!provider.isDefault}
disabled={disabled}
aria-label={dict.admin.setAsDefault}
onCheckedChange={(checked) =>
onUpdate({ isDefault: checked })
}
/>
</label>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
{dict.admin.delete}
</Button>
</div>
{/* Credentials (shared with the user ModelConfigDialog) */}
<ProviderCredentialsFields
provider={provider.provider}
name={provider.name}
baseUrl={provider.baseUrl}
awsRegion={provider.awsRegion}
disabled={disabled}
onChange={(field, value) => onUpdate({ [field]: value })}
renderSecret={({ field, id }) => (
// Bare id keeps the shared component's <Label htmlFor={id}>
// associated; only one ProviderDetail is mounted at a time.
<SecretInput
id={id}
keepOnEmpty
value={provider[field]}
disabled={disabled}
onChange={(v) => onUpdate({ [field]: v })}
/>
)}
/>
{/* Models */}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{dict.admin.models}
</Label>
<div className="flex items-center gap-1.5">
<Input
value={modelInput}
disabled={disabled}
placeholder={dict.admin.modelIdPlaceholder}
spellCheck={false}
className="h-8 w-48 font-mono text-xs"
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addModel(modelInput)
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={disabled || !modelInput.trim()}
aria-label={dict.admin.addModel}
onClick={() => addModel(modelInput)}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
{suggestions.length > 0 && (
<Select
disabled={disabled}
onValueChange={(v) => addModel(v)}
>
<SelectTrigger className="h-8 w-28 text-xs">
{dict.admin.suggested}
</SelectTrigger>
<SelectContent className="max-h-72">
{suggestions.map((m) => (
<SelectItem
key={m}
value={m}
className="font-mono text-xs"
>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="overflow-hidden rounded-lg border">
{provider.models.length === 0 ? (
<p className="p-5 text-center text-sm text-muted-foreground">
{dict.admin.addProviderToOfferModels}
</p>
) : (
<ul className="divide-y">
{provider.models.map((modelId, index) => {
const result = testResults[modelId]
return (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{provider.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
{result && (
<span
className={cn(
"flex items-center gap-1 text-xs",
result.ok
? "text-green-600 dark:text-green-400"
: "text-destructive",
)}
>
{result.ok ? (
<Check
className="h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<AlertCircle
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="max-w-48 truncate">
{result.message}
</span>
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={
disabled || testing !== null
}
onClick={() =>
void testModel(modelId)
}
>
{testing === modelId ? (
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Zap
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="ml-1">
{dict.admin.test}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={disabled}
aria-label={formatMessage(
dict.admin.removeModel,
{ model: modelId },
)}
onClick={() =>
onUpdate({
models: provider.models.filter(
(m) => m !== modelId,
),
})
}
>
<X
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</Button>
</li>
)
})}
</ul>
)}
</div>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage(dict.admin.deleteProviderTitle, {
name: provider.name || info.label,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.admin.deleteProviderDesc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.admin.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
setDeleteOpen(false)
onDelete()
}}
>
{dict.admin.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export function ModelsSection({
providers,
envProviders,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
disabled: boolean
password: string
onChange: (providers: AdminProvider[]) => void
}) {
const dict = useDictionary()
const [selectedId, setSelectedId] = useState<string | null>(
providers[0]?.id ?? null,
)
const selected = providers.find((p) => p.id === selectedId)
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = {
id: crypto.randomUUID(),
provider,
models: [],
isDefault: providers.length === 0,
}
onChange([...providers, newProvider])
setSelectedId(newProvider.id)
}
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
onChange(
providers.map((p) => {
if (p.id !== id) {
// Only one default at a time
return patch.isDefault ? { ...p, isDefault: false } : p
}
return { ...p, ...patch }
}),
)
}
const deleteProvider = (id: string) => {
const next = providers.filter((p) => p.id !== id)
onChange(next)
setSelectedId(next[0]?.id ?? null)
}
return (
<div className="flex min-h-72 flex-col sm:flex-row">
{/* Provider list */}
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
<div className="flex-1 space-y-1 p-2">
{providers.length === 0 && envProviders.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
{dict.admin.addProviderHint}
</p>
)}
{envProviders.map((p) => (
<button
key={`env:${p.name}`}
type="button"
onClick={() => setSelectedId(`env:${p.name}`)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === `env:${p.name}` &&
"bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name}
</span>
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
{dict.admin.sourceEnv}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === p.id && "bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name || PROVIDER_INFO[p.provider].label}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
</div>
<div className="border-t p-2">
<Select
disabled={disabled}
onValueChange={(v) => addProvider(v as ProviderName)}
>
<SelectTrigger className="w-full">
<Plus
className="mr-1 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
{dict.modelConfig.addProvider}
</SelectTrigger>
<SelectContent className="max-h-72">
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
(p) => {
// Global-credential providers already in
// the env config can't be added here —
// panel credentials would override theirs
const envBlocked =
FIXED_CRED_PROVIDERS.includes(p) &&
envProviders.some(
(e) => e.provider === p,
)
return (
<SelectItem
key={p}
value={p}
disabled={envBlocked}
>
<div className="flex items-center gap-2">
<ProviderLogo provider={p} />
{PROVIDER_INFO[p].label}
{envBlocked && (
<span className="text-xs text-muted-foreground">
{
dict.admin
.managedViaEnv
}
</span>
)}
</div>
</SelectItem>
)
},
)}
</SelectContent>
</Select>
</div>
</div>
{/* Detail */}
<div className="min-w-0 flex-1 p-4">
{selected ? (
<ProviderDetail
key={selected.id}
provider={selected}
disabled={disabled}
password={password}
onUpdate={(patch) => updateProvider(selected.id, patch)}
onDelete={() => deleteProvider(selected.id)}
/>
) : selectedEnv ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={selectedEnv.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">
{selectedEnv.name}
</h3>
<p className="text-xs text-muted-foreground">
{dict.admin.envReadOnly}
</p>
</div>
</div>
<div className="overflow-hidden rounded-lg border">
<ul className="divide-y">
{selectedEnv.models.map((modelId, index) => (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{selectedEnv.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="py-12 text-center text-sm text-muted-foreground">
{dict.admin.selectProviderHint}
</p>
)}
</div>
</div>
)
}
-610
View File
@@ -1,610 +0,0 @@
"use client"
import {
AlertTriangle,
Check,
Loader2,
LockKeyhole,
ShieldCheck,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import {
SETTING_GROUPS,
SETTINGS_BY_GROUP,
} from "@/lib/admin/settings-registry"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
isSecretValue,
SESSION_PASSWORD_KEY,
type SettingState,
type SettingsMap,
savedTextOf,
} from "./admin-shared"
import { ModelsSection } from "./models-section"
import { SettingField } from "./setting-field"
// ── Page ─────────────────────────────────────────────────────────────
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
export default function AdminPage() {
const dict = useDictionary()
// Localized group title/description, keyed by group id
const groupText = (id: string) =>
(
dict.admin.groups as Record<
string,
{ title: string; description: string } | undefined
>
)[id]
const navItems = NAV_GROUP_IDS.map((id) => ({
id,
title:
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
}))
const [password, setPassword] = useState("")
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
const [authError, setAuthError] = useState("")
const [authLoading, setAuthLoading] = useState(false)
const [writable, setWritable] = useState(true)
// Models section state
const [providers, setProviders] = useState<AdminProvider[]>([])
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
const [savedProviders, setSavedProviders] = useState<string>("[]")
const providersDirty = JSON.stringify(providers) !== savedProviders
// General settings state
const [settings, setSettings] = useState<SettingsMap>({})
const [pending, setPending] = useState<Record<string, string | null>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
{},
)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{
ok: boolean
text: string
} | null>(null)
const [activeGroup, setActiveGroup] = useState("models")
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
const applySettingsResponse = useCallback(
(data: { writable: boolean; settings: SettingState[] }) => {
setWritable(data.writable)
const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s
setSettings(map)
// Seed each toggle once from whether the group has configured
// values; don't stomp a user's explicit toggle on later saves
setEnabledGroups((prev) => {
const next = { ...prev }
for (const group of SETTING_GROUPS) {
if (!group.toggleable || group.id in next) continue
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
}
return next
})
},
[],
)
const applyProvidersResponse = useCallback(
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
},
[],
)
const login = useCallback(
async (pw: string) => {
setAuthLoading(true)
setAuthError("")
try {
const [settingsData, providersData] = await Promise.all([
adminFetch("/api/admin/settings", pw),
adminFetch("/api/admin/providers", pw),
])
applySettingsResponse(settingsData)
applyProvidersResponse(providersData)
setAuthedPassword(pw)
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
} catch (err) {
setAuthError(
err instanceof Error ? err.message : dict.admin.loginFailed,
)
} finally {
setAuthLoading(false)
}
},
[applySettingsResponse, applyProvidersResponse, dict],
)
// Restore session on mount
useEffect(() => {
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
if (stored) void login(stored)
}, [login])
// Warn before leaving with unsaved changes
const hasDirty = dirtyCount > 0
useEffect(() => {
if (!hasDirty) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
// Some browsers only show the prompt when returnValue is set
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [hasDirty])
// Highlight the section currently in view in the sidebar
useEffect(() => {
if (!authedPassword) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top,
)
if (visible[0]) setActiveGroup(visible[0].target.id)
},
{ rootMargin: "-10% 0px -50% 0px" },
)
for (const id of NAV_GROUP_IDS) {
const el = document.getElementById(id)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [authedPassword])
const handleChange = useCallback(
(key: string, value: string | null) => {
setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
})
setPending((prev) => {
const state = settings[key]
const isRevert =
value !== null &&
state?.source === "file" &&
!isSecretValue(state?.value) &&
value === savedTextOf(state)
const isNoop =
value === "" &&
(!state || state.source !== "file") &&
!isSecretValue(state?.value)
if (isRevert || isNoop) {
const next = { ...prev }
delete next[key]
return next
}
return { ...prev, [key]: value === "" ? null : value }
})
},
[settings],
)
// Toggling a group off stages deletion of its saved values so the
// feature actually turns off on save; toggling on drops those deletions.
const handleGroupToggle = useCallback(
(groupId: string, enabled: boolean) => {
setSaveMessage(null)
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
(d) => d.key,
)
setPending((prev) => {
const next = { ...prev }
for (const key of keys) {
if (!enabled) {
// Stage deletion only for values currently set
if (settings[key]?.source !== "default")
next[key] = null
} else if (next[key] === null) {
delete next[key]
}
}
return next
})
},
[settings],
)
const handleSave = useCallback(async () => {
if (!authedPassword || dirtyCount === 0) return
setSaving(true)
setSaveMessage(null)
setErrors({})
try {
if (providersDirty) {
const data = await adminFetch(
"/api/admin/providers",
authedPassword,
{ method: "PUT", body: JSON.stringify({ providers }) },
)
applyProvidersResponse(data)
}
if (Object.keys(pending).length > 0) {
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-admin-password": authedPassword,
},
body: JSON.stringify({ values: pending }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
// Per-field validation errors come back as {errors: {...}}
if (data.errors) {
setErrors(data.errors)
const firstKey = Object.keys(data.errors)[0]
document.getElementById(`setting-${firstKey}`)?.focus()
throw new Error(dict.admin.invalidSettings)
}
throw new Error(
data.error || `Request failed (${res.status})`,
)
}
applySettingsResponse(data)
setPending({})
}
setSaveMessage({
ok: true,
text: dict.admin.saved,
})
setTimeout(() => setSaveMessage(null), 4000)
} catch (err) {
setSaveMessage({
ok: false,
text:
err instanceof Error ? err.message : dict.admin.saveFailed,
})
} finally {
setSaving(false)
}
}, [
authedPassword,
pending,
providers,
providersDirty,
dirtyCount,
applySettingsResponse,
applyProvidersResponse,
dict,
])
// ── Login screen ─────────────────────────────────────────────────
if (!authedPassword) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<form
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
onSubmit={(e) => {
e.preventDefault()
void login(password)
}}
>
<div className="flex items-center gap-2">
<LockKeyhole
className="h-5 w-5 text-muted-foreground"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-sm text-muted-foreground">
{dict.admin.loginPrompt}
</p>
<div className="space-y-1.5">
<Label htmlFor="admin-password">
{dict.admin.password}
</Label>
<Input
id="admin-password"
name="admin-password"
type="password"
value={password}
autoComplete="current-password"
spellCheck={false}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<p
className={cn(
"text-sm text-destructive",
!authError && "sr-only",
)}
aria-live="polite"
>
{authError}
</p>
<Button
type="submit"
className="w-full"
disabled={authLoading}
>
{authLoading ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.signingIn}
</>
) : (
dict.admin.signIn
)}
</Button>
</form>
</div>
)
}
// ── Settings screen ──────────────────────────────────────────────
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<ShieldCheck
className="h-5 w-5 text-primary"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-xs text-muted-foreground">
{dict.admin.precedence}
</p>
</div>
</header>
{!writable && (
<div className="border-b bg-amber-500/10">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
{dict.admin.notWritable}
</div>
</div>
)}
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
<nav
aria-label={dict.admin.settingGroups}
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
>
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={
activeGroup === item.id
? "true"
: undefined
}
className={cn(
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeGroup === item.id
? "bg-muted font-medium text-foreground"
: "text-muted-foreground",
)}
>
{item.title}
</a>
</li>
))}
</ul>
</nav>
<main className="min-w-0 flex-1 pb-24">
{/* Models section */}
<section aria-labelledby="models" className="mb-10">
<h2
id="models"
className="scroll-mt-20 text-base font-semibold"
>
{dict.admin.models}
</h2>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{dict.admin.modelsDescription}
</p>
<div className="overflow-hidden rounded-lg border bg-card">
<ModelsSection
providers={providers}
envProviders={envProviders}
disabled={!writable || saving}
password={authedPassword}
onChange={(next) => {
setSaveMessage(null)
setProviders(next)
}}
/>
</div>
</section>
{/* Registry-driven groups */}
{SETTING_GROUPS.map((group) => {
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff =
group.toggleable && !enabledGroups[group.id]
const fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id)
const title = gt?.title ?? group.title
return (
<section
key={group.id}
aria-labelledby={group.id}
className="mb-10"
>
<div className="flex items-center justify-between gap-4">
<h2
id={group.id}
className="scroll-mt-20 text-base font-semibold"
>
{title}
</h2>
{group.toggleable && (
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
enabledGroups[group.id]
? "border-primary/30 bg-primary/5 text-primary"
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
>
{enabledGroups[group.id]
? dict.admin.enabled
: dict.admin.disabled}
<Switch
checked={
!!enabledGroups[group.id]
}
disabled={!writable || saving}
aria-label={formatMessage(
dict.admin.enableGroup,
{ group: title },
)}
onCheckedChange={(checked) =>
handleGroupToggle(
group.id,
checked,
)
}
/>
</label>
)}
</div>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{gt?.description ?? group.description}
</p>
<div
className={cn(
"rounded-lg border bg-card px-4",
groupOff &&
"pointer-events-none opacity-50",
)}
>
{defs.map((def) => (
<SettingField
key={def.key}
def={def}
state={settings[def.key]}
pendingValue={pending[def.key]}
error={errors[def.key]}
disabled={fieldsDisabled}
onChange={(v) =>
handleChange(def.key, v)
}
/>
))}
</div>
</section>
)
})}
</main>
</div>
{/* Always-mounted live region so save results are announced */}
<p aria-live="polite" className="sr-only">
{saveMessage?.text ?? ""}
</p>
{(dirtyCount > 0 || saveMessage) && (
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<p
className={cn(
"flex min-w-0 items-center gap-1.5 truncate text-sm",
saveMessage?.ok
? "text-green-600 dark:text-green-400"
: saveMessage
? "text-destructive"
: "text-muted-foreground",
)}
>
{saveMessage?.ok && (
<Check
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
{saveMessage && !saveMessage.ok
? saveMessage.text
: dirtyCount > 0
? dict.admin.unsavedChanges
: saveMessage?.text}
</p>
{dirtyCount > 0 && (
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => {
setPending({})
setErrors({})
setProviders(JSON.parse(savedProviders))
}}
>
{dict.admin.discard}
</Button>
<Button
type="button"
disabled={saving || !writable}
onClick={() => void handleSave()}
>
{saving ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.saving}
</>
) : (
dict.admin.saveChanges
)}
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}
-312
View File
@@ -1,312 +0,0 @@
import { Eye, EyeOff, X } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
isSecretValue,
type SecretValue,
type SettingState,
savedTextOf,
} from "./admin-shared"
// ── Small shared UI bits ─────────────────────────────────────────────
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
const dict = useDictionary()
if (source === "default") return null
return (
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
source === "file"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
title={
source === "file"
? dict.admin.sourceSavedTitle
: dict.admin.sourceEnvTitle
}
>
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
</span>
)
}
export function RestartBadge() {
const dict = useDictionary()
return (
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
{dict.admin.restartRequired}
</span>
)
}
// Secret input: shows masked hint as placeholder, typing replaces.
// With keepOnEmpty, clearing the field reverts to the stored value
// ("keep") instead of deleting it — explicit deletion is via the X button.
export function SecretInput({
id,
value,
disabled,
keepOnEmpty,
onChange,
}: {
id: string
value: string | SecretValue | undefined
disabled?: boolean
keepOnEmpty?: boolean
onChange: (value: string | SecretValue) => void
}) {
const dict = useDictionary()
const [show, setShow] = useState(false)
// The stored marker as it was at mount, to revert to on empty
const [original] = useState(value)
const hadStored = isSecretValue(original)
const text = typeof value === "string" ? value : ""
const placeholder = isSecretValue(value)
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
: dict.admin.notSet
const handleText = (t: string) => {
if (t === "" && keepOnEmpty && hadStored && original) {
onChange(original)
} else {
onChange(t)
}
}
return (
<div className="flex items-center gap-1">
<Input
id={id}
type={show ? "text" : "password"}
value={text}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={placeholder}
className="h-9 font-mono text-xs"
onChange={(e) => handleText(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
onClick={() => setShow((s) => !s)}
>
{show ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
{keepOnEmpty && (hadStored || text) && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={dict.admin.removeValue}
title={dict.admin.removeValueTitle}
onClick={() => onChange("")}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
)
}
// ── General settings field (registry-driven) ─────────────────────────
export function SettingField({
def,
state,
pendingValue,
error,
disabled,
onChange,
}: {
def: SettingDef
state: SettingState | undefined
pendingValue: string | null | undefined
error?: string
disabled: boolean
onChange: (value: string | null) => void
}) {
const dict = useDictionary()
const isDirty = pendingValue !== undefined
const source = state?.source ?? "default"
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
const secretState = state && isSecretValue(state.value) ? state.value : null
// Localized label/description keyed by env var name, falling back to the
// registry's English (the registry stays canonical for the server).
const t = (
dict.admin.settings as Record<
string,
{ label?: string; description?: string } | undefined
>
)[def.key]
const label = t?.label ?? def.label
const description = t?.description ?? def.description
const inputId = `setting-${def.key}`
const errorId = `${inputId}-error`
let control: React.ReactNode
switch (def.type) {
case "boolean": {
// When unset, reflect the built-in runtime default so the toggle
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
const effective =
currentValue !== "" ? currentValue : (def.default ?? "false")
// A saved boolean can be cleared back to its env/default value.
const canClear =
(isDirty && pendingValue !== null) || source === "file"
control = (
<div className="flex items-center gap-3">
<Switch
id={inputId}
checked={effective === "true"}
disabled={disabled}
onCheckedChange={(checked) =>
onChange(checked ? "true" : "false")
}
/>
{canClear && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => onChange(null)}
>
{dict.admin.resetToDefault}
</Button>
)}
</div>
)
break
}
case "enum":
control = (
<Select
value={currentValue || undefined}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger id={inputId} className="w-full max-w-xs">
<SelectValue placeholder={dict.admin.notSet} />
</SelectTrigger>
<SelectContent>
{def.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)
break
case "secret":
control = (
<div className="w-full max-w-md">
<SecretInput
id={inputId}
value={
isDirty
? (pendingValue ?? "")
: (secretState ?? currentValue)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : "")
}
/>
</div>
)
break
case "number":
control = (
<Input
id={inputId}
type="number"
inputMode="numeric"
min={def.min}
max={def.max}
value={currentValue}
disabled={disabled}
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-xs tabular-nums"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
break
default:
control = (
<Input
id={inputId}
type="text"
value={currentValue}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-md"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
}
return (
<div className="border-b border-border/60 py-4 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
<SourceChip source={source} />
{def.restartRequired && <RestartBadge />}
{isDirty && (
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
{dict.admin.modified}
</span>
)}
</div>
{description && (
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
{description}
</p>
)}
{control}
<p
id={errorId}
className={cn(
"text-xs text-destructive",
error ? "mt-1.5" : "sr-only",
)}
aria-live="polite"
>
{error ?? ""}
</p>
</div>
)
}
+2 -15
View File
@@ -41,24 +41,19 @@ export async function generateMetadata({
params: Promise<{ lang: string }>
}): Promise<Metadata> {
const { lang: rawLang } = await params
const lang = (
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
// Default to English metadata
const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
}
const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
"zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
}
return {
@@ -85,14 +80,7 @@ export async function generateMetadata({
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale:
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
images: [
{
url: "/architecture.png",
@@ -127,7 +115,6 @@ export async function generateMetadata({
en: "/en",
zh: "/zh",
ja: "/ja",
"zh-Hant": "/zh-Hant",
},
},
}
+36 -36
View File
@@ -4,37 +4,32 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
const {
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const router = useRouter()
const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
@@ -54,7 +49,7 @@ export default function Home() {
}
const savedUi = localStorage.getItem("drawio-theme")
if (isDrawioTheme(savedUi)) {
if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi)
}
@@ -71,15 +66,11 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark)
}
// Detect Electron and use bundled draw.io files for offline use
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
// Include /index.html because Next.js doesn't auto-serve index.html for directories
const electronDetected =
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
!!(window as unknown as { electronAPI?: unknown }).electronAPI
if (electronDetected) {
setIsElectron(true)
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
const savedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
if (savedCloseProtection === "true") {
setCloseProtection(true)
}
setIsLoaded(true)
@@ -99,9 +90,10 @@ export default function Home() {
resetDrawioReady()
}
const handleDrawioUiChange = (theme: DrawioTheme) => {
localStorage.setItem("drawio-theme", theme)
setDrawioUi(theme)
const handleDrawioUiChange = () => {
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady()
}
@@ -154,6 +146,20 @@ export default function Home() {
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 (
<div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup
@@ -177,10 +183,8 @@ export default function Home() {
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
key={`${drawioUi}-${darkMode}-${currentLang}`}
ref={drawioRef}
autosave
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
@@ -191,13 +195,8 @@ export default function Home() {
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark:
darkMode || drawioUi === "dark",
dark: darkMode,
lang: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
}),
}}
/>
</div>
@@ -240,10 +239,11 @@ export default function Home() {
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onDrawioUiChange={handleDrawioUiChange}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>
</Suspense>
</div>
-89
View File
@@ -1,89 +0,0 @@
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProvidersSchema,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
async function payload() {
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
// read-only in the panel; their credentials live in the environment
const envConfig = await loadEnvServerModelsConfig()
const adminProviders = loadAdminProviders()
// A panel default overrides any env default (matches the merge in
// loadRawServerModelsConfig), so env stars must reflect that
const adminHasDefault = adminProviders.some(
(p) => p.isDefault && p.models.length > 0,
)
return {
writable: isSettingsWritable(),
providers: maskAdminProviders(adminProviders),
envProviders:
envConfig?.providers.map((p) => ({
name: p.name,
provider: p.provider,
models: p.models,
isDefault: !!p.default && !adminHasDefault,
})) ?? [],
}
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json(await payload())
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProvidersSchema.safeParse(
(body as { providers?: unknown })?.providers,
)
if (!parsed.success) {
return Response.json(
{
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
},
{ status: 400 },
)
}
const stored = loadAdminProviders()
const merged = mergeSecrets(parsed.data, stored)
const envConfig = await loadEnvServerModelsConfig()
const validationError = validateAdminProviders(merged, envConfig)
if (validationError) {
return Response.json({ error: validationError }, { status: 400 })
}
saveSettings(deriveEnvUpdates(merged, stored))
return Response.json(await payload())
}
-126
View File
@@ -1,126 +0,0 @@
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
import {
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
import {
SETTINGS_BY_KEY,
SETTINGS_REGISTRY,
type SettingDef,
} from "@/lib/admin/settings-registry"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function serializeSettings() {
const fileValues = loadSettings()
return SETTINGS_REGISTRY.map((def) => {
const source = getValueSource(def.key)
const raw =
source === "file"
? fileValues[def.key]
: (getEnvFallback(def.key) ?? null)
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
return { key: def.key, source, value }
})
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json({
writable: isSettingsWritable(),
settings: serializeSettings(),
})
}
function validateValue(def: SettingDef, value: string): string | null {
switch (def.type) {
case "number": {
const num = Number(value)
if (!Number.isFinite(num)) return "Must be a number"
if (def.min !== undefined && num < def.min)
return `Must be at least ${def.min}`
if (def.max !== undefined && num > def.max)
return `Must be at most ${def.max}`
return null
}
case "boolean":
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: { values?: Record<string, unknown> }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
if (!body.values || typeof body.values !== "object") {
return Response.json(
{ error: "Body must contain a values object" },
{ status: 400 },
)
}
const updates: Record<string, string | null> = {}
const errors: Record<string, string> = {}
for (const [key, value] of Object.entries(body.values)) {
const def = SETTINGS_BY_KEY.get(key)
if (!def) {
errors[key] = "Unknown setting"
continue
}
if (value === null || value === "") {
updates[key] = null
continue
}
if (typeof value !== "string") {
errors[key] = "Value must be a string"
continue
}
const error = validateValue(def, value)
if (error) {
errors[key] = error
continue
}
updates[key] = value
}
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 400 })
}
saveSettings(updates)
return Response.json({
writable: true,
settings: serializeSettings(),
})
}
-66
View File
@@ -1,66 +0,0 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
loadAdminProviders,
mergeSecrets,
} from "@/lib/admin/providers"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
// Test a model with the client's CURRENT provider state (which may be
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
// masked {isSet} markers, which are resolved against settings.json — so
// testing works both before and after saving.
export async function POST(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
let body: { provider?: unknown; modelId?: string }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProviderSchema.safeParse(body.provider)
if (!parsed.success || !body.modelId) {
return Response.json(
{ valid: false, error: "Invalid provider or model" },
{ status: 400 },
)
}
// SECURITY: a stored secret is only resolved from an {isSet} marker if
// the endpoint it would be sent to (provider + baseUrl) still matches
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
const sameEndpoint =
stored &&
stored.provider === parsed.data.provider &&
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
const [resolved] = mergeSecrets(
[parsed.data],
sameEndpoint && stored ? [stored] : [],
)
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl,
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
}),
}),
)
}
+14 -107
View File
@@ -14,7 +14,6 @@ import path from "path"
import { z } from "zod"
import {
getAIModel,
SINGLE_SYSTEM_PROVIDERS,
supportsImageInput,
supportsPromptCaching,
} from "@/lib/ai-providers"
@@ -35,7 +34,6 @@ import {
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { findServerModelById } from "@/lib/server-model-config"
import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
@@ -90,12 +88,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
}
}
const body = await req.json()
const { messages, xml, previousXml, sessionId } = body
const customSystemMessage =
typeof body.customSystemMessage === "string"
? body.customSystemMessage.slice(0, 5000)
: ""
const { messages, xml, previousXml, sessionId } = await req.json()
// Get user ID for Langfuse tracking and quota
const userId = getUserIdFromRequest(req)
@@ -124,10 +117,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
const hasOwnApiKey = !!(
req.headers.get("x-ai-provider") &&
(req.headers.get("x-ai-api-key") ||
req.headers.get("x-aws-access-key-id") ||
req.headers.get("x-vertex-api-key"))
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
)
// Skip quota check if: quota disabled, user has own API key, or is anonymous
@@ -178,7 +168,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
// 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
@@ -190,30 +179,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
// 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 | 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 = {
// Server model provider takes precedence over client header
provider: serverModelConfig.provider || provider,
provider,
baseUrl,
apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"),
@@ -222,10 +189,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"),
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 && {
@@ -236,18 +199,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Read minimal style preference from header
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
const {
model,
providerOptions,
headers,
modelId,
provider: resolvedProvider,
} = getAIModel(clientOverrides)
const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides)
// Check if model supports prompt caching
const shouldCache = supportsPromptCaching(modelId)
@@ -257,9 +211,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
: systemMessage
// Extract file parts (images) from the last user message
const fileParts =
@@ -432,51 +383,15 @@ ${userInputText}
}
// System messages with multiple cache breakpoints for optimal caching:
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
// Some providers (e.g. MiniMax) don't support multiple system messages
// Merge them into a single system message for compatibility
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
const isCustomOpenAIEndpoint =
resolvedProvider === "openai" &&
!!(
baseUrl ||
process.env.OPENAI_BASE_URL ||
(serverModelConfig.baseUrlEnv &&
process.env[serverModelConfig.baseUrlEnv])
)
const isSingleSystemProvider =
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
const xmlContext = `${
previousXml
? `Previous diagram XML (before user's last message):
"""xml
${previousXml}
"""
`
: ""
}Current diagram XML (AUTHORITATIVE - the source of truth):
"""xml
${xml || ""}
"""
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
const systemMessages = isSingleSystemProvider
? [
// This allows: if only user message changes, both system caches are reused
// if XML changes, instruction cache is still reused
const systemMessages = [
// Cache breakpoint 1: Instructions (rarely change)
{
role: "system" as const,
content: `${finalSystemMessage}\n\n${xmlContext}`,
},
]
: [
// Cache breakpoint 1: Instructions (+ optional custom instructions)
{
role: "system" as const,
content: finalSystemMessage,
content: systemMessage,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
@@ -486,7 +401,7 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
// Cache breakpoint 2: Previous and Current diagram XML context
{
role: "system" as const,
content: xmlContext,
content: `${previousXml ? `Previous diagram XML (before user's last message):\n"""xml\n${previousXml}\n"""\n\n` : ""}Current diagram XML (AUTHORITATIVE - the source of truth):\n"""xml\n${xml || ""}\n"""\n\nIMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
@@ -499,7 +414,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const result = streamText({
model,
abortSignal: req.signal,
...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}),
@@ -527,13 +441,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "`
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
const repairedInput = jsonrepair(inputToRepair)
@@ -713,7 +620,7 @@ Available libraries:
- Networking: cisco19, network, kubernetes, vvd, rack
- Business: bpmn, lean_mapping
- General: flowchart, basic, arrows2, infographic, sitemap
- UI/Mockups: android, material_design
- UI/Mockups: android
- Enterprise: citrix, sap, mscae, atlassian
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
- Icons: webicons
@@ -758,7 +665,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
if (
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, lean_mapping, openstack, rack`
}
console.error(
`[get_shape_library] Error loading "${library}":`,
-130
View File
@@ -1,130 +0,0 @@
import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
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: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
const headController = new AbortController()
const headTimeout = setTimeout(() => headController.abort(), 3000)
try {
const headResponse = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": USER_AGENT },
signal: headController.signal,
})
const contentType = headResponse.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
},
{ status: 422 },
)
}
} catch (err) {
console.warn(
"HEAD pre-check failed, proceeding with extraction:",
err,
)
} finally {
clearTimeout(headTimeout)
}
// 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": USER_AGENT },
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 },
)
}
}
-14
View File
@@ -1,14 +0,0 @@
import { NextResponse } from "next/server"
import { loadFlattenedServerModels } from "@/lib/server-model-config"
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
// This ensures Docker users can set these values when starting containers
export const dynamic = "force-dynamic"
export async function GET() {
const models = await loadFlattenedServerModels()
return NextResponse.json({
models,
hasConfig: models.length > 0,
})
}
-136
View File
@@ -1,136 +0,0 @@
/**
* API endpoint for VLM-based diagram validation.
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { streamObject } from "ai"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export const maxDuration = 30
interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL
sessionId?: string
}
// Default valid result for disabled/error cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
function createStreamingResponse(result: ValidationResult): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
export async function POST(req: Request): Promise<Response> {
try {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
if (!enableValidation) {
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
const { imageData, sessionId } = body
if (!imageData) {
return Response.json(
{ error: "Missing imageData" },
{ status: 400 },
)
}
// Validate image data format
if (
!imageData.startsWith("data:image/png;base64,") &&
!imageData.startsWith("data:image/")
) {
return Response.json(
{ error: "Invalid image data format" },
{ status: 400 },
)
}
// Get the validation model
let model
try {
model = getValidationModel()
} catch (error) {
console.warn(
"[validate-diagram] Validation model not available:",
error,
)
// Return valid if no vision model is configured
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
const timeout =
Math.max(
1000,
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000
// Stream the VLM response for useObject consumption
const result = streamObject({
model,
schema: ValidationResultSchema,
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
role: "user",
content: [
{
type: "image",
image: imageData,
},
{
type: "text",
text: "Please analyze this diagram for visual quality issues.",
},
],
},
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ object }) => {
if (sessionId && object) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
)
}
},
})
return result.toTextStreamResponse()
} catch (error) {
// Log with session context if available
const errorMessage =
error instanceof Error ? error.message : String(error)
console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
}
+68 -181
View File
@@ -3,18 +3,74 @@ import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
interface ValidateRequest {
provider: string
apiKey: string
@@ -24,8 +80,6 @@ interface ValidateRequest {
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
// Vertex AI specific
vertexApiKey?: string // Express Mode API key
}
export async function POST(req: Request) {
@@ -39,8 +93,6 @@ export async function POST(req: Request) {
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body
if (!provider || !modelId) {
@@ -51,7 +103,7 @@ export async function POST(req: Request) {
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) {
if (baseUrl && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
@@ -69,16 +121,6 @@ export async function POST(req: Request) {
{ status: 400 },
)
}
} else if (provider === "vertexai") {
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(
{ valid: false, error: "API key is required" },
@@ -116,15 +158,6 @@ export async function POST(req: Request) {
break
}
case "vertexai": {
const vertex = createVertex({
apiKey: vertexApiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = vertex(modelId)
break
}
case "azure": {
const azure = createOpenAI({
apiKey,
@@ -169,28 +202,17 @@ export async function POST(req: Request) {
case "siliconflow": {
const sf = createOpenAI({
apiKey,
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
baseURL: baseUrl || "https://api.siliconflow.com/v1",
})
model = sf.chat(modelId)
break
}
case "ollama": {
// SECURITY: Mirror ai-providers.ts guard — only use server
// OLLAMA_API_KEY when the URL is also from server config.
const ollamaApiKey = baseUrl
? apiKey || undefined
: apiKey || process.env.OLLAMA_API_KEY || undefined
const ollamaProvider = createOllama({
baseURL:
baseUrl ||
process.env.OLLAMA_BASE_URL ||
"https://ollama.com/api",
...(ollamaApiKey && {
headers: { Authorization: `Bearer ${ollamaApiKey}` },
}),
const ollama = createOllama({
baseURL: baseUrl || "http://localhost:11434",
})
model = ollamaProvider(modelId)
model = ollama(modelId)
break
}
@@ -229,148 +251,13 @@ export async function POST(req: Request) {
}
case "doubao": {
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
const doubaoBaseUrl =
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
const lowerModelId = modelId.toLowerCase()
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
// ByteDance Doubao uses DeepSeek-compatible API
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
baseURL:
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
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
}
}
case "minimax": {
const rawUrl =
baseUrl ||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
"https://api.minimaxi.com/anthropic"
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
normalizeMiniMaxBaseURL(rawUrl)
if (isAnthropicCompatible) {
const minimax = createAnthropic({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
} else {
const minimax = createOpenAI({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
}
break
}
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
case "glm":
case "qwen":
case "kimi":
case "qiniu":
case "novita": {
const baseURL =
baseUrl ||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
""
if (!baseURL) {
return NextResponse.json(
{
valid: false,
error: `No base URL configured for provider: ${provider}`,
},
{ status: 400 },
)
}
const openai = createOpenAI({
apiKey,
baseURL,
})
model = openai.chat(modelId)
break
}
-13
View File
@@ -244,19 +244,6 @@
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
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 */
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
+3 -70
View File
@@ -1,6 +1,5 @@
import { Cloud } from "lucide-react"
import type { ComponentProps, ElementRef, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import type { ComponentProps, ReactNode } from "react"
import {
Command,
CommandDialog,
@@ -70,45 +69,9 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({
className,
...props
}: ModelSelectorListProps) => {
const listRef = useRef<ElementRef<typeof CommandList>>(null)
const [showShadow, setShowShadow] = useState(false)
useEffect(() => {
const listElement = listRef.current
if (!listElement) return
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = listElement
// Show shadow if there is more content below
// Using a small threshold to handle fractional pixel rendering
setShowShadow(
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
)
}
// Initial check
checkScroll()
// Event listeners
listElement.addEventListener("scroll", checkScroll)
window.addEventListener("resize", checkScroll)
// Observe content changes (e.g. async loading of items)
const observer = new MutationObserver(checkScroll)
observer.observe(listElement, { childList: true, subtree: true })
return () => {
listElement.removeEventListener("scroll", checkScroll)
window.removeEventListener("resize", checkScroll)
observer.disconnect()
}
}, [])
return (
}: ModelSelectorListProps) => (
<div className="relative">
<CommandList
ref={listRef}
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
@@ -117,15 +80,9 @@ export const ModelSelectorList = ({
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
showShadow ? "opacity-100" : "opacity-0",
)}
/>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
</div>
)
}
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
@@ -212,27 +169,3 @@ export const ModelSelectorName = ({
}: ModelSelectorNameProps) => (
<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>
)
+3
View File
@@ -141,6 +141,9 @@ export default function ExamplePanel({
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
+22 -212
View File
@@ -1,26 +1,16 @@
"use client"
import {
BookmarkPlus,
Download,
History,
Image as ImageIcon,
Link,
Loader2,
Send,
Square,
} from "lucide-react"
import type React from "react"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
@@ -28,15 +18,11 @@ import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage"
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"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -147,24 +133,17 @@ function showValidationErrors(errors: string[], dict: any) {
}
}
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps {
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onStop?: () => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string
error?: Error | null
@@ -172,41 +151,28 @@ interface ChatInputProps {
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
showUnvalidatedModels?: boolean
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
onConfigureModels?: () => void
}
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput(
{
export function ChatInput({
input,
status,
onSubmit,
onChange,
onStop,
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false,
shouldFocus = false,
onFocused,
},
ref,
) {
onConfigureModels = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
@@ -216,31 +182,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
// Expose focus method via ref
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus()
},
}))
// Focus the textarea when shouldFocus becomes true
// Use setTimeout to ensure focus happens after drawio iframe settles
useEffect(() => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
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
@@ -257,39 +199,13 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
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) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
@@ -396,53 +312,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
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 (
<form
id="chat-form"
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
@@ -453,23 +324,13 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
{/* File previews */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/>
</div>
)}
@@ -483,7 +344,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
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"
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-end gap-1 px-3 py-2 border-t border-border/50">
@@ -493,9 +354,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
@@ -507,9 +366,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={
isDisabled || !isRealDiagram(chartXML)
}
disabled={isDisabled}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
@@ -528,32 +385,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<ImageIcon className="h-4 w-4" />
</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>
)}
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={isDisabled || !input.trim()}
tooltipContent={dict.templates.saveAsTemplate}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<BookmarkPlus className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
@@ -573,30 +404,24 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
{(status === "streaming" || status === "submitted") &&
onStop ? (
<Button
type="button"
onClick={onStop}
size="sm"
variant="destructive"
className="h-8 w-8 p-0 rounded-xl shadow-sm"
aria-label={dict.chat.stopGeneration}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</Button>
</>
)}
</Button>
</div>
</div>
<HistoryDialog
@@ -618,21 +443,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => setShowSaveAsTemplate(false)}
initialPrompt={input.trim()}
/>
</form>
)
},
)
}
+37 -154
View File
@@ -3,20 +3,19 @@
import type { UIMessage } from "ai"
import {
BookmarkPlus,
Check,
ChevronDown,
ChevronUp,
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import type { MutableRefObject } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
@@ -27,12 +26,8 @@ import {
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { ChatLobby } from "@/components/chat/ChatLobby"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
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 Image from "@/components/image-with-basepath"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
@@ -62,20 +57,20 @@ function getCompleteOperations(
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 {
type: "text" | "file" | "url"
type: "text" | "file"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text" | "url"
fileType?: "pdf" | "text"
}
function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
// Match [PDF: filename], [File: filename], or [URL: url] patterns
// Match [PDF: filename] or [File: filename] patterns
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 match
@@ -86,34 +81,28 @@ function splitTextIntoFileSections(text: string): TextSection[] {
sections.push({ type: "text", content: beforeText })
}
// Add file/url section
const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
// Add file section
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
const filename = match[2].trim()
const content = match[3].trim()
const fileContent = match[3].trim()
sections.push({
type: sectionType === "url" ? "url" : "file",
content: content,
type: "file",
content: fileContent,
filename,
charCount: content.length,
charCount: fileContent.length,
fileType,
})
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()
if (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) {
sections.push({ type: "text", content: text })
}
@@ -132,8 +121,8 @@ const getMessageTextContent = (message: UIMessage): string => {
// Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message)
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
// Strip out [PDF: ...] and [File: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim()
}
@@ -159,12 +148,6 @@ interface ChatMessageDisplayProps {
onSelectSession?: (id: string) => void
onDeleteSession?: (id: string) => void
loadedMessageIdsRef?: MutableRefObject<Set<string>>
validationStates?: Record<string, ValidationState>
onImproveWithSuggestions?: (feedback: string) => void
onSendTemplate?: (
template: import("@/lib/template-storage").Template,
) => void
currentInput?: string
}
export function ChatMessageDisplay({
@@ -182,10 +165,6 @@ export function ChatMessageDisplay({
onSelectSession,
onDeleteSession,
loadedMessageIdsRef,
validationStates = {},
onImproveWithSuggestions,
onSendTemplate,
currentInput = "",
}: ChatMessageDisplayProps) {
const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
@@ -245,10 +224,6 @@ export function ChatMessageDisplay({
const [expandedPdfSections, setExpandedPdfSections] = useState<
Record<string, boolean>
>({})
// Track "Save as Template" dialog
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
string | null
>(null)
const setCopyState = (
messageId: string,
@@ -417,7 +392,6 @@ export function ChatMessageDisplay({
// Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0)
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (messagesEndRef.current && messages.length > 0) {
@@ -431,17 +405,8 @@ export function ChatMessageDisplay({
return
}
// Throttle scroll during streaming to avoid layout thrashing
// Leading + trailing: scroll immediately, then once more after cooldown
if (!scrollThrottleRef.current) {
// Single message added - smooth scroll
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
scrollThrottleRef.current = setTimeout(() => {
scrollThrottleRef.current = null
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
})
}, 150)
}
}
}, [messages])
@@ -464,15 +429,11 @@ export function ChatMessageDisplay({
const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
// Auto-collapse on completion, but only if user hasn't manually toggled
if (state === "output-available") {
setExpandedTools((prev) => {
// Only auto-collapse if not already set (user hasn't interacted)
if (prev[toolCallId] === undefined) {
return { ...prev, [toolCallId]: false }
}
return prev
})
setExpandedTools((prev) => ({
...prev,
[toolCallId]: false,
}))
}
if (
@@ -672,8 +633,6 @@ export function ChatMessageDisplay({
onDeleteSession={onDeleteSession}
setInput={setInput}
setFiles={setFiles}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
dict={dict}
/>
) : messages.length === 0 ? null : (
@@ -771,42 +730,8 @@ export function ChatMessageDisplay({
<Copy className="h-3.5 w-3.5" />
)}
</button>
{/* Save as Template button - only for user messages */}
<button
type="button"
onClick={() =>
setSaveAsTemplateMessageId(
message.id,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={
dict.templates
?.saveAsTemplate ||
"Save as Template"
}
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Save as Template Dialog */}
{saveAsTemplateMessageId === message.id && (
<TemplateCreateDialog
open={true}
onOpenChange={(open) => {
if (!open)
setSaveAsTemplateMessageId(null)
}}
onSuccess={() => {
setSaveAsTemplateMessageId(null)
}}
initialPrompt={getUserOriginalText(
message,
)}
/>
)}
<div className="max-w-[85%] min-w-0">
{/* Reasoning blocks - displayed first for assistant messages */}
{message.role === "assistant" &&
@@ -986,25 +911,12 @@ export function ChatMessageDisplay({
return groups.map(
(group, groupIndex) => {
if (group.type === "tool") {
const toolPart = group
.parts[0] as ToolPartLike
const toolCallId =
toolPart.toolCallId
const isDisplayDiagram =
toolPart.type ===
"tool-display_diagram"
const validationState =
validationStates[
toolCallId
]
return (
<div
key={`${message.id}-tool-${group.startIndex}`}
>
<ToolCallCard
key={`${message.id}-tool-${group.startIndex}`}
part={
toolPart
group
.parts[0] as ToolPartLike
}
expandedTools={
expandedTools
@@ -1023,19 +935,6 @@ export function ChatMessageDisplay({
}
dict={dict}
/>
{/* Show validation card for display_diagram tools */}
{isDisplayDiagram &&
validationState && (
<ValidationCard
state={
validationState
}
onImproveWithSuggestions={
onImproveWithSuggestions
}
/>
)}
</div>
)
}
@@ -1149,14 +1048,12 @@ export function ChatMessageDisplay({
) => {
if (
section.type ===
"file" ||
section.type ===
"url"
"file"
) {
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
const isExpanded =
expandedPdfSections[
sectionKey
pdfKey
] ??
false
const charDisplay =
@@ -1165,27 +1062,10 @@ export function ChatMessageDisplay({
1000
? `${(section.charCount / 1000).toFixed(1)}k`
: 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 (
<div
key={
sectionKey
pdfKey
}
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
>
@@ -1200,7 +1080,7 @@ export function ChatMessageDisplay({
prev,
) => ({
...prev,
[sectionKey]:
[pdfKey]:
!isExpanded,
}),
)
@@ -1208,10 +1088,13 @@ export function ChatMessageDisplay({
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">
<Icon
className={`h-4 w-4 ${iconColor}`}
/>
<span className="text-xs font-medium truncate max-w-[200px]">
{section.fileType ===
"pdf" ? (
<FileText className="h-4 w-4 text-red-500" />
) : (
<FileCode className="h-4 w-4 text-blue-500" />
)}
<span className="text-xs font-medium">
{
section.filename
}
@@ -1231,7 +1114,7 @@ export function ChatMessageDisplay({
)}
</button>
{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">
{
section.content
+39 -208
View File
@@ -8,7 +8,8 @@ import {
PanelRightOpen,
Settings,
} from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import type React from "react"
import {
useCallback,
@@ -21,7 +22,6 @@ import { flushSync } from "react-dom"
import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import Image from "@/components/image-with-basepath"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
@@ -29,19 +29,14 @@ import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { useSessionManager } from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
import type { ValidationState } from "./chat/ValidationCard"
import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
@@ -69,18 +64,18 @@ interface ChatMessage {
interface ChatPanelProps {
isVisible: boolean
onToggleVisibility: () => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
isMobile?: boolean
onCloseProtectionChange?: (enabled: boolean) => void
}
// Constants for tool states
const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development"
// Increased to 3 to support VLM validation retries (matches MAX_VALIDATION_RETRIES)
const MAX_AUTO_RETRY_COUNT = 3
const MAX_AUTO_RETRY_COUNT = 1
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
@@ -111,10 +106,11 @@ export default function ChatPanel({
isVisible,
onToggleVisibility,
drawioUi,
onDrawioUiChange,
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
isMobile = false,
onCloseProtectionChange,
}: ChatPanelProps) {
const {
loadDiagram: onDisplayChart,
@@ -125,42 +121,43 @@ export default function ChatPanel({
latestSvg,
clearDiagram,
getThumbnailSvg,
captureValidationPng,
diagramHistory,
setDiagramHistory,
} = useDiagram()
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => {
return Promise.race([
new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve
}
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
}
}),
new Promise<string>((_, reject) => {
const currentResolver = resolverRef.current
setTimeout(() => {
if (resolverRef.current === currentResolver) {
resolverRef.current = null
}
reject(new Error("Chart export timed out after 10 seconds"))
}, 10000)
}),
new Promise<string>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
"Chart export timed out after 10 seconds",
),
),
10000,
),
),
])
}
// File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [urlData, setUrlData] = useState<Map<string, UrlData>>(new Map())
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -176,9 +173,6 @@ export default function ChatPanel({
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0)
const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [customSystemMessage, setCustomSystemMessage] = useState("")
const [shouldFocusInput, setShouldFocusInput] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
useEffect(() => {
@@ -188,22 +182,6 @@ export default function ChatPanel({
}
}, [])
// Load VLM validation setting from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.vlmValidationEnabled)
if (stored !== null) {
setVlmValidationEnabled(stored === "true")
}
}, [])
// Load custom system message from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.customSystemMessage)
if (stored !== null) {
setCustomSystemMessage(stored)
}
}, [])
// Check config on mount
useEffect(() => {
fetch(getApiEndpoint("/api/config"))
@@ -291,52 +269,6 @@ export default function ChatPanel({
> | null>(null)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
// Validation state for displaying VLM validation progress
// Key: toolCallId, Value: ValidationState
const [validationStates, setValidationStates] = useState<
Record<string, ValidationState>
>({})
// Callback to update validation state from tool handler
const handleValidationStateChange = useCallback(
(toolCallId: string, state: ValidationState) => {
setValidationStates((prev) => ({
...prev,
[toolCallId]: state,
}))
},
[],
)
// Handler for VLM validation setting change
const handleVlmValidationChange = useCallback((value: boolean) => {
setVlmValidationEnabled(value)
localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value))
}, [])
// Handler for custom system message change
const handleCustomSystemMessageChange = useCallback((value: string) => {
setCustomSystemMessage(value)
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
}, [])
// Ref to store the sendMessage function for use in callbacks
const sendMessageRef = useRef<typeof sendMessage | null>(null)
// Callback to improve diagram with validation suggestions
const handleImproveWithSuggestions = useCallback((feedback: string) => {
if (sendMessageRef.current) {
// Send the feedback as a new user message to trigger regeneration
sendMessageRef.current({
role: "user",
parts: [{ type: "text", text: feedback }],
})
}
}, [])
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef,
@@ -345,22 +277,10 @@ export default function ChatPanel({
onDisplayChart,
onFetchChart,
onExport,
captureValidationPng,
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
sessionId,
onValidationStateChange: handleValidationStateChange,
})
const {
messages,
sendMessage,
addToolOutput,
status,
error,
setMessages,
stop,
} = useChat({
const { messages, sendMessage, addToolOutput, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
@@ -417,7 +337,8 @@ export default function ChatPanel({
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
friendlyMessage =
"Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
@@ -440,7 +361,9 @@ export default function ChatPanel({
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
parts: [
{ type: "text" as const, text: friendlyMessage },
],
}
return [...currentMessages, errorMessage]
})
@@ -502,11 +425,6 @@ export default function ChatPanel({
},
})
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
useEffect(() => {
sendMessageRef.current = sendMessage
}, [sendMessage])
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
useEffect(() => {
@@ -602,7 +520,7 @@ export default function ChatPanel({
try {
const currentSession = sessionManager.currentSession
if (currentSession) {
if (currentSession && currentSession.messages.length > 0) {
// Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true
syncUIWithSession(currentSession)
@@ -639,7 +557,7 @@ export default function ChatPanel({
lastSyncedSessionIdRef.current = newSessionId
// Sync UI with new session
if (newSession) {
if (newSession && newSession.messages.length > 0) {
justLoadedSessionRef.current = true
syncUIWithSession(newSession)
} else if (!newSession) {
@@ -694,7 +612,7 @@ export default function ChatPanel({
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => {
try {
if (messages.length > 0 || hasDiagramNow) {
if (messages.length > 0) {
const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession,
@@ -716,7 +634,6 @@ export default function ChatPanel({
}
}
}, [
chartXML,
messages,
status,
sessionIsAvailable,
@@ -747,8 +664,7 @@ export default function ChatPanel({
const handleVisibilityChange = async () => {
if (
document.visibilityState === "hidden" &&
(messagesRef.current.length > 0 ||
isRealDiagram(chartXMLRef.current))
messagesRef.current.length > 0
) {
try {
// Attempt to save session - browser may not wait for completion
@@ -794,8 +710,6 @@ export default function ChatPanel({
input,
files,
pdfData,
undefined,
urlData,
)
setMessages([
@@ -821,7 +735,6 @@ export default function ChatPanel({
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
return
}
}
@@ -842,7 +755,6 @@ export default function ChatPanel({
files,
pdfData,
parts,
urlData,
)
// Add the combined text as the first part
@@ -867,7 +779,6 @@ export default function ChatPanel({
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
} catch (error) {
console.error("Error fetching chart data:", error)
}
@@ -903,7 +814,6 @@ export default function ChatPanel({
} else {
justLoadedSessionIdRef.current = null
}
setValidationStates({}) // Clear validation states when switching sessions
syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false })
}
@@ -920,10 +830,10 @@ export default function ChatPanel({
if (result.wasCurrentSession) {
// Deleted current session - clear UI and URL
syncUIWithSession(null)
router.replace(pathname, { scroll: false })
router.replace(window.location.pathname, { scroll: false })
}
},
[sessionManager, syncUIWithSession, router, pathname],
[sessionManager, syncUIWithSession, router],
)
const handleNewChat = useCallback(async () => {
@@ -941,12 +851,9 @@ export default function ChatPanel({
// Clear UI state (can't use syncUIWithSession here because we also need to clear files)
setMessages([])
setInput("")
clearDiagram()
setDiagramHistory([])
setValidationStates({}) // Clear validation states to prevent memory leak
handleFileChange([]) // Use handleFileChange to also clear pdfData
setUrlData(new Map())
const newSessionId = `session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
@@ -956,10 +863,7 @@ export default function ChatPanel({
toast.success(dict.dialogs.clearSuccess)
// Clear URL param to show blank state
router.replace(pathname, { scroll: false })
// After starting a fresh chat, move focus back to the chat input
setShouldFocusInput(true)
router.replace(window.location.pathname, { scroll: false })
}, [
clearDiagram,
handleFileChange,
@@ -971,28 +875,8 @@ export default function ChatPanel({
dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory,
pathname,
])
// Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback(
async (template: { prompt: string }) => {
flushSync(() => {
setInput(template.prompt)
setFiles([])
setUrlData(new Map())
})
const formElement = document.getElementById(
"chat-form",
) as HTMLFormElement | null
if (formElement) {
formElement.requestSubmit()
}
},
[setInput, setFiles, setUrlData],
)
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
@@ -1030,29 +914,6 @@ export default function ChatPanel({
}
}
// Handle stop button click
const handleStop = useCallback(() => {
const lastMessage = messages[messages.length - 1]
const toolParts = lastMessage?.parts?.filter(
(part: any) =>
part.type?.startsWith("tool-") &&
part.state === "input-streaming",
)
toolParts?.forEach((part: any) => {
if (part.toolCallId) {
addToolOutput({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
})
stop()
}, [messages, addToolOutput, stop])
// Send chat message with headers
const sendChatMessage = (
parts: any,
@@ -1070,7 +931,7 @@ export default function ChatPanel({
sendMessage(
{ parts },
{
body: { xml, previousXml, sessionId, customSystemMessage },
body: { xml, previousXml, sessionId },
headers: {
"x-access-code": config.accessCode,
...(config.aiProvider && {
@@ -1096,14 +957,6 @@ export default function ChatPanel({
...(config.awsSessionToken && {
"x-aws-session-token": config.awsSessionToken,
}),
// Vertex AI credentials (Express Mode)
...(config.vertexApiKey && {
"x-vertex-api-key": config.vertexApiKey,
}),
}),
// Send selected model ID for server model lookup (apiKeyEnv/baseUrlEnv)
...(config.selectedModelId && {
"x-selected-model-id": config.selectedModelId,
}),
...(minimalStyle && {
"x-minimal-style": "true",
@@ -1119,7 +972,6 @@ export default function ChatPanel({
files: File[],
pdfData: Map<File, FileData>,
imageParts?: any[],
urlDataParam?: Map<string, UrlData>,
): Promise<string> => {
let userText = baseText
@@ -1150,14 +1002,6 @@ export default function ChatPanel({
}
}
if (urlDataParam) {
for (const [url, data] of urlDataParam) {
if (data.content) {
userText += `\n\n[URL: ${url}]\nTitle: ${data.title}\n\n${data.content}`
}
}
}
return userText
}
@@ -1394,10 +1238,6 @@ export default function ChatPanel({
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
loadedMessageIdsRef={loadedMessageIdsRef}
validationStates={validationStates}
onImproveWithSuggestions={handleImproveWithSuggestions}
onSendTemplate={handleSendTemplate}
currentInput={input}
/>
</main>
@@ -1421,38 +1261,29 @@ export default function ChatPanel({
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onStop={handleStop}
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
urlData={urlData}
onUrlChange={setUrlData}
sessionId={sessionId}
error={error}
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId}
onConfigureModels={() => setShowModelConfigDialog(true)}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
shouldFocus={shouldFocusInput}
onFocused={() => setShouldFocusInput(false)}
onConfigureModels={() => setShowModelConfigDialog(true)}
/>
</footer>
<SettingsDialog
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange}
drawioUi={drawioUi}
onDrawioUiChange={onDrawioUiChange}
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
vlmValidationEnabled={vlmValidationEnabled}
onVlmValidationChange={handleVlmValidationChange}
customSystemMessage={customSystemMessage}
onCustomSystemMessageChange={handleCustomSystemMessageChange}
onOpenModelConfig={() => setShowModelConfigDialog(true)}
/>
<ModelConfigDialog
+10 -94
View File
@@ -8,10 +8,9 @@ import {
Trash2,
X,
} from "lucide-react"
import { useEffect, useState } from "react"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import Image from "next/image"
import { useState } from "react"
import ExamplePanel from "@/components/chat-example-panel"
import Image from "@/components/image-with-basepath"
import {
AlertDialog,
AlertDialogAction,
@@ -22,8 +21,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { STORAGE_KEYS } from "@/lib/storage"
import type { Template } from "@/lib/template-storage"
interface SessionMetadata {
id: string
@@ -38,8 +35,6 @@ interface ChatLobbyProps {
onDeleteSession?: (id: string) => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
dict: {
sessionHistory?: {
recentChats?: string
@@ -49,10 +44,6 @@ interface ChatLobbyProps {
deleteTitle?: string
deleteDescription?: string
}
templates?: {
title?: string
myTemplates?: string
}
examples?: {
quickExamples?: string
}
@@ -84,73 +75,33 @@ function formatSessionDate(
})
}
function getPanelVisibility() {
if (typeof window === "undefined")
return { recentChats: true, myTemplates: true, quickExamples: true }
return {
recentChats:
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
myTemplates:
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
quickExamples:
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
}
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
onSendTemplate,
currentInput = "",
dict,
}: ChatLobbyProps) {
const [templatesExpanded, setTemplatesExpanded] = useState(true)
const [examplesExpanded, setExamplesExpanded] = useState(true)
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
// 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("")
// Listen for panel visibility changes from settings
useEffect(() => {
const handler = () => setPanelVisibility(getPanelVisibility())
window.addEventListener("panelVisibilityChange", handler)
return () =>
window.removeEventListener("panelVisibilityChange", handler)
}, [])
const hasHistory = sessions.length > 0
if (!hasHistory) {
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
return null
}
return (
<div className="animate-fade-in">
{panelVisibility.myTemplates && (
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
)}
{panelVisibility.quickExamples && (
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
<ExamplePanel setInput={setInput} setFiles={setFiles} />
</div>
)}
</div>
)
// 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 */}
{panelVisibility.recentChats && (
<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"}
@@ -194,10 +145,7 @@ export function ChatLobby({
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 === " "
) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onSelectSession(session.id)
}
@@ -258,39 +206,8 @@ export function ChatLobby({
)}
</div>
</div>
)}
{/* Collapsible My Templates Section */}
{panelVisibility.myTemplates && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setTemplatesExpanded(!templatesExpanded)}
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.templates?.myTemplates || "My Templates"}
</span>
{templatesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{templatesExpanded && (
<div className="mt-2">
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
</div>
)}
</div>
)}
{/* Collapsible Quick Examples Section */}
{panelVisibility.quickExamples && (
{/* Collapsible Examples Section */}
<div className="border-t border-border/50 pt-4">
<button
type="button"
@@ -316,7 +233,6 @@ export function ChatLobby({
</div>
)}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
-203
View File
@@ -1,203 +0,0 @@
"use client"
import { Bookmark, Plus } from "lucide-react"
import { useEffect, 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 { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import {
createTemplate,
type TemplateCreateInput,
} from "@/lib/template-storage"
interface TemplateCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
initialPrompt?: string
}
export function TemplateCreateDialog({
open,
onOpenChange,
onSuccess,
initialPrompt = "",
}: TemplateCreateDialogProps) {
const dict = useDictionary()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
const [pinned, setPinned] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form when dialog opens with the latest initialPrompt
useEffect(() => {
if (open) {
setTitle("")
setDescription("")
setPrompt(initialPrompt)
setPinned(false)
setError(null)
}
}, [open, initialPrompt])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const input: TemplateCreateInput = {
prompt: trimmedPrompt,
title: title.trim() || undefined,
description: description.trim() || undefined,
pinned,
}
const template = await createTemplate(input)
if (template) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.createFailed)
}
} catch (err) {
console.error("Failed to create template:", err)
setError(dict.templates.createFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
{dict.templates.createTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.createDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label htmlFor="prompt" className="text-foreground">
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
className="min-h-[100px] resize-none break-words"
required
/>
</div>
{/* Title field - optional */}
<div className="space-y-2">
<Label htmlFor="title" className="text-foreground">
{dict.templates.titleLabel}
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
/>
<p className="text-xs text-muted-foreground">
{dict.templates.titleHint}
</p>
</div>
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor="description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
dict.templates.descriptionPlaceholder
}
className="min-h-[60px] resize-none"
/>
</div>
{/* Pinned switch */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
{dict.templates.pinnedLabel}
</Label>
<p className="text-xs text-muted-foreground">
{dict.templates.pinnedHint}
</p>
</div>
<Switch
id="pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{dict.common.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: dict.templates.createButton}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-215
View File
@@ -1,215 +0,0 @@
"use client"
import { Bookmark, Edit2 } from "lucide-react"
import { useEffect, 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 { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { type Template, updateTemplate } from "@/lib/template-storage"
interface TemplateEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
template: Template | null
onSuccess: () => void
}
export function TemplateEditDialog({
open,
onOpenChange,
template,
onSuccess,
}: TemplateEditDialogProps) {
const dict = useDictionary()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
const [pinned, setPinned] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Populate form when template changes
useEffect(() => {
if (template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null)
}
onOpenChange(newOpen)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!template) return
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
prompt: trimmedPrompt,
title: title.trim() || template.title,
description: description.trim() || undefined,
pinned,
}
const updated = await updateTemplate(template.id, updates)
if (updated) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.updateFailed)
}
} catch (err) {
console.error("Failed to update template:", err)
setError(dict.templates.updateFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit2 className="w-5 h-5" />
{dict.templates.editTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.editDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label
htmlFor="edit-prompt"
className="text-foreground"
>
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="edit-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
className="min-h-[100px] resize-none break-words"
required
/>
</div>
{/* Title field - optional */}
<div className="space-y-2">
<Label
htmlFor="edit-title"
className="text-foreground"
>
{dict.templates.titleLabel}
</Label>
<Input
id="edit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
/>
<p className="text-xs text-muted-foreground">
{dict.templates.titleHint}
</p>
</div>
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor="edit-description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
dict.templates.descriptionPlaceholder
}
className="min-h-[60px] resize-none"
/>
</div>
{/* Pinned switch */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="edit-pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
{dict.templates.pinnedLabel}
</Label>
<p className="text-xs text-muted-foreground">
{dict.templates.pinnedHint}
</p>
</div>
<Switch
id="edit-pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{dict.common.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: dict.common.save}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-622
View File
@@ -1,622 +0,0 @@
"use client"
import {
Bookmark,
Copy,
Download,
Edit2,
FileText,
Plus,
Search,
Trash2,
Upload,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDictionary } from "@/hooks/use-dictionary"
import {
deleteTemplate,
duplicateTemplate,
exportTemplates,
getAllTemplates,
importTemplates,
incrementClickCount,
incrementRunCount,
searchTemplates,
type Template,
updateTemplate,
validateImportData,
} from "@/lib/template-storage"
import { TemplateCreateDialog } from "./TemplateCreateDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps {
setInput: (input: string) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
}
function formatLastUsed(timestamp: number, neverUsedText: string): string {
if (!timestamp) return neverUsedText
const now = Date.now()
const diffMs = now - timestamp
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
try {
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
if (diffMins < 1) return rtf.format(0, "minute")
if (diffMins < 60) return rtf.format(-diffMins, "minute")
if (diffHours < 24) return rtf.format(-diffHours, "hour")
if (diffDays < 7) return rtf.format(-diffDays, "day")
} catch {
// Fallback if Intl.RelativeTimeFormat is not available
if (diffMins < 1) return "<1m ago"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
}
return new Date(timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function TemplatePanel({
setInput,
onSendTemplate,
currentInput = "",
}: TemplatePanelProps) {
const dict = useDictionary()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
null,
)
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const [importMessage, setImportMessage] = useState<{
type: "success" | "error"
text: string
} | null>(null)
const loadTemplates = useCallback(async () => {
const result = await getAllTemplates()
setTemplates(result)
setLoading(false)
}, [])
// Filter templates by search query
const filteredTemplates = searchQuery.trim()
? searchTemplates(templates, searchQuery)
: templates
useEffect(() => {
loadTemplates()
}, [loadTemplates])
const handleCreateSuccess = () => {
loadTemplates()
}
const handleEditSuccess = () => {
loadTemplates()
}
const handleEdit = (template: Template) => {
setTemplateToEdit(template)
setEditDialogOpen(true)
}
const handleDuplicate = async (template: Template) => {
const duplicated = await duplicateTemplate(
template.id,
dict.templates.copySuffix || "(copy)",
)
if (duplicated) {
loadTemplates()
}
}
const handleDeleteClick = (template: Template) => {
setTemplateToDelete(template)
setDeleteDialogOpen(true)
}
const handleDeleteConfirm = async () => {
if (!templateToDelete) return
const success = await deleteTemplate(templateToDelete.id)
if (success) {
loadTemplates()
}
setDeleteDialogOpen(false)
setTemplateToDelete(null)
}
const handleTogglePin = async (template: Template) => {
const updated = await updateTemplate(template.id, {
pinned: !template.pinned,
})
if (updated) {
loadTemplates()
}
}
// Handle template card click - send directly or show confirmation
const handleTemplateClick = async (template: Template) => {
// If there's unsent content in the input, show confirmation dialog
if (currentInput.trim()) {
setTemplateToSend(template)
setConfirmSendDialogOpen(true)
return
}
// No unsent content, send directly
await sendTemplate(template)
}
// Actually send the template
const sendTemplate = async (template: Template) => {
// Increment click count only when actually sending
await incrementClickCount(template.id)
if (onSendTemplate) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
// Call the send callback
onSendTemplate(template)
} else {
// Fallback: just fill the input if no send callback provided
setInput(template.prompt)
}
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Handle confirmation dialog - user confirmed to send template
const handleConfirmSend = async () => {
if (!templateToSend) return
await sendTemplate(templateToSend)
}
// Handle cancel - close dialog without sending
const handleCancelSend = () => {
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Export templates to JSON file
const handleExport = () => {
if (templates.length === 0) {
setImportMessage({
type: "error",
text: dict.templates.exportEmpty || "No templates to export",
})
return
}
try {
const exportData = exportTemplates(templates)
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setImportMessage({
type: "success",
text: dict.templates.exportSuccess.replace(
"{count}",
String(templates.length),
),
})
setTimeout(() => setImportMessage(null), 3000)
} catch (error) {
console.error("Failed to export templates:", error)
setImportMessage({
type: "error",
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
})
}
}
// Import templates from JSON file
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
setImportMessage({
type: "error",
text:
dict.templates.importNoFile || "Please select a JSON file",
})
return
}
try {
const text = await file.text()
const data = JSON.parse(text)
// Validate import data
const validation = validateImportData(data)
if (!validation.valid) {
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
validation.error || "Invalid data",
),
})
return
}
// Import templates with dedup-append strategy
const result = await importTemplates(data.templates, templates)
// Reload template list
await loadTemplates()
setImportMessage({
type: "success",
text: dict.templates.importSuccess
.replace("{imported}", String(result.imported))
.replace("{skipped}", String(result.skipped)),
})
setTimeout(() => setImportMessage(null), 5000)
} catch (error) {
console.error("Failed to import templates:", error)
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
error instanceof Error ? error.message : "Unknown error",
),
})
} finally {
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
// Empty state: no templates at all
if (!loading && templates.length === 0) {
return (
<div className="py-6 px-2 animate-fade-in">
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
<div className="flex flex-col items-center justify-center py-8 px-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-primary/60" />
</div>
<p className="text-sm font-medium text-foreground mb-1">
{dict.templates.emptyTitle}
</p>
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
{dict.templates.emptyDescription}
</p>
<button
type="button"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="w-4 h-4" />
{dict.templates.createFirst}
</button>
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
</div>
</div>
)
}
// Template list
return (
<div className="py-2 px-2 animate-fade-in">
<div className="space-y-3">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={dict.templates.searchPlaceholder}
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"
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCreateDialogOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{dict.templates.createButton}
</button>
<div className="flex-1" />
<button
type="button"
onClick={handleExport}
disabled={templates.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={dict.templates.exportTemplates}
>
<Download className="w-3.5 h-3.5" />
{dict.templates.exportTemplates}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={dict.templates.importTemplates}
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
</div>
{/* Import message */}
{importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)}
<div className="space-y-2">
{loading
? // Loading skeleton
Array.from({ length: 3 }).map((_, i) => (
<div
key={`skeleton-${String(i)}`}
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
</div>
))
: filteredTemplates.length === 0
? // Search empty state
!loading && (
<div className="flex flex-col items-center justify-center py-6 px-4">
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
<p className="text-sm text-muted-foreground text-center">
{dict.templates.searchNoResults}
</p>
</div>
)
: filteredTemplates.map((template) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
<div
key={template.id}
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={() =>
handleTemplateClick(template)
}
onKeyDown={(e) => {
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
handleTemplateClick(template)
}
}}
role="button"
tabIndex={0}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">
{template.title}
</div>
{template.pinned && (
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
)}
</div>
{template.description && (
<div className="text-xs text-muted-foreground truncate">
{template.description}
</div>
)}
</div>
{/* Actions and stats */}
<div className="relative shrink-0">
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
{template.runCount > 0
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
: dict.templates.neverUsed}
</div>
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleTogglePin(template)
}}
className={`p-1.5 rounded-lg transition-all ${
template.pinned
? "text-primary hover:text-primary/80 hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
title={
template.pinned
? dict.templates
.unpin || "Unpin"
: dict.templates.pin ||
"Pin"
}
>
<Bookmark
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleEdit(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={dict.common.edit}
>
<Edit2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDuplicate(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={
dict.templates.duplicate ||
"Duplicate"
}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
<TemplateEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
template={templateToEdit}
onSuccess={handleEditSuccess}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.deleteTitle ||
"Delete this template?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.deleteDescription ||
"This will permanently delete this template. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
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>
{/* Confirm Send Dialog - when there's unsent input */}
<AlertDialog
open={confirmSendDialogOpen}
onOpenChange={setConfirmSendDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.confirmSendTitle ||
"Replace current input?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.confirmSendDescription ||
"You have unsent content in the input. Sending this template will replace it."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelSend}>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSend}>
{dict.templates.confirmSendButton ||
"Send Template"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+2 -17
View File
@@ -67,8 +67,8 @@ export function ToolCallCard({
}: 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
// Default to collapsed if tool is complete, expanded if still streaming
const isExpanded = expandedTools[callId] ?? state !== "output-available"
const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId
@@ -195,22 +195,7 @@ export function ToolCallCard({
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
state === "input-streaming" ||
state === "input-available" ? (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
style={{
fontFamily:
"var(--font-mono), ui-monospace, monospace",
margin: 0,
padding: 0,
}}
>
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
-328
View File
@@ -1,328 +0,0 @@
"use client"
import {
AlertTriangle,
Check,
ChevronDown,
ChevronUp,
Eye,
ImageIcon,
RefreshCw,
X,
} from "lucide-react"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
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>
)
}
+3 -63
View File
@@ -1,8 +1,8 @@
"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 { useEffect, useRef, useState } from "react"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
@@ -20,19 +20,12 @@ interface FilePreviewListProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<
string,
{ url: string; title: string; charCount: number; isExtracting: boolean }
>
onRemoveUrl?: (url: string) => void
}
export function FilePreviewList({
files,
onRemoveFile,
pdfData = new Map(),
urlData,
onRemoveUrl,
}: FilePreviewListProps) {
const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -84,7 +77,7 @@ export function FilePreviewList({
}
}, [imageUrls, selectedImage])
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
if (files.length === 0) return null
return (
<>
@@ -159,59 +152,6 @@ export function FilePreviewList({
</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>
{/* Image Modal/Lightbox */}
{selectedImage && (
+2 -2
View File
@@ -1,7 +1,7 @@
"use client"
import Image from "next/image"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -43,7 +43,7 @@ export function HistoryDialog({
return (
<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>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
-16
View File
@@ -1,16 +0,0 @@
import NextImage, { type ImageProps } from "next/image"
import { forwardRef } from "react"
import { getAssetUrl } from "@/lib/base-path"
export default forwardRef<HTMLImageElement, ImageProps>(
function Image(props, ref) {
const src =
typeof props.src === "string" &&
props.src.startsWith("/") &&
!props.src.startsWith("//")
? getAssetUrl(props.src)
: props.src
return <NextImage {...props} src={src} ref={ref} />
},
)
+551 -151
View File
@@ -5,24 +5,22 @@ import {
Check,
ChevronRight,
Clock,
Cloud,
Eye,
EyeOff,
Key,
Link2,
Loader2,
Plus,
Server,
Settings2,
Sparkles,
Tag,
Trash2,
X,
Zap,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
ProviderCredentialsFields,
type SecretField,
} from "@/components/provider-credentials-fields"
import { ProviderLogo } from "@/components/provider-logo"
import {
AlertDialog,
AlertDialogAction,
@@ -67,6 +65,54 @@ interface ModelConfigDialogProps {
type ValidationStatus = "idle" | "validating" | "success" | "error"
// Map provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance",
}
// Provider logo component
function ProviderLogo({
provider,
className,
}: {
provider: ProviderName
className?: string
}) {
// Use Lucide icons for providers without models.dev logos
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
width={16}
/>
)
}
// Configuration section with title and optional action
function ConfigSection({
title,
@@ -190,7 +236,6 @@ export function ModelConfigDialog({
"awsAccessKeyId",
"awsSecretAccessKey",
"awsRegion",
"vertexApiKey",
]
if (credentialFields.includes(field)) {
setValidationStatus("idle")
@@ -234,8 +279,6 @@ export function ModelConfigDialog({
// Check credentials based on provider type
const isBedrock = selectedProvider.provider === "bedrock"
const isEdgeOne = selectedProvider.provider === "edgeone"
const isOllama = selectedProvider.provider === "ollama"
const isVertexAI = selectedProvider.provider === "vertexai"
if (isBedrock) {
if (
!selectedProvider.awsAccessKeyId ||
@@ -244,12 +287,7 @@ export function ModelConfigDialog({
) {
return
}
} else if (isVertexAI) {
// Vertex AI requires vertexApiKey for Express Mode
if (!selectedProvider.vertexApiKey) {
return
}
} else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) {
} else if (!isEdgeOne && !selectedProvider.apiKey) {
return
}
@@ -289,8 +327,6 @@ export function ModelConfigDialog({
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
})
const data = await response.json()
@@ -345,104 +381,6 @@ export function ModelConfigDialog({
return provider.name || PROVIDER_INFO[provider.provider].label
}
// Inline Test button + error, shared across credential layouts. Disabled
// until the relevant credentials are present.
const renderTestButton = (canValidate: boolean) => (
<div className="flex items-center gap-2">
<Button
variant={validationStatus === "success" ? "outline" : "default"}
size="sm"
onClick={handleValidate}
disabled={!canValidate || validationStatus === "validating"}
className={cn(
"h-9 px-4",
validationStatus === "success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus === "validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus === "success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{dict.modelConfig.verified}
</>
) : (
dict.modelConfig.test
)}
</Button>
{validationStatus === "error" && validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{validationError}
</p>
)}
</div>
)
// Plaintext secret input with show/hide toggle (the user dialog stores
// keys client-side, so values are shown directly — unlike the masked
// admin panel). The primary key field carries the inline Test button.
const renderProviderSecret = (field: SecretField, id: string) => {
if (!selectedProvider) return null
const value = (selectedProvider[field] as string | undefined) ?? ""
// The "primary" credential sits beside the Test button; for Bedrock
// the test lives below the region, so its inputs have no inline test.
const isBedrock = selectedProvider.provider === "bedrock"
const withInlineTest =
!isBedrock && (field === "apiKey" || field === "vertexApiKey")
const canValidate =
field === "vertexApiKey"
? !!selectedProvider.vertexApiKey
: selectedProvider.provider === "ollama" ||
!!selectedProvider.apiKey
const input = (
<div className="relative flex-1">
<Input
id={id}
type={showApiKey ? "text" : "password"}
value={value}
onChange={(e) =>
handleProviderUpdate(field, e.target.value)
}
placeholder={
field === "awsSecretAccessKey"
? dict.modelConfig.enterSecretKey
: field === "awsAccessKeyId"
? "AKIA..."
: dict.modelConfig.enterApiKey
}
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
aria-label={
showApiKey
? dict.modelConfig.hideValue
: dict.modelConfig.showValue
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
)
if (!withInlineTest) return input
return (
<div className="space-y-2">
<div className="flex gap-2">
{input}
{renderTestButton(canValidate)}
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
@@ -469,7 +407,7 @@ export function ModelConfigDialog({
</span>
</div>
<ScrollArea className="flex-1 px-2 min-h-0">
<ScrollArea className="flex-1 px-2">
<div className="space-y-1 pb-2">
{config.providers.length === 0 ? (
<div className="px-3 py-8 text-center">
@@ -494,12 +432,12 @@ export function ModelConfigDialog({
}}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
"text-left text-sm transition-all duration-150 border border-transparent",
"text-left text-sm transition-all duration-150",
"hover:bg-interactive-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selectedProviderId ===
provider.id &&
"bg-surface-0 shadow-sm border-border-subtle",
"bg-surface-0 shadow-sm ring-1 ring-border-subtle",
)}
>
<div
@@ -577,7 +515,7 @@ export function ModelConfigDialog({
</div>
{/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-auto scrollbar-thin">
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
{selectedProvider ? (
<ScrollArea className="flex-1" ref={scrollRef}>
<div className="p-6 space-y-8">
@@ -643,45 +581,511 @@ export function ModelConfigDialog({
icon={Settings2}
>
<ConfigCard>
<ProviderCredentialsFields
provider={
selectedProvider.provider
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.displayName
}
name={selectedProvider.name}
baseUrl={
selectedProvider.baseUrl
</Label>
<Input
id="provider-name"
value={
selectedProvider.name ||
""
}
awsRegion={
selectedProvider.awsRegion
}
onChange={(field, value) =>
onChange={(e) =>
handleProviderUpdate(
field,
value,
"name",
e.target.value,
)
}
renderSecret={({ field, id }) =>
renderProviderSecret(
field,
id,
placeholder={
PROVIDER_INFO[
selectedProvider
.provider
].label
}
className="h-9"
/>
</div>
{/* Credentials - different for Bedrock vs other providers */}
{selectedProvider.provider ===
"bedrock" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.awsAccessKeyId
}
</Label>
<Input
id="aws-access-key-id"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.awsAccessKeyId ||
""
}
onChange={(e) =>
handleProviderUpdate(
"awsAccessKeyId",
e.target
.value,
)
}
footer={
selectedProvider.provider ===
"bedrock"
? renderTestButton(
!!selectedProvider.awsAccessKeyId &&
!!selectedProvider.awsSecretAccessKey &&
!!selectedProvider.awsRegion,
placeholder="AKIA..."
className="h-9 font-mono text-xs"
/>
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.awsSecretAccessKey
}
</Label>
<div className="relative">
<Input
id="aws-secret-access-key"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.awsSecretAccessKey ||
""
}
onChange={(e) =>
handleProviderUpdate(
"awsSecretAccessKey",
e.target
.value,
)
: selectedProvider.provider ===
"edgeone"
? renderTestButton(
true,
}
placeholder={
dict
.modelConfig
.enterSecretKey
}
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() =>
setShowApiKey(
!showApiKey,
)
: undefined
}
aria-label={
showApiKey
? "Hide secret access key"
: "Show secret access key"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.awsRegion
}
</Label>
<Select
value={
selectedProvider.awsRegion ||
""
}
onValueChange={(
v,
) =>
handleProviderUpdate(
"awsRegion",
v,
)
}
>
<SelectTrigger className="h-9 font-mono text-xs hover:bg-accent">
<SelectValue
placeholder={
dict
.modelConfig
.selectRegion
}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="us-east-1">
us-east-1
(N.
Virginia)
</SelectItem>
<SelectItem value="us-east-2">
us-east-2
(Ohio)
</SelectItem>
<SelectItem value="us-west-2">
us-west-2
(Oregon)
</SelectItem>
<SelectItem value="eu-west-1">
eu-west-1
(Ireland)
</SelectItem>
<SelectItem value="eu-west-2">
eu-west-2
(London)
</SelectItem>
<SelectItem value="eu-west-3">
eu-west-3
(Paris)
</SelectItem>
<SelectItem value="eu-central-1">
eu-central-1
(Frankfurt)
</SelectItem>
<SelectItem value="ap-south-1">
ap-south-1
(Mumbai)
</SelectItem>
<SelectItem value="ap-northeast-1">
ap-northeast-1
(Tokyo)
</SelectItem>
<SelectItem value="ap-northeast-2">
ap-northeast-2
(Seoul)
</SelectItem>
<SelectItem value="ap-southeast-1">
ap-southeast-1
(Singapore)
</SelectItem>
<SelectItem value="ap-southeast-2">
ap-southeast-2
(Sydney)
</SelectItem>
<SelectItem value="sa-east-1">
sa-east-1
(São Paulo)
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Test Button for Bedrock */}
<div className="flex items-center gap-2">
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
!selectedProvider.awsAccessKeyId ||
!selectedProvider.awsSecretAccessKey ||
!selectedProvider.awsRegion ||
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict.modelConfig
.test
)}
</Button>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
</>
) : selectedProvider.provider ===
"edgeone" ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict.modelConfig
.test
)}
</Button>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
</div>
) : (
<>
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.apiKey
}
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="api-key"
type={
showApiKey
? "text"
: "password"
}
value={
selectedProvider.apiKey
}
onChange={(
e,
) =>
handleProviderUpdate(
"apiKey",
e
.target
.value,
)
}
placeholder={
dict
.modelConfig
.enterApiKey
}
className="h-9 pr-10 font-mono text-xs"
/>
<button
type="button"
onClick={() =>
setShowApiKey(
!showApiKey,
)
}
aria-label={
showApiKey
? "Hide API key"
: "Show API key"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
!selectedProvider.apiKey ||
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict
.modelConfig
.test
)}
</Button>
</div>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{
dict.modelConfig
.baseUrl
}
<span className="text-muted-foreground font-normal">
{
dict
.modelConfig
.optional
}
</span>
</Label>
<Input
id="base-url"
value={
selectedProvider.baseUrl ||
""
}
onChange={(e) =>
handleProviderUpdate(
"baseUrl",
e.target
.value,
)
}
placeholder={
PROVIDER_INFO[
selectedProvider
.provider
]
.defaultBaseUrl ||
dict.modelConfig
.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
</div>
</>
)}
</ConfigCard>
</ConfigSection>
@@ -1089,16 +1493,12 @@ export function ModelConfigDialog({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
id="show-unvalidated-models"
checked={modelConfig.showUnvalidatedModels}
onCheckedChange={
modelConfig.setShowUnvalidatedModels
}
/>
<Label
htmlFor="show-unvalidated-models"
className="text-xs text-muted-foreground cursor-pointer"
>
<Label className="text-xs text-muted-foreground cursor-pointer">
{dict.modelConfig.showUnvalidatedModels}
</Label>
</div>
+54 -190
View File
@@ -5,10 +5,8 @@ import {
Bot,
Check,
ChevronDown,
Monitor,
Server,
Settings2,
User,
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
@@ -21,27 +19,39 @@ import {
ModelSelectorLogo,
ModelSelectorName,
ModelSelector as ModelSelectorRoot,
ModelSelectorSectionHeader,
ModelSelectorSeparator,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { useDictionary } from "@/hooks/use-dictionary"
import {
type FlattenedModel,
PROVIDER_LOGO_MAP,
} from "@/lib/types/model-config"
import type { FlattenedModel } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
interface ModelSelectorProps {
models: FlattenedModel[]
selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void
onConfigure?: () => void
onConfigure: () => void
disabled?: boolean
showUnvalidatedModels?: boolean
}
// Map our provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance",
}
// Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider(
models: FlattenedModel[],
@@ -51,11 +61,7 @@ function groupModelsByProvider(
{ provider: string; models: FlattenedModel[] }
>()
for (const model of models) {
// For server models, strip "Server · " prefix for cleaner grouping
const key =
model.source === "server"
? model.providerLabel.replace(/^Server · /, "")
: model.providerLabel
const key = model.providerLabel
const existing = groups.get(key)
if (existing) {
existing.models.push(model)
@@ -83,26 +89,10 @@ export function ModelSelector({
}
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
// Separate server and user models
const serverModels = useMemo(
() => displayModels.filter((m) => m.source === "server"),
const groupedModels = useMemo(
() => groupModelsByProvider(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
const selectedModel = useMemo(
@@ -111,7 +101,9 @@ export function ModelSelector({
)
const handleSelect = (value: string) => {
if (value === "__server_default__") {
if (value === "__configure__") {
onConfigure()
} else if (value === "__server_default__") {
onSelect(undefined)
} else {
onSelect(value)
@@ -167,7 +159,7 @@ export function ModelSelector({
size="sm"
disabled={disabled}
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",
)}
// accessibility: expose label to screen readers
@@ -197,21 +189,15 @@ export function ModelSelector({
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty>
{displayModels.length === 0 &&
models.length > 0
{displayModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
{/* Server Default Option */}
<ModelSelectorGroup heading={dict.modelConfig.default}>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
@@ -234,51 +220,30 @@ export function ModelSelector({
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={
dict.modelConfig.serverModels
}
/>
{Array.from(
groupedServerModels.entries(),
).map(
{/* Configured Models by Provider */}
{Array.from(groupedModels.entries()).map(
([
providerLabel,
{
provider,
models: providerModels,
},
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
key={providerLabel}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
value={model.modelId}
onSelect={() =>
handleSelect(
model.id,
)
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
selectedModelId === model.id
? "opacity-100"
: "opacity-0",
)}
@@ -287,109 +252,17 @@ export function ModelSelector({
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
{model.validated !== true && (
<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
dict.modelConfig
.unvalidatedModelWarning
}
>
@@ -397,41 +270,32 @@ export function ModelSelector({
</span>
)}
</ModelSelectorItem>
),
)}
))}
</ModelSelectorGroup>
),
)}
</>
)}
</ModelSelectorList>
</div>
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
<div className="relative z-10 shrink-0 border-t bg-background">
{onConfigure && (
<div className="px-3 py-2">
{/* Configure Option */}
<ModelSelectorSeparator />
<ModelSelectorGroup>
<ModelSelectorItem
value="__configure_models__"
onSelect={() => {
onConfigure()
setOpen(false)
}}
className="flex cursor-pointer items-center gap-2 rounded-sm"
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer"
>
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</div>
)}
<div className="px-3 pb-2 text-xs text-muted-foreground">
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
</div>
</div>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
</div>
-259
View File
@@ -1,259 +0,0 @@
"use client"
import { Key, Link2, Tag } from "lucide-react"
import type { ReactNode } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via
// renderSecret. That (and the optional test action) are the only genuine
// differences between the two screens; the field structure is shared here.
export type SecretField =
| "apiKey"
| "awsAccessKeyId"
| "awsSecretAccessKey"
| "vertexApiKey"
// AWS regions offered for Bedrock (shared by both screens)
const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"],
["us-east-2", "Ohio"],
["us-west-2", "Oregon"],
["eu-west-1", "Ireland"],
["eu-west-2", "London"],
["eu-west-3", "Paris"],
["eu-central-1", "Frankfurt"],
["ap-south-1", "Mumbai"],
["ap-northeast-1", "Tokyo"],
["ap-northeast-2", "Seoul"],
["ap-southeast-1", "Singapore"],
["ap-southeast-2", "Sydney"],
["sa-east-1", "São Paulo"],
]
interface ProviderCredentialsFieldsProps {
provider: ProviderName
// Plain (non-secret) field values — secrets are owned by renderSecret
name?: string
baseUrl?: string
awsRegion?: string
disabled?: boolean
// Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
// Render the control for a secret field. The caller may include trailing
// UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
// Extra content after the fields — used for the Bedrock test row and the
// EdgeOne test button, which aren't beside a credential input.
footer?: ReactNode
}
// Display name + per-provider credential inputs, shared by the user
// ModelConfigDialog and the admin Models panel.
export function ProviderCredentialsFields({
provider,
name,
baseUrl,
awsRegion,
disabled,
onChange,
renderSecret,
footer,
}: ProviderCredentialsFieldsProps) {
const dict = useDictionary()
const info = PROVIDER_INFO[provider]
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1",
})
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div>
}
return (
<div className="space-y-5">
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.displayName}
</Label>
<Input
id="provider-name"
value={name ?? ""}
disabled={disabled}
onChange={(e) => onChange("name", e.target.value)}
placeholder={info.label}
className="h-9"
/>
</div>
{provider === "bedrock" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsAccessKeyId}
</Label>
{renderSecret({
field: "awsAccessKeyId",
id: "aws-access-key-id",
})}
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSecretAccessKey}
</Label>
{renderSecret({
field: "awsSecretAccessKey",
id: "aws-secret-access-key",
})}
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue
placeholder={dict.modelConfig.selectRegion}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : provider === "vertexai" ? (
<>
{/* Vertex AI API Key (Express Mode) */}
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
</Label>
{renderSecret({
field: "vertexApiKey",
id: "vertex-api-key",
})}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
</>
) : (
<>
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
</div>
</>
)}
{footer}
</div>
)
}
-36
View File
@@ -1,36 +0,0 @@
import { Cloud, Server, Sparkles } from "lucide-react"
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
// Provider logo from models.dev, with Lucide fallbacks for providers
// that have no logo there
export function ProviderLogo({
provider,
className,
}: {
provider: ProviderName
className?: string
}) {
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt=""
aria-hidden="true"
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
width={16}
/>
)
}
+9 -31
View File
@@ -23,22 +23,9 @@ export function QuotaLimitToast({
}: QuotaLimitToastProps) {
const dict = useDictionary()
const isTokenLimit = type === "token"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const quotaMessage = isTokenLimit
? isSelfHosted
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
: dict.quota.messageToken
: isSelfHosted
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
: dict.quota.messageApi
const tipHtml = isSelfHosted
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
: dict.quota.tip
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
@@ -84,24 +71,19 @@ export function QuotaLimitToast({
</div>
{/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>{quotaMessage}</p>
{!isSelfHosted && (
<p>
{isTokenLimit
? dict.quota.messageToken
: dict.quota.messageApi}
</p>
<p
dangerouslySetInnerHTML={{
__html: formatMessage(
dict.quota.doubaoSponsorship,
{
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
},
),
}}
/>
)}
<p
dangerouslySetInnerHTML={{
__html: tipHtml,
__html: formatMessage(dict.quota.doubaoSponsorship, {
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
}),
}}
/>
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p>
</div>{" "}
{/* Action buttons */}
@@ -119,8 +101,6 @@ export function QuotaLimitToast({
{dict.quota.configModel}
</button>
)}
{!isSelfHosted && (
<>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
@@ -139,8 +119,6 @@ export function QuotaLimitToast({
<Coffee className="w-3.5 h-3.5" />
{dict.quota.sponsor}
</a>
</>
)}
</div>
</div>
)
+1 -6
View File
@@ -20,7 +20,7 @@ import {
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
export type ExportFormat = "drawio" | "png" | "svg"
interface SaveDialogProps {
open: boolean
@@ -74,11 +74,6 @@ export function SaveDialog({
label: dict.save.formats.svg,
extension: ".svg",
},
{
value: "xmlsvg" as const,
label: dict.save.formats.xmlsvg,
extension: ".drawio.svg",
},
]
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
+53 -358
View File
@@ -1,9 +1,8 @@
"use client"
import { ChevronRight, 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 { Suspense, useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Suspense, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -22,12 +21,9 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
// Reusable setting item component for consistent layout
function SettingItem({
@@ -58,26 +54,22 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
"zh-Hant": "繁體中文",
}
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
onCloseProtectionChange?: (enabled: boolean) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void
onOpenModelConfig?: () => void
customSystemMessage?: string
onCustomSystemMessageChange?: (value: string) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
function getStoredAccessCodeRequired(): boolean | null {
@@ -90,56 +82,30 @@ function getStoredAccessCodeRequired(): boolean | null {
function SettingsContent({
open,
onOpenChange,
onCloseProtectionChange,
drawioUi,
onDrawioUiChange,
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
onOpenModelConfig,
customSystemMessage = "",
onCustomSystemMessageChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Panel visibility state
const [showRecentChats, setShowRecentChats] = useState(true)
const [showMyTemplates, setShowMyTemplates] = useState(true)
const [showQuickExamples, setShowQuickExamples] = useState(true)
const handlePanelToggle = useCallback(
(key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
localStorage.setItem(key, String(value))
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
},
[],
)
// Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => {
// Re-fetch config whenever the dialog opens to ensure we always show
// the access code input if the server requires it. This fixes the case
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
// configured) would hide the access code input.
if (!open) return
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch(getApiEndpoint("/api/config"))
.then((res) => {
@@ -155,9 +121,10 @@ function SettingsContent({
setAccessCodeRequired(required)
})
.catch(() => {
// Keep existing cached value on error
// Don't cache on error - allow retry on next mount
setAccessCodeRequired(false)
})
}, [open])
}, [])
// Detect current language from pathname
useEffect(() => {
@@ -176,31 +143,13 @@ function SettingsContent({
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode)
const storedSendShortcut = localStorage.getItem(
STORAGE_KEYS.sendShortcut,
)
setSendShortcut(storedSendShortcut || "ctrl-enter")
setShowRecentChats(
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
)
setShowMyTemplates(
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
)
setShowQuickExamples(
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
"false",
const storedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
setError("")
// Load proxy settings (Electron only)
if (window.electronAPI?.getProxy) {
window.electronAPI.getProxy().then((config) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}
}
}, [open])
@@ -208,13 +157,6 @@ function SettingsContent({
// Save locale to localStorage for persistence across restarts
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("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
@@ -266,48 +208,8 @@ 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 (
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle>
@@ -317,29 +219,8 @@ function SettingsContent({
</DialogHeader>
{/* Content */}
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
<div className="px-6 pb-6">
<div className="divide-y divide-border-subtle">
{/* API Keys & Models */}
{onOpenModelConfig && (
<SettingItem
label={dict.settings.apiKeysModels}
description={dict.settings.apiKeysModelsDescription}
>
<Button
variant="ghost"
size="sm"
className="h-9 w-9 p-0"
onClick={() => {
onOpenChange(false)
onOpenModelConfig()
}}
aria-label={dict.settings.apiKeysModels}
>
<ChevronRight className="h-4 w-4" />
</Button>
</SettingItem>
)}
{/* Access Code (conditional) */}
{accessCodeRequired && (
<div className="py-4 first:pt-0 space-y-3">
@@ -433,40 +314,42 @@ function SettingsContent({
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={dict.settings.drawioStyleDescription}
description={`${dict.settings.drawioStyleDescription} ${
drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch
}`}
>
<Select
value={drawioUi}
onValueChange={(v) =>
onDrawioUiChange(v as DrawioTheme)
}
<Button
id="drawio-ui"
variant="outline"
onClick={onToggleDrawioUi}
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
>
<SelectTrigger
id="drawio-ui-select"
aria-label={dict.settings.drawioStyle}
className="w-[120px] h-9 rounded-xl"
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</SettingItem>
{/* Close Protection */}
<SettingItem
label={dict.settings.closeProtection}
description={dict.settings.closeProtectionDescription}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kennedy">
{dict.settings.themeDefault}
</SelectItem>
<SelectItem value="atlas">Atlas</SelectItem>
<SelectItem value="dark">
{dict.settings.themeDark}
</SelectItem>
<SelectItem value="min">
{dict.settings.themeMinimal}
</SelectItem>
<SelectItem value="sketch">
{dict.settings.themeSketch}
</SelectItem>
<SelectItem value="simple">
{dict.settings.themeSimple}
</SelectItem>
</SelectContent>
</Select>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</SettingItem>
{/* Diagram Style */}
@@ -487,194 +370,6 @@ function SettingsContent({
</span>
</div>
</SettingItem>
{/* Panel Visibility */}
<SettingItem
label={dict.settings.panelVisibility}
description={dict.settings.panelVisibilityDescription}
>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-recent-chats"
checked={showRecentChats}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showRecentChats,
v,
setShowRecentChats,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showRecentChats}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-my-templates"
checked={showMyTemplates}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showMyTemplates,
v,
setShowMyTemplates,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showMyTemplates}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-quick-examples"
checked={showQuickExamples}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showQuickExamples,
v,
setShowQuickExamples,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showQuickExamples}
</span>
</label>
</div>
</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>
{/* Custom System Message */}
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label
htmlFor="custom-system-message"
className="text-sm font-medium"
>
{dict.settings.customSystemMessage}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.customSystemMessageDescription}
</p>
</div>
<Textarea
id="custom-system-message"
value={customSystemMessage}
onChange={(e) =>
onCustomSystemMessageChange(e.target.value)
}
placeholder={
dict.settings.customSystemMessagePlaceholder
}
className="min-h-[80px] max-h-[160px] text-sm"
maxLength={5000}
/>
</div>
{/* 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>
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+5 -7
View File
@@ -77,13 +77,12 @@ function CommandInput({
)
}
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => {
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
{...props}
/>
)
})
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
}
function CommandEmpty({
...props
-116
View File
@@ -1,116 +0,0 @@
"use client"
import { Link, Loader2 } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { useDictionary } from "@/hooks/use-dictionary"
interface UrlInputDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (url: string) => void
isExtracting: boolean
}
export function UrlInputDialog({
open,
onOpenChange,
onSubmit,
isExtracting,
}: UrlInputDialogProps) {
const dict = useDictionary()
const [url, setUrl] = useState("")
const [error, setError] = useState("")
const handleSubmit = () => {
setError("")
if (!url.trim()) {
setError(dict.url.enterUrl)
return
}
try {
new URL(url)
} catch {
setError(dict.url.invalidFormat)
return
}
onSubmit(url.trim())
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isExtracting) {
e.preventDefault()
handleSubmit()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.url.title}</DialogTitle>
<DialogDescription>
{dict.url.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value)
setError("")
}}
onKeyDown={handleKeyDown}
placeholder="https://example.com/article"
disabled={isExtracting}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isExtracting}
>
{dict.url.Cancel}
</Button>
<Button
onClick={handleSubmit}
disabled={isExtracting || !url.trim()}
>
{isExtracting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{dict.url.Extracting}
</>
) : (
<>
<Link className="mr-2 h-4 w-4" />
{dict.url.extract}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+25 -70
View File
@@ -20,10 +20,9 @@ interface DiagramContextType {
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void
handleExportWithoutHistory: () => void
resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void
saveDiagramToFile: (
filename: string,
@@ -32,7 +31,6 @@ interface DiagramContextType {
successMessage?: string,
) => void
getThumbnailSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
@@ -53,10 +51,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | 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)
const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
@@ -65,10 +63,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
setIsDrawioReady(true)
// Restore diagram after remount (e.g., theme/UI change)
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
drawioRef.current.load({ xml: chartXMLRef.current })
}
}
const resetDrawioReady = () => {
@@ -81,6 +75,24 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
chartXMLRef.current = chartXML
}, [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) {
hasDiagramRestoredRef.current = false
return
}
// Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
}, [isDrawioReady])
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
@@ -135,37 +147,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// 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
}
}
const loadDiagram = (
chart: string,
skipValidation?: boolean,
@@ -205,13 +186,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
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)
if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format
@@ -219,8 +193,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute
// xmlsvg is saved directly as SVG file, no need for extraction
if (format === "png" || format === "svg" || format === "xmlsvg") {
if (format === "png" || format === "svg") {
return
}
}
@@ -253,16 +226,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
return
}
setChartXML(data.xml)
}
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>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -283,8 +246,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Map format to draw.io export format
const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
const drawioFormat = format === "drawio" ? "xmlsvg" : format
// Set up the resolver before triggering export
saveResolverRef.current = {
@@ -308,13 +270,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
fileContent = exportData
mimeType = "image/png"
extension = ".png"
} else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".drawio.svg"
} else {
// SVG format (view-only)
// SVG format
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".svg"
@@ -393,11 +350,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef,
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
clearDiagram,
saveDiagramToFile,
getThumbnailSvg,
captureValidationPng,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
-3
View File
@@ -11,9 +11,6 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
volumes:
# Persists admin panel settings (data/settings.json)
- ./data:/app/data
# environment:
# # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
-78
View File
@@ -1,78 +0,0 @@
# 常见问题解答 (FAQ)
---
## 1. 无法导出 PDF
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
**解决方案**: 先导出为图片(PNG),再打印转成 PDF
**相关 Issue**: #539, #125
---
## 2. 无法访问 embed.diagrams.net(离线/内网部署)
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
**解决方案**: 必须在构建时通过 `args` 传入:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
ports: ["3000:3000"]
env_file: .env
```
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
**相关 Issue**: #295, #317
---
## 3. 自建模型只思考不画图
**问题**: 本地部署的模型(如 Qwen、LiteLLM)只输出思考过程,不生成图表
**可能原因**:
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
**解决方案**: 开启 tool calling,例如 vLLM
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**相关 Issue**: #269, #75
---
## 4. 上传图片后提示"未提供图片"
**问题**: 上传图片后,系统显示"未提供图片"错误
**可能原因**:
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
**解决方案**:
- 使用支持视觉的模型:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
- 模型名带 `vision``vl` 的支持图片
- 更新到最新版本(v0.4.9+
**相关 Issue**: #324, #421, #469
+20 -37
View File
@@ -19,9 +19,7 @@
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型!
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -30,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目录](#目录)
- [示例](#示例)
- [功能特性](#功能特性)
- [MCP服务器](#mcp服务器)
- [MCP服务器(预览)](#mcp服务器预览)
- [Claude Code CLI](#claude-code-cli)
- [快速开始](#快速开始)
- [在线试用](#在线试用)
@@ -39,12 +37,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [安装](#安装)
- [部署](#部署)
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
- [部署到Vercel](#部署到vercel)
- [部署到Vercel(推荐)](#部署到vercel推荐)
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
- [多提供商支持](#多提供商支持)
- [工作原理](#工作原理)
- [支持与联系](#支持与联系)
- [常见问题](#常见问题)
- [Star历史](#star历史)
## 示例
@@ -56,31 +53,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr>
<td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>RAG技术图</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
<strong>GCP架构图</strong><br />
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
</td>
<td width="50%" valign="top">
<strong>React和AWS认证流程</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
<strong>AWS架构图</strong><br />
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>开放式创新</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
<strong>Azure架构图</strong><br />
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫咪素描</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
</td>
</tr>
@@ -98,7 +95,9 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器
## MCP服务器(预览)
> **预览功能**:此功能为实验性功能,可能不稳定。
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
@@ -180,7 +179,7 @@ npm run dev
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
### 部署到Vercel
### 部署到Vercel(推荐)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
@@ -195,18 +194,16 @@ npm run dev
## 多提供商支持
- [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock(默认)
- OpenAI
- Anthropic
- Google AI
- Google Vertex AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
@@ -214,20 +211,10 @@ npm run dev
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
## 工作原理
@@ -242,7 +229,7 @@ npm run dev
## 支持与联系
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token
**特别感谢[字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
@@ -250,10 +237,6 @@ npm run dev
- 邮箱:me[at]jiang.jp
## 常见问题
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
## Star历史
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
-24
View File
@@ -1,24 +0,0 @@
# 管理面板
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
## 启用面板
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
2. 访问 `/admin` 并登录。
## 可配置内容
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
## 优先级
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
## 注意事项
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
+2 -158
View File
@@ -13,7 +13,7 @@
### 豆包 (字节跳动火山引擎)
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token!
> **免费 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
@@ -53,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
可选的自定义端点:
```bash
@@ -159,19 +152,6 @@ 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
@@ -214,85 +194,6 @@ AI_MODEL=openai/gpt-4o
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
### MiniMax
MiniMax 支持两种 API 格式:
- **Anthropic 兼容**`/anthropic` 端点)— 推荐,支持 interleaved thinking
- **OpenAI 兼容**`/v1` 端点)— 标准 OpenAI 聊天补全格式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
可选配置:
```bash
# 中国大陆版,Anthropic 兼容(默认)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陆版,OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国际版,Anthropic 兼容
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国际版,OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (智谱 AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
可选的自定义端点:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (阿里云通义千问)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
可选的自定义端点:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (月之暗面 Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
可选的自定义端点:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (七牛云)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
可选的自定义端点:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`
@@ -300,66 +201,9 @@ QINIU_BASE_URL=https://your-custom-endpoint
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`
```bash
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
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)的长文本。
-78
View File
@@ -1,78 +0,0 @@
# Frequently Asked Questions (FAQ)
---
## 1. Cannot Export PDF
**Problem**: Web version redirects to `convert.diagrams.net/node/export` when exporting PDF, then nothing happens
**Cause**: Embedded Draw.io doesn't support direct PDF export, it relies on external conversion service which doesn't work in iframe
**Solution**: Export as image (PNG) first, then print to PDF
**Related Issues**: #539, #125
---
## 2. Cannot Access embed.diagrams.net (Offline/Intranet Deployment)
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
**Solution**: Must pass via `args` at build time:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://your-server-ip:8080/
ports: ["3000:3000"]
env_file: .env
```
**Intranet Users**: Modify Dockerfile and build image on external network, then transfer to intranet
**Related Issues**: #295, #317
---
## 3. Self-hosted Model Only Thinks But Doesn't Draw
**Problem**: Locally deployed models (e.g., Qwen, LiteLLM) only output thinking process, don't generate diagrams
**Possible Causes**:
1. **Model too small** - Small models struggle to follow tool calling instructions correctly, recommend 32B+ parameter models
2. **Tool calling not enabled** - Model service needs tool use configuration
**Solution**: Enable tool calling, e.g., vLLM:
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**Related Issues**: #269, #75
---
## 4. "No Image Provided" After Uploading Image
**Problem**: After uploading an image, the system shows "No image provided" error
**Possible Causes**:
1. Model doesn't support vision (e.g., Kimi K2, DeepSeek, Qwen text models)
**Solution**:
- Use vision-capable models: GPT-5.2, Claude 4.5 Sonnet, Gemini 3 Pro
- Models with `vision` or `vl` in name support images
- Update to latest version (v0.4.9+)
**Related Issues**: #324, #421, #469
-24
View File
@@ -1,24 +0,0 @@
# Admin Panel
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
## Enabling the panel
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
2. Visit `/admin` and sign in.
## What you can configure
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
## Precedence
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
## Notes
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
+2 -173
View File
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
### Doubao (ByteDance Volcengine)
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
> **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
```bash
DOUBAO_API_KEY=your_api_key
@@ -33,21 +33,6 @@ Optional 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
```bash
@@ -68,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
Optional custom endpoint:
```bash
@@ -180,19 +158,6 @@ Optional custom URL:
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 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,85 +194,6 @@ Model format uses `provider/model` syntax:
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
### MiniMax
MiniMax supports two API formats:
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
Optional configuration:
```bash
# China mainland, Anthropic-compatible (default)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# China mainland, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# International, Anthropic-compatible
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# International, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
Optional custom endpoint:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
Optional custom endpoint:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
Optional custom endpoint:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
Optional custom endpoint:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -315,66 +201,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`:
```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
```
## 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
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
-21
View File
@@ -22,27 +22,6 @@ cp env.example .env
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.
-78
View File
@@ -1,78 +0,0 @@
# よくある質問 (FAQ)
---
## 1. PDFをエクスポートできない
**問題**: Web版でPDFエクスポートをクリックすると `convert.diagrams.net/node/export` にリダイレクトされ、その後何も起こらない
**原因**: 埋め込みDraw.ioは直接PDFエクスポートをサポートしておらず、外部変換サービスに依存しているが、iframe内では正常に動作しない
**解決策**: まず画像(PNG)としてエクスポートし、その後PDFに印刷する
**関連Issue**: #539, #125
---
## 2. embed.diagrams.netにアクセスできない(オフライン/イントラネットデプロイ)
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
**解決策**: ビルド時に `args` で渡す必要があります:
```yaml
# docker-compose.yml
services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io:
build:
context: .
args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://あなたのサーバーIP:8080/
ports: ["3000:3000"]
env_file: .env
```
**イントラネットユーザー**: 外部ネットワークでDockerfileを修正してイメージをビルドし、イントラネットに転送する
**関連Issue**: #295, #317
---
## 3. 自前モデルが思考するだけで描画しない
**問題**: ローカルデプロイのモデル(Qwen、LiteLLMなど)が思考過程のみを出力し、図表を生成しない
**考えられる原因**:
1. **モデルが小さすぎる** - 小さいモデルはtool calling指示に正しく従うことが難しい、32B+パラメータのモデルを推奨
2. **tool callingが有効になっていない** - モデルサービスでtool use機能を設定する必要がある
**解決策**: tool callingを有効にする、例えばvLLM:
```bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen3-32B \
--enable-auto-tool-choice \
--tool-call-parser hermes
```
**関連Issue**: #269, #75
---
## 4. 画像アップロード後「画像が提供されていません」と表示される
**問題**: 画像をアップロードした後、「画像が提供されていません」というエラーが表示される
**考えられる原因**:
1. モデルがビジョン機能をサポートしていない(Kimi K2、DeepSeek、Qwenテキストモデルなど)
**解決策**:
- ビジョン対応モデルを使用:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
- モデル名に `vision` または `vl` が含まれているものは画像をサポート
- 最新バージョン(v0.4.9+)にアップデート
**関連Issue**: #324, #421, #469
+20 -35
View File
@@ -19,7 +19,7 @@
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました!
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目次](#目次)
- [](#例)
- [機能](#機能)
- [MCPサーバー](#mcpサーバー)
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
- [Claude Code CLI](#claude-code-cli)
- [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す)
@@ -37,12 +37,11 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [インストール](#インストール)
- [デプロイ](#デプロイ)
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
- [Vercelへのデプロイ](#vercelへのデプロイ)
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨)
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
- [仕組み](#仕組み)
- [サポート&お問い合わせ](#サポートお問い合わせ)
- [よくある質問](#よくある質問)
- [スター履歴](#スター履歴)
## 例
@@ -54,31 +53,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr>
<td colspan="2" valign="top" align="center">
<strong>アニメーションTransformerコネクタ</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>RAG技術ダイアグラム</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
<strong>GCPアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
</td>
<td width="50%" valign="top">
<strong>ReactとAWSによる認証</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
<strong>AWSアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>オープンイノベーション</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
<strong>Azureアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫のスケッチ</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
</td>
</tr>
@@ -96,7 +95,9 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバー
## MCPサーバー(プレビュー)
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
MCPModel Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
@@ -179,7 +180,7 @@ npm run dev
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
### Vercelへのデプロイ
### Vercelへのデプロイ(推奨)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
@@ -194,18 +195,16 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
## マルチプロバイダーサポート
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- AWS Bedrock(デフォルト)
- OpenAI
- Anthropic
- Google AI
- Google Vertex AI
- Azure OpenAI
- Ollama
- OpenRouter
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
@@ -213,20 +212,10 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
### 管理パネル
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
## 仕組み
@@ -241,7 +230,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
## サポート&お問い合わせ
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
@@ -249,10 +238,6 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
- メール:me[at]jiang.jp
## よくある質問
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
## スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
-24
View File
@@ -1,24 +0,0 @@
# 管理パネル
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
## パネルの有効化
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
2. `/admin` にアクセスしてサインインします。
## 設定できる項目
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
## 優先順位
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
## 注意事項
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
+2 -158
View File
@@ -13,7 +13,7 @@
### Doubao (ByteDance Volcengine)
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
> **無料トークン**: [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
@@ -53,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN``Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY``x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
任意のカスタムエンドポイント:
```bash
@@ -165,19 +158,6 @@ AI_MODEL=llama3.2
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 キーを管理することなくプロバイダーを切り替えることができます。
@@ -214,85 +194,6 @@ AI_MODEL=openai/gpt-4o
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
### MiniMax
MiniMax は 2 つの API 形式をサポートしています:
- **Anthropic 互換**`/anthropic` エンドポイント)— 推奨、インターリーブ思考をサポート
- **OpenAI 互換**`/v1` エンドポイント)— 標準 OpenAI チャット補完形式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
オプション設定:
```bash
# 中国大陸版、Anthropic 互換(デフォルト)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陸版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国際版、Anthropic 互換
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国際版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
オプションのカスタムエンドポイント:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
オプションのカスタムエンドポイント:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
オプションのカスタムエンドポイント:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
オプションのカスタムエンドポイント:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
@@ -300,66 +201,9 @@ QINIU_BASE_URL=https://your-custom-endpoint
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
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)を伴う長文テキストの生成を含むため、非常に強力なモデル性能が必要です。
-367
View File
@@ -1,367 +0,0 @@
# material_design
**Type:** SVG images (Google Material Icons CDN)
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
</mxCell>
```
Replace `{icon_name}` with any icon name from the list below.
## action (115)
- `account_balance`
- `account_balance_wallet`
- `account_box`
- `account_circle`
- `add_shopping_cart`
- `admin_panel_settings`
- `analytics`
- `arrow_right_alt`
- `article`
- `assessment`
- `assignment`
- `assignment_ind`
- `assignment_turned_in`
- `autorenew`
- `bookmark`
- `bookmark_border`
- `build`
- `calendar_month`
- `calendar_today`
- `card_giftcard`
- `check_circle`
- `check_circle_outline`
- `code`
- `contact_support`
- `credit_card`
- `dashboard`
- `date_range`
- `delete`
- `delete_forever`
- `delete_outline`
- `description`
- `dns`
- `done`
- `done_all`
- `done_outline`
- `drag_indicator`
- `event`
- `exit_to_app`
- `explore`
- `face`
- `fact_check`
- `favorite`
- `favorite_border`
- `feedback`
- `filter_alt`
- `fingerprint`
- `flight_takeoff`
- `grade`
- `help`
- `help_outline`
- `highlight_off`
- `history`
- `home`
- `info`
- `label`
- `language`
- `launch`
- `leaderboard`
- `lightbulb`
- `list`
- `lock`
- `lock_open`
- `login`
- `logout`
- `manage_accounts`
- `note_add`
- `open_in_full`
- `open_in_new`
- `paid`
- `payment`
- `pending`
- `pending_actions`
- `perm_identity`
- `pets`
- `power_settings_new`
- `preview`
- `print`
- `published_with_changes`
- `question_answer`
- `receipt`
- `reorder`
- `report_problem`
- `room`
- `savings`
- `schedule`
- `search`
- `settings`
- `shopping_bag`
- `shopping_basket`
- `shopping_cart`
- `star_rate`
- `stars`
- `store`
- `supervisor_account`
- `swap_horiz`
- `sync_alt`
- `task_alt`
- `thumb_up`
- `thumb_up_off_alt`
- `timeline`
- `tips_and_updates`
- `today`
- `touch_app`
- `trending_up`
- `update`
- `verified`
- `verified_user`
- `view_in_ar`
- `view_list`
- `visibility`
- `visibility_off`
- `watch_later`
- `work`
- `work_outline`
- `zoom_in`
## alert (4)
- `error`
- `error_outline`
- `warning`
- `warning_amber`
## av (12)
- `library_books`
- `mic`
- `pause`
- `play_arrow`
- `play_circle`
- `play_circle_filled`
- `play_circle_outline`
- `replay`
- `skip_next`
- `videocam`
- `volume_off`
- `volume_up`
## communication (13)
- `alternate_email`
- `business`
- `call`
- `chat`
- `chat_bubble_outline`
- `email`
- `forum`
- `list_alt`
- `location_on`
- `mail_outline`
- `phone`
- `qr_code_scanner`
- `vpn_key`
## content (27)
- `add`
- `add_box`
- `add_circle`
- `add_circle_outline`
- `block`
- `bolt`
- `calculate`
- `clear`
- `content_copy`
- `create`
- `filter_list`
- `flag`
- `how_to_reg`
- `insights`
- `inventory`
- `inventory_2`
- `link`
- `mail`
- `push_pin`
- `remove`
- `remove_circle`
- `remove_circle_outline`
- `reply`
- `save`
- `send`
- `sort`
- `undo`
## device (9)
- `dark_mode`
- `devices`
- `light_mode`
- `password`
- `restart_alt`
- `sell`
- `signal_cellular_alt`
- `summarize`
- `task`
## editor (9)
- `attach_file`
- `attach_money`
- `bar_chart`
- `checklist`
- `edit_note`
- `format_list_bulleted`
- `mode_edit`
- `monetization_on`
- `post_add`
## file (8)
- `cloud_upload`
- `download`
- `file_download`
- `file_upload`
- `folder`
- `folder_open`
- `grid_view`
- `upload_file`
## hardware (6)
- `computer`
- `keyboard_arrow_down`
- `keyboard_arrow_right`
- `phone_iphone`
- `security`
- `smartphone`
## image (16)
- `add_a_photo`
- `auto_awesome`
- `auto_stories`
- `circle`
- `collections`
- `edit`
- `image`
- `navigate_before`
- `navigate_next`
- `palette`
- `photo_camera`
- `picture_as_pdf`
- `receipt_long`
- `remove_red_eye`
- `timer`
- `tune`
## maps (11)
- `badge`
- `category`
- `directions_car`
- `local_fire_department`
- `local_offer`
- `local_shipping`
- `map`
- `menu_book`
- `place`
- `restaurant`
- `volunteer_activism`
## navigation (29)
- `apps`
- `arrow_back`
- `arrow_back_ios`
- `arrow_back_ios_new`
- `arrow_downward`
- `arrow_drop_down`
- `arrow_drop_up`
- `arrow_forward`
- `arrow_forward_ios`
- `arrow_right`
- `arrow_upward`
- `campaign`
- `cancel`
- `check`
- `chevron_left`
- `chevron_right`
- `close`
- `double_arrow`
- `east`
- `expand_less`
- `expand_more`
- `fullscreen`
- `menu`
- `menu_open`
- `more_horiz`
- `more_vert`
- `payments`
- `refresh`
- `unfold_more`
## notification (6)
- `account_tree`
- `event_available`
- `priority_high`
- `support_agent`
- `sync`
- `wifi`
## places (2)
- `apartment`
- `storefront`
## search (2)
- `feed`
- `manage_search`
## social (23)
- `construction`
- `emoji_emotions`
- `emoji_events`
- `engineering`
- `group`
- `group_add`
- `groups`
- `health_and_safety`
- `notifications`
- `notifications_active`
- `notifications_none`
- `people`
- `people_alt`
- `person`
- `person_add`
- `person_outline`
- `psychology`
- `public`
- `school`
- `share`
- `thumb_up_alt`
- `travel_explore`
- `water_drop`
## toggle (8)
- `check_box`
- `check_box_outline_blank`
- `radio_button_checked`
- `radio_button_unchecked`
- `star`
- `star_border`
- `star_outline`
- `toggle_on`
Total: 300 icons (top by popularity from 2100+ available)
+5 -15
View File
@@ -10,13 +10,8 @@ directories:
afterPack: ./scripts/afterPack.cjs
files:
- from: dist-electron
to: dist-electron
filter:
- "**/*"
- from: .
filter:
- package.json
- dist-electron/**/*
- "!node_modules"
asarUnpack:
- "**/*.node"
@@ -42,11 +37,10 @@ mac:
arch:
- x64
- arm64
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
# to properly sign nested bundles with --deep flag for bundled draw.io files
identity: null
hardenedRuntime: false
hardenedRuntime: true
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
dmg:
contents:
@@ -95,10 +89,6 @@ linux:
arch:
- x64
- arm64
- target: rpm
arch:
- x64
- arm64
# Publish configuration (optional)
publish:
+1 -36
View File
@@ -25,25 +25,6 @@ interface ApplyPresetResult {
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 {
interface Window {
/** Main window Electron API */
@@ -64,16 +45,6 @@ declare global {
openFile: () => Promise<string | null>
/** Save data to file via save dialog */
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" | "zh-Hant" | undefined
>
/** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
}
/** Settings window Electron API */
@@ -100,10 +71,4 @@ declare global {
}
}
export type {
ApplyPresetResult,
ConfigPreset,
ProxyConfig,
SetProxyResult,
SetUserLocaleResult,
}
export { ConfigPreset, ApplyPresetResult }
+46 -53
View File
@@ -12,12 +12,11 @@ import {
getCurrentPresetId,
setCurrentPreset,
} from "./config-manager"
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
import { restartNextServer } from "./next-server"
import { showSettingsWindow } from "./settings-window"
/**
* Build and set the application menu with i18n support
* Build and set the application menu
*/
export function buildAppMenu(): void {
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 {
buildAppMenu()
}
/**
* Get the menu template with translations
* Get the menu template
*/
function getMenuTemplate(): MenuItemConstructorOptions[] {
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[] = []
// macOS app menu
@@ -49,10 +44,10 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
template.push({
label: app.name,
submenu: [
{ role: "about" }, // System-translated
{ role: "about" },
{ type: "separator" },
{
label: t.settings,
label: "Settings...",
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
@@ -60,26 +55,26 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
},
},
{ type: "separator" },
{ role: "services" }, // System-translated
{ role: "services" },
{ type: "separator" },
{ role: "hide" }, // System-translated
{ role: "hideOthers" }, // System-translated
{ role: "unhide" }, // System-translated
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" }, // System-translated
{ role: "quit" },
],
})
}
// File menu
template.push({
label: t.file,
label: "File",
submenu: [
...(isMac
? []
: [
{
label: t.settings,
label: "Settings",
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
@@ -88,76 +83,76 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
},
{ type: "separator" } as MenuItemConstructorOptions,
]),
isMac ? { role: "close" } : { role: "quit" }, // System-translated
isMac ? { role: "close" } : { role: "quit" },
],
})
// Edit menu
template.push({
label: t.edit,
label: "Edit",
submenu: [
{ role: "undo" }, // System-translated
{ role: "redo" }, // System-translated
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" }, // System-translated
{ role: "copy" }, // System-translated
{ role: "paste" }, // System-translated
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
...(isMac
? [
{
role: "pasteAndMatchStyle",
} as MenuItemConstructorOptions, // System-translated
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
} as MenuItemConstructorOptions,
{ role: "delete" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions,
]
: [
{ role: "delete" } as MenuItemConstructorOptions, // System-translated
{ role: "delete" } as MenuItemConstructorOptions,
{ type: "separator" } as MenuItemConstructorOptions,
{ role: "selectAll" } as MenuItemConstructorOptions, // System-translated
{ role: "selectAll" } as MenuItemConstructorOptions,
]),
],
})
// View menu
template.push({
label: t.view,
label: "View",
submenu: [
{ role: "reload" }, // System-translated
{ role: "forceReload" }, // System-translated
{ role: "toggleDevTools" }, // System-translated
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" }, // System-translated
{ role: "zoomIn" }, // System-translated
{ role: "zoomOut" }, // System-translated
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" }, // System-translated
{ role: "togglefullscreen" },
],
})
// Configuration menu with presets
template.push(buildConfigMenu(t))
template.push(buildConfigMenu())
// Window menu
template.push({
label: t.window,
label: "Window",
submenu: [
{ role: "minimize" }, // System-translated
{ role: "zoom" }, // System-translated
{ role: "minimize" },
{ role: "zoom" },
...(isMac
? [
{ 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
template.push({
label: t.help,
label: "Help",
submenu: [
{
label: t.documentation,
label: "Documentation",
click: async () => {
await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io",
@@ -165,7 +160,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
},
},
{
label: t.reportIssue,
label: "Report Issue",
click: async () => {
await shell.openExternal(
"https://github.com/dayuanjiang/next-ai-draw-io/issues",
@@ -181,9 +176,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
/**
* Build the Configuration menu with presets
*/
function buildConfigMenu(
t: ReturnType<typeof getMenuTranslations>,
): MenuItemConstructorOptions {
function buildConfigMenu(): MenuItemConstructorOptions {
const presets = getAllPresets()
const currentPresetId = getCurrentPresetId()
@@ -223,11 +216,11 @@ function buildConfigMenu(
}))
return {
label: t.configuration,
label: "Configuration",
submenu: [
...(presetItems.length > 0
? [
{ label: t.switchPreset, enabled: false },
{ label: "Switch Preset", enabled: false },
{ type: "separator" } as MenuItemConstructorOptions,
...presetItems,
{ type: "separator" } as MenuItemConstructorOptions,
@@ -236,8 +229,8 @@ function buildConfigMenu(
{
label:
presetItems.length > 0
? t.managePresets
: t.addConfigurationPreset,
? "Manage Presets..."
: "Add Configuration Preset...",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
+2 -29
View File
@@ -137,7 +137,6 @@ interface ConfigPresetsFile {
version: 1
currentPresetId: string | null
presets: ConfigPreset[]
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
}
const CONFIG_FILE_NAME = "config-presets.json"
@@ -162,7 +161,6 @@ export function loadPresets(): ConfigPresetsFile {
version: 1,
currentPresetId: null,
presets: [],
userLocale: undefined,
}
}
@@ -183,7 +181,6 @@ export function loadPresets(): ConfigPresetsFile {
version: 1,
currentPresetId: null,
presets: [],
userLocale: undefined,
}
}
}
@@ -354,14 +351,10 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
apiKey: "SILICONFLOW_API_KEY",
baseUrl: "SILICONFLOW_BASE_URL",
},
modelscope: {
apiKey: "MODELSCOPE_API_KEY",
baseUrl: "MODELSCOPE_BASE_URL",
},
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
// bedrock doesn't use API keys in the same way
// bedrock and ollama don't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
}
/**
@@ -465,23 +458,3 @@ export function getCurrentPresetEnv(): Record<string, string> {
}
return env
}
/**
* Get user's preferred locale from config
* Returns undefined if not set
*/
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
const data = loadPresets()
return data.userLocale
}
/**
* Set user's preferred locale in config
*/
export function setUserLocale(
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
): void {
const data = loadPresets()
data.userLocale = locale === null ? undefined : locale
savePresets(data)
}
+1 -6
View File
@@ -4,7 +4,6 @@ import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager"
import { registerSettingsWindowHandlers } from "./settings-window"
import { createWindow, getMainWindow } from "./window-manager"
@@ -25,9 +24,6 @@ if (!gotTheLock) {
// Load environment variables from .env files
loadEnvFile()
// Apply proxy settings from saved config
applyProxyToEnv()
// Apply saved preset environment variables (overrides .env)
const presetEnv = getCurrentPresetEnv()
for (const [key, value] of Object.entries(presetEnv)) {
@@ -94,8 +90,7 @@ if (!gotTheLock) {
if (
url.includes("diagrams.net") ||
url.includes("draw.io") ||
url.startsWith("http://localhost") ||
url.startsWith("http://127.0.0.1")
url.startsWith("http://localhost")
) {
return { action: "allow" }
}
-73
View File
@@ -1,5 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain } from "electron"
import { rebuildAppMenu } from "./app-menu"
import {
applyPresetToEnv,
type ConfigPreset,
@@ -8,18 +7,10 @@ import {
getAllPresets,
getCurrentPreset,
getCurrentPresetId,
getUserLocale,
setCurrentPreset,
setUserLocale,
updatePreset,
} from "./config-manager"
import { restartNextServer } from "./next-server"
import {
applyProxyToEnv,
getProxyConfig,
type ProxyConfig,
saveProxyConfig,
} from "./proxy-manager"
/**
* Allowed configuration keys for presets
@@ -218,68 +209,4 @@ export function registerIpcHandlers(): void {
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", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" }
}
try {
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
// 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",
}
}
})
}
-211
View File
@@ -1,211 +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" | "zh-Hant"
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: "問題を報告",
},
"zh-Hant": {
// 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 {
// Check for zh-Hant before normalizing
if (
locale === "zh-Hant" ||
locale.toLowerCase().startsWith("zh-hant") ||
locale.toLowerCase().startsWith("zh-tw") ||
locale.toLowerCase().startsWith("zh-hk")
) {
return translations["zh-Hant"]
}
// 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", "zh-Hant"
*/
export function detectSystemLocale(appLocale: string): MenuLocale {
const lower = appLocale.toLowerCase()
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
if (
lower.startsWith("zh-hant") ||
lower.startsWith("zh-tw") ||
lower.startsWith("zh-hk")
) {
return "zh-Hant"
}
const normalized = lower.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)
}
+5 -42
View File
@@ -68,9 +68,7 @@ export async function startNextServer(): Promise<string> {
const env: Record<string, string> = {
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "127.0.0.1",
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1",
HOSTNAME: "localhost",
}
// 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
// This is the recommended way to run Node.js code in Electron
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) {
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 = 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> {
console.log("Restarting Next.js server...")
// Stop the current server and wait for it to exit
await stopNextServer()
// Stop the current server
stopNextServer()
// Wait for the port to be released
await waitForServerStop()
+10 -38
View File
@@ -9,11 +9,9 @@ import { app } from "electron"
const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility
development: 6002,
// Legacy production port — tried first to preserve localStorage for existing users
legacyProduction: 61337,
// New production port below the ephemeral range (49152-65535)
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
production: 13370,
// Production mode uses fixed port (61337) to preserve localStorage
// Falls back to sequential ports if unavailable
production: 61337,
// Maximum attempts to find an available port (fallback)
maxAttempts: 100,
}
@@ -29,10 +27,7 @@ let allocatedPort: number | null = null
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once("error", (err: NodeJS.ErrnoException) => {
console.warn(`Port ${port} unavailable: ${err.code}`)
resolve(false)
})
server.once("error", () => resolve(false))
server.once("listening", () => {
server.close()
resolve(true)
@@ -44,12 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
/**
* Find an available port
* - In development: uses fixed port (6002)
* - In production: uses fixed port (13370) to preserve localStorage
* - In production: uses fixed port (61337) to preserve localStorage
* - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0)
*
* @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port
* @throws Error if no available port found after max attempts
*/
export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged
@@ -69,16 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null
}
// In production, try legacy port first to preserve existing users' localStorage
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
if (await isPortAvailable(legacyPort)) {
allocatedPort = legacyPort
return legacyPort
}
}
// Try preferred port
// Try preferred port first
if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort
return preferredPort
@@ -98,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
}
}
// Last resort: let the OS pick an available port
console.warn(
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
throw new Error(
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
)
const osPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.once("listening", () => {
const addr = server.address()
const port = (addr as net.AddressInfo).port
server.close(() => resolve(port))
})
server.listen(0, "127.0.0.1")
})
allocatedPort = osPort
console.log(`OS assigned port: ${osPort}`)
return osPort
}
/**
@@ -141,5 +113,5 @@ export function getServerUrl(): string {
"No port allocated yet. Call findAvailablePort() first.",
)
}
return `http://127.0.0.1:${allocatedPort}`
return `http://localhost:${allocatedPort}`
}
-75
View File
@@ -1,75 +0,0 @@
import { app } from "electron"
import * as fs from "fs"
import * as path from "path"
import type { ProxyConfig } from "../electron.d"
export type { ProxyConfig }
const CONFIG_FILE = "proxy-config.json"
function getConfigPath(): string {
return path.join(app.getPath("userData"), CONFIG_FILE)
}
/**
* Load proxy configuration from JSON file
*/
export function loadProxyConfig(): ProxyConfig {
try {
const configPath = getConfigPath()
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, "utf-8")
return JSON.parse(data) as ProxyConfig
}
} catch (error) {
console.error("Failed to load proxy config:", error)
}
return {}
}
/**
* Save proxy configuration to JSON file
*/
export function saveProxyConfig(config: ProxyConfig): void {
try {
const configPath = getConfigPath()
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
} catch (error) {
console.error("Failed to save proxy config:", error)
throw error
}
}
/**
* Apply proxy configuration to process.env
* Must be called BEFORE starting the Next.js server
*/
export function applyProxyToEnv(): void {
const config = loadProxyConfig()
if (config.httpProxy) {
process.env.HTTP_PROXY = config.httpProxy
process.env.http_proxy = config.httpProxy
} else {
delete process.env.HTTP_PROXY
delete process.env.http_proxy
}
if (config.httpsProxy) {
process.env.HTTPS_PROXY = config.httpsProxy
process.env.https_proxy = config.httpsProxy
} else {
delete process.env.HTTPS_PROXY
delete process.env.https_proxy
}
}
/**
* Get current proxy configuration (from process.env)
*/
export function getProxyConfig(): ProxyConfig {
return {
httpProxy: process.env.HTTP_PROXY || process.env.http_proxy || "",
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || "",
}
}
+1 -12
View File
@@ -60,24 +60,13 @@ export function createWindow(serverUrl: string): BrowserWindow {
mainWindow.webContents.openDevTools()
}
// Override the draw.io iframe's beforeunload handler so the window can
// close after the user edits text in a shape (fixes #815). Diagrams are
// already persisted via autosave, so the prompt is unnecessary.
mainWindow.webContents.on("will-prevent-unload", (event) => {
event.preventDefault()
})
mainWindow.on("closed", () => {
mainWindow = null
})
// Handle page title updates
mainWindow.webContents.on("page-title-updated", (event, title) => {
if (
title &&
!title.includes("localhost") &&
!title.includes("127.0.0.1")
) {
if (title && !title.includes("localhost")) {
mainWindow?.setTitle(title)
} else {
event.preventDefault()
-10
View File
@@ -21,14 +21,4 @@ contextBridge.exposeInMainWorld("electronAPI", {
// File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"),
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),
})
-1
View File
@@ -55,7 +55,6 @@
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option>
<option value="modelscope">ModelScope</option>
<option value="ollama">Ollama (Local)</option>
</select>
</div>
-1
View File
@@ -288,7 +288,6 @@ function getProviderLabel(provider) {
openrouter: "OpenRouter",
deepseek: "DeepSeek",
siliconflow: "SiliconFlow",
modelscope: "ModelScope",
ollama: "Ollama",
}
return labels[provider] || provider
+4 -73
View File
@@ -1,6 +1,6 @@
# AI Provider Configuration
# AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Default: bedrock
AI_PROVIDER=bedrock
@@ -25,8 +25,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
# Anthropic (Direct) Configuration
# ANTHROPIC_API_KEY=sk-ant-... # Sent as `x-api-key` header
# ANTHROPIC_AUTH_TOKEN= # Alternative to ANTHROPIC_API_KEY; sent as `Authorization: Bearer` header (mutually exclusive)
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
@@ -41,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_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
# Configure endpoint using ONE of these methods:
# 1. AZURE_RESOURCE_NAME - SDK constructs: https://{name}.openai.azure.com/openai/v1{path}
@@ -60,9 +51,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
# AZURE_REASONING_SUMMARY=detailed
# Ollama Configuration (Local or Cloud)
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
# Ollama (Local) Configuration
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
# OpenRouter Configuration
@@ -82,10 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# ModelScope Configuration
# MODELSCOPE_API_KEY=ms-...
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
# ByteDance Doubao Configuration (via Volcengine)
# DOUBAO_API_KEY=your-doubao-api-key
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
@@ -103,11 +89,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# LANGFUSE_SECRET_KEY=sk-lf-...
# 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)
# Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
@@ -116,14 +97,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code
# Admin Panel (Optional)
# Set a password to enable the web admin panel at /admin, where most of the
# settings in this file can be edited at runtime (stored in data/settings.json,
# which takes precedence over environment variables).
# Leave unset to disable the admin panel entirely.
# ADMIN_PASSWORD=your-admin-password
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Draw.io Configuration (Optional)
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
@@ -139,45 +112,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Enabled by default. Set to "false" to disable.
# ENABLE_PDF_INPUT=true
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
# 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
# Self-hosted deployment (Optional)
# Self-hosted users may implement custom quota-management solutions,
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
# NEXT_PUBLIC_SELFHOSTED=true
# Minimax Configuration (Optional)
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
# MINIMAX_API_KEY=your_minimax_api_key
# MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic # Optional, default (China mainland)
# GLM Configuration (Optional)
# Get your API key from: https://open.bigmodel.cn/dev/api
# GLM_API_KEY=your_glm_api_key
# GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # Optional, default
# Qwen Configuration (Optional)
# Get your API key from: https://www.aliyun.com/product/bailian
# QWEN_API_KEY=your_qwen_api_key
# QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Optional, default
# Kimi Configuration (Optional)
# Get your API key from: https://platform.moonshot.cn/
# KIMI_API_KEY=your_kimi_api_key
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # Optional, default
# Qiniu Configuration (Optional)
# Get your API key from: https://www.qiniu.com/ai/models
# QINIU_API_KEY=your_qiniu_api_key
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
# Novita AI Configuration (Optional)
# Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
+6 -203
View File
@@ -1,12 +1,4 @@
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"
const DEBUG = process.env.NODE_ENV === "development"
@@ -37,13 +29,11 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void
const MAX_VALIDATION_RETRIES = 3
// Type for the validation function passed from useValidateDiagram hook
type ValidateDiagramFn = (
imageData: string,
sessionId?: string,
) => Promise<ValidationResult>
interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string>
@@ -52,14 +42,6 @@ interface UseDiagramToolHandlersParams {
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string>
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,
onFetchChart,
onExport,
captureValidationPng,
validateDiagram,
enableVlmValidation = true,
sessionId,
onValidationStateChange,
}: 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 (
{ toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn,
@@ -205,159 +160,7 @@ ${finalXml}
// Success - diagram will be rendered by chat-message-display
if (DEBUG) {
console.log(
"[display_diagram] Success! Checking if VLM validation is enabled...",
)
}
// 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",
"[display_diagram] Success! Adding tool output with state: output-available",
)
}
addToolOutput({
+6 -105
View File
@@ -1,8 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { getApiEndpoint } from "@/lib/base-path"
import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage"
import {
createEmptyConfig,
@@ -134,56 +132,14 @@ export interface UseModelConfigReturn {
export function useModelConfig(): UseModelConfigReturn {
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
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(() => {
const loaded = loadConfig()
setConfig(loaded)
setIsLoaded(true)
}, [])
// Load server models on mount (if any)
useEffect(() => {
if (typeof window === "undefined") return
fetch(getApiEndpoint("/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)
useEffect(() => {
if (isLoaded) {
@@ -192,33 +148,9 @@ export function useModelConfig(): UseModelConfigReturn {
}, [config, isLoaded])
// Derived state
const userModels = 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 models = flattenModels(config)
const selectedModel = config.selectedModelId
? models.find((m) => m.id === config.selectedModelId)
? findModelById(config, config.selectedModelId)
: undefined
// Actions
@@ -350,7 +282,7 @@ export function useModelConfig(): UseModelConfigReturn {
return {
config,
isLoaded: isLoaded && serverLoaded,
isLoaded,
models,
selectedModel,
selectedModelId: config.selectedModelId,
@@ -382,10 +314,6 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: string
awsRegion: string
awsSessionToken: string
// Selected model ID (for server model lookup)
selectedModelId: string
// Vertex AI credentials (Express Mode)
vertexApiKey: string
} {
const empty = {
accessCode: "",
@@ -397,8 +325,6 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
}
if (typeof window === "undefined") return empty
@@ -421,8 +347,6 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
}
}
@@ -433,32 +357,12 @@ export function getSelectedAIConfig(): {
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) {
return { ...empty, accessCode }
}
// Server-side model selection (id = "server:<name-slug>:<modelId>")
// 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
// Find selected model
const model = findModelById(config, config.selectedModelId)
if (!model) {
return { ...empty, accessCode }
@@ -475,8 +379,5 @@ export function getSelectedAIConfig(): {
awsSecretAccessKey: model.awsSecretAccessKey || "",
awsRegion: model.awsRegion || "",
awsSessionToken: model.awsSessionToken || "",
selectedModelId: config.selectedModelId || "",
// Vertex AI credentials (Express Mode)
vertexApiKey: model.vertexApiKey || "",
}
}
-136
View File
@@ -1,136 +0,0 @@
"use client"
/**
* Hook for VLM-based diagram validation using AI SDK's useObject.
*/
import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react"
import { getApiEndpoint } from "@/lib/base-path"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export type { ValidationResult }
// Default valid result for fallback cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
interface UseValidateDiagramOptions {
onSuccess?: (result: ValidationResult) => void
onError?: (error: Error) => void
}
// Track pending validation promises for imperative API
type PendingValidation = {
resolve: (result: ValidationResult) => void
reject: (error: Error) => void
}
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { onSuccess, onError } = options
const pendingValidationRef = useRef<PendingValidation | null>(null)
const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema,
onFinish: ({
object,
error: finishError,
}: {
object: ValidationResult | undefined
error: Error | undefined
}) => {
if (finishError) {
console.error(
"[useValidateDiagram] Validation error:",
finishError,
)
onError?.(finishError)
pendingValidationRef.current?.reject(finishError)
pendingValidationRef.current = null
return
}
if (object) {
const result = object as ValidationResult
onSuccess?.(result)
pendingValidationRef.current?.resolve(result)
pendingValidationRef.current = null
}
},
onError: (err: Error) => {
console.error("[useValidateDiagram] Stream error:", err)
onError?.(err)
pendingValidationRef.current?.reject(err)
pendingValidationRef.current = null
},
})
/**
* Validate a diagram image.
* Returns a promise that resolves with the validation result.
*/
const validate = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
// Reject any pending validation to prevent promise leaks
if (pendingValidationRef.current) {
pendingValidationRef.current.reject(
new Error("Validation superseded by new request"),
)
pendingValidationRef.current = null
}
return new Promise((resolve, reject) => {
// Store the promise handlers
pendingValidationRef.current = { resolve, reject }
// Submit the validation request
submit({ imageData, sessionId })
})
},
[submit],
)
/**
* Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures.
*/
const validateWithFallback = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
try {
return await validate(imageData, sessionId)
} catch (error) {
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
)
return DEFAULT_VALID_RESULT
}
},
[validate],
)
return {
// Validation functions
validate,
validateWithFallback,
stop,
// State
isValidating: isLoading,
partialResult: object as ValidationResult | undefined,
error,
}
}
+1 -11
View File
@@ -1,17 +1,7 @@
import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export async function register() {
// Overlay admin settings file onto process.env before anything reads config
if (process.env.NEXT_RUNTIME === "nodejs") {
try {
const { applyToEnv } = await import("@/lib/admin/settings")
applyToEnv()
} catch (err) {
console.error("[admin-settings] Failed to apply settings:", err)
}
}
export function register() {
// Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn(
-37
View File
@@ -1,37 +0,0 @@
import { timingSafeEqual } from "crypto"
// Shared auth for admin API routes: compares x-admin-password header
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
export function checkAdminAuth(req: Request): Response | null {
const password = process.env.ADMIN_PASSWORD
if (!password) {
return Response.json(
{
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
},
{ status: 403 },
)
}
const provided = req.headers.get("x-admin-password") || ""
const a = Buffer.from(provided)
const b = Buffer.from(password)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return Response.json(
{ error: "Invalid admin password" },
{ status: 401 },
)
}
return null
}
export interface MaskedSecret {
isSet: true
hint: string
}
export function maskSecret(value: string): MaskedSecret {
return {
isSet: true,
hint: value.length > 8 ? `${value.slice(-4)}` : "••••",
}
}
-303
View File
@@ -1,303 +0,0 @@
import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
} from "@/lib/server-model-config"
import {
FIXED_CRED_PROVIDERS,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
import { type MaskedSecret, maskSecret } from "./auth"
import { loadSettings } from "./settings"
// Admin-configured providers, mirroring the user ModelConfigDialog's data
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
//
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
// loadRawServerModelsConfig() merges the env baseline with the panel's
// providers at read time, so .env stays authoritative for its own entries.
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
// OPENAI_API_KEY that env-based entries may rely on.
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
// A secret field in transit: plaintext string (new value) or an
// {isSet} marker meaning "keep the stored value".
const SecretInputSchema = z
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
.optional()
export const AdminProviderSchema = z.object({
id: z.string().min(1),
provider: ProviderNameSchema,
name: z.string().optional(),
apiKey: SecretInputSchema,
baseUrl: z.string().optional(),
awsAccessKeyId: SecretInputSchema,
awsSecretAccessKey: SecretInputSchema,
awsRegion: z.string().optional(),
vertexApiKey: SecretInputSchema,
models: z.array(z.string().min(1)),
isDefault: z.boolean().optional(),
})
export const AdminProvidersSchema = z.array(AdminProviderSchema)
// Stored shape: secrets are plain strings (never {isSet} markers, which
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
// hand-edited/corrupted value can't slip a marker object past maskSecret.
const StoredAdminProviderSchema = AdminProviderSchema.extend({
apiKey: z.string().optional(),
awsAccessKeyId: z.string().optional(),
awsSecretAccessKey: z.string().optional(),
vertexApiKey: z.string().optional(),
})
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
// Stored form: secrets are plain strings
export interface StoredAdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string
baseUrl?: string
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
vertexApiKey?: string
models: string[]
isDefault?: boolean
}
const SECRET_FIELDS = [
"apiKey",
"awsAccessKeyId",
"awsSecretAccessKey",
"vertexApiKey",
] as const
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
function credEnvNames(
provider: ProviderName,
index: number,
): { key?: string; url?: string } {
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
return {}
}
const prefix =
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
const suffix = index === 0 ? "" : `_${index + 1}`
return {
key: `ADMIN_${prefix}_API_KEY${suffix}`,
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
}
}
export function loadAdminProviders(): StoredAdminProvider[] {
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
// Validate each entry's shape — a malformed/hand-edited value must
// not reach runtime code that assumes provider/models exist.
return parsed.flatMap((entry) => {
const result = StoredAdminProviderSchema.safeParse(entry)
return result.success ? [result.data as StoredAdminProvider] : []
})
} catch {
console.error("[admin-providers] Failed to parse stored providers")
return []
}
}
export type MaskedAdminProvider = Omit<
StoredAdminProvider,
(typeof SECRET_FIELDS)[number]
> & {
apiKey?: MaskedSecret
awsAccessKeyId?: MaskedSecret
awsSecretAccessKey?: MaskedSecret
vertexApiKey?: MaskedSecret
}
export function maskAdminProviders(
list: StoredAdminProvider[],
): MaskedAdminProvider[] {
return list.map((p) => {
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
masked[field] = value ? maskSecret(value) : undefined
}
return masked
})
}
// Resolve {isSet} markers in incoming secrets against the stored list
export function mergeSecrets(
incoming: AdminProviderInput[],
stored: StoredAdminProvider[],
): StoredAdminProvider[] {
const storedById = new Map(stored.map((p) => [p.id, p]))
return incoming.map((p) => {
const prev = storedById.get(p.id)
const merged = { ...p } as StoredAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
if (typeof value === "string") {
merged[field] = value || undefined
} else if (value?.isSet) {
merged[field] = prev?.[field]
} else {
merged[field] = undefined
}
}
return merged
})
}
function displayName(p: StoredAdminProvider): string {
return p.name?.trim() || PROVIDER_INFO[p.provider].label
}
export function validateAdminProviders(
list: StoredAdminProvider[],
envConfig: ServerModelsConfig | null = null,
): string | null {
const envProviders = envConfig?.providers ?? []
for (const single of FIXED_CRED_PROVIDERS) {
if (list.filter((p) => p.provider === single).length > 1) {
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
}
// Its credentials are global; a panel instance would silently
// override the credentials env-configured models rely on
if (
list.some((p) => p.provider === single) &&
envProviders.some((p) => p.provider === single)
) {
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
}
}
const names = list.map((p) => displayName(p))
if (new Set(names).size !== names.length) {
return "Provider display names must be unique."
}
const envNames = new Set(envProviders.map((p) => p.name))
const clash = names.find((n) => envNames.has(n))
if (clash) {
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
}
if (list.filter((p) => p.isDefault).length > 1) {
return "Only one provider can be the default."
}
return null
}
// The panel's contribution to the server models config, derived at read
// time and merged with the env baseline by loadRawServerModelsConfig().
export function adminProvidersToConfig(
list: StoredAdminProvider[],
): ServerModelsConfig {
const config: ServerModelsConfig = { providers: [] }
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.models.length === 0) continue
const env = credEnvNames(p.provider, index)
config.providers.push({
name: displayName(p),
provider: p.provider,
models: p.models,
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
...(p.isDefault ? { default: true } : {}),
})
}
return config
}
// Settings updates derived from the provider list: credential env vars,
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
// Keys derived from `previous` but absent now are set to null (removed,
// falling back to the environment).
export function deriveEnvUpdates(
list: StoredAdminProvider[],
previous: StoredAdminProvider[],
): Record<string, string | null> {
const updates: Record<string, string | null> = {}
// Clear everything the previous list owned, then overwrite below
for (const key of derivedEnvKeys(previous)) updates[key] = null
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
if (p.awsSecretAccessKey)
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
} else if (p.provider === "vertexai") {
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
} else if (p.provider === "ollama") {
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
} else {
const env = credEnvNames(p.provider, index)
if (env.key && p.apiKey) updates[env.key] = p.apiKey
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
}
}
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
// The panel's default also becomes the server-wide default model;
// without one, the env-configured default applies.
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
if (defaultEntry) {
updates.AI_PROVIDER = defaultEntry.provider
updates.AI_MODEL = defaultEntry.models[0]
}
return updates
}
// Every settings key the panel may have written for a given list.
// AI_MODELS_CONFIG is included to clean up values written by older
// versions of the panel (it is no longer written).
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const keys = new Set<string>([
"AI_MODELS_CONFIG",
"AI_PROVIDER",
"AI_MODEL",
])
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
keys.add("AWS_ACCESS_KEY_ID")
keys.add("AWS_SECRET_ACCESS_KEY")
keys.add("AWS_REGION")
} else if (p.provider === "vertexai") {
keys.add("GOOGLE_VERTEX_API_KEY")
keys.add("GOOGLE_VERTEX_BASE_URL")
} else if (p.provider === "ollama") {
keys.add("OLLAMA_API_KEY")
keys.add("OLLAMA_BASE_URL")
} else {
const env = credEnvNames(p.provider, index)
if (env.key) keys.add(env.key)
if (env.url) keys.add(env.url)
}
}
return [...keys]
}
-229
View File
@@ -1,229 +0,0 @@
// Declarative registry of the general env vars editable in the admin panel.
// Drives both server-side validation (app/api/admin/settings) and UI
// rendering (app/[lang]/admin). Keys are exactly the env var names.
//
// AI providers and models are managed separately in the panel's Models
// section (lib/admin/providers.ts), not here.
//
// Not listed here (and therefore rejected by the API):
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
export interface SettingDef {
key: string
group: string
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
// Built-in default applied at runtime when the value is unset, so the UI
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
default?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
}
export interface SettingGroup {
id: string
title: string
description: string
// Optional sections gated by an on/off switch in the panel; fields are
// grayed out until enabled. Starts on when any field is already set.
toggleable?: boolean
}
export const SETTING_GROUPS: SettingGroup[] = [
{
id: "generation",
title: "Generation",
description: "Output parameters applied to all chat requests.",
},
{
id: "access",
title: "Access Control",
description: "Restrict who can use this deployment.",
},
{
id: "features",
title: "Features",
description: "Optional features and security toggles.",
},
{
id: "observability",
title: "Observability",
description: "Langfuse tracing for LLM calls.",
toggleable: true,
},
{
id: "quota",
title: "Quota & Rate Limits",
description:
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
toggleable: true,
},
]
export const SETTINGS_REGISTRY: SettingDef[] = [
// ── Generation ───────────────────────────────────────────────────
{
key: "TEMPERATURE",
group: "generation",
type: "number",
label: "Temperature",
description:
"Leave unset for reasoning models that reject temperature.",
min: 0,
max: 2,
},
{
key: "MAX_OUTPUT_TOKENS",
group: "generation",
type: "number",
label: "Max Output Tokens",
min: 1,
},
// ── Access Control ───────────────────────────────────────────────
{
key: "ACCESS_CODE_LIST",
group: "access",
type: "string",
label: "Access Codes",
description:
"Comma-separated list. Users must enter one to chat. Empty = open access.",
placeholder: "code1,code2",
},
// ── Features ─────────────────────────────────────────────────────
{
key: "ENABLE_VLM_VALIDATION",
group: "features",
type: "boolean",
label: "VLM Diagram Validation",
description:
"Visually validate generated diagrams with a vision model.",
},
{
key: "VALIDATION_MODEL",
group: "features",
type: "string",
label: "Validation Model",
description: "Falls back to the default AI model when empty.",
},
{
key: "VALIDATION_TIMEOUT",
group: "features",
type: "number",
label: "Validation Timeout (ms)",
min: 1000,
},
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{
key: "ALLOW_PRIVATE_URLS",
group: "features",
type: "boolean",
label: "Allow Private URLs",
description:
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
// Unset means allowed at runtime (ssrf-protection: !== "false")
default: "true",
},
// ── Observability ────────────────────────────────────────────────
{
key: "LANGFUSE_PUBLIC_KEY",
group: "observability",
type: "string",
label: "Langfuse Public Key",
placeholder: "pk-lf-…",
restartRequired: true,
},
{
key: "LANGFUSE_SECRET_KEY",
group: "observability",
type: "secret",
label: "Langfuse Secret Key",
restartRequired: true,
},
{
key: "LANGFUSE_BASEURL",
group: "observability",
type: "string",
label: "Langfuse Base URL",
placeholder: "https://cloud.langfuse.com",
restartRequired: true,
},
// ── Quota ────────────────────────────────────────────────────────
{
key: "DAILY_REQUEST_LIMIT",
group: "quota",
type: "number",
label: "Daily Request Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "DAILY_TOKEN_LIMIT",
group: "quota",
type: "number",
label: "Daily Token Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "TPM_LIMIT",
group: "quota",
type: "number",
label: "Tokens Per Minute",
min: 1,
},
{
key: "DYNAMODB_QUOTA_TABLE",
group: "quota",
type: "string",
label: "DynamoDB Table",
description: "Quota enforcement is disabled when empty.",
restartRequired: true,
},
{
key: "DYNAMODB_REGION",
group: "quota",
type: "string",
label: "DynamoDB Region",
placeholder: "ap-northeast-1",
restartRequired: true,
},
{
key: "QUOTA_TIMEZONE",
group: "quota",
type: "string",
label: "Quota Timezone",
description: "Timezone for the daily reset boundary.",
placeholder: "UTC",
restartRequired: true,
},
]
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
SETTINGS_REGISTRY.map((def) => [def.key, def]),
)
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
SETTING_GROUPS.map((g) => [
g.id,
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
]),
)
-134
View File
@@ -1,134 +0,0 @@
import fs from "fs"
import path from "path"
// File-based admin settings, overlaid onto process.env (dotenv-style).
// Precedence: settings file > env var > built-in default.
// Keys are exactly the env var names.
interface SettingsFile {
version: 1
values: Record<string, string>
}
// Original env values snapshotted before the first overlay, so removing a
// key from the settings file restores the env default. null = was unset.
const originalEnv: Record<string, string | null> = {}
// Keys currently overlaid, so we can restore ones removed from the file.
let overlaidKeys = new Set<string>()
let cachedSettings: Record<string, string> | null = null
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json")
}
export function loadSettings(): Record<string, string> {
if (cachedSettings) return cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
// Keep only string values — a hand-edited or corrupted file could
// hold null/arrays/numbers that would otherwise be overlaid onto
// process.env and coerce to junk like "[object Object]".
const values: Record<string, string> = {}
const rawValues =
parsed &&
typeof parsed.values === "object" &&
parsed.values &&
!Array.isArray(parsed.values)
? parsed.values
: {}
for (const [key, value] of Object.entries(rawValues)) {
if (typeof value === "string") values[key] = value
}
cachedSettings = values
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
cachedSettings = {}
}
return cachedSettings
}
export function applyToEnv(): void {
const values = loadSettings()
// Restore env for keys that were overlaid before but are now gone
for (const key of overlaidKeys) {
if (!(key in values)) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else process.env[key] = original
}
}
for (const [key, value] of Object.entries(values)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key] ?? null
}
process.env[key] = value
}
overlaidKeys = new Set(Object.keys(values))
}
// The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null {
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
return process.env[key] ?? null
}
// Whether a key's current value comes from the file, the environment, or is unset
export function getValueSource(key: string): "file" | "env" | "default" {
if (key in loadSettings()) return "file"
return getEnvFallback(key) !== null ? "env" : "default"
}
export function saveSettings(updates: Record<string, string | null>): void {
const current = { ...loadSettings() }
for (const [key, value] of Object.entries(updates)) {
if (value === null) delete current[key]
else current[key] = value
}
const filePath = getSettingsPath()
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const tmpPath = `${filePath}.tmp`
const data: SettingsFile = { version: 1, values: current }
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath)
cachedSettings = current
applyToEnv()
}
let writableCache: boolean | null = null
export function isSettingsWritable(): boolean {
if (writableCache !== null) return writableCache
try {
const dir = path.dirname(getSettingsPath())
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
writableCache = true
} catch {
writableCache = false
}
return writableCache
}
// Test-only: reset module state
export function _resetForTests(): void {
cachedSettings = null
writableCache = null
for (const key of overlaidKeys) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original
}
overlaidKeys = new Set()
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
}
+26
View File
@@ -0,0 +1,26 @@
import { STORAGE_KEYS } from "./storage"
/**
* Get AI configuration from localStorage.
* Returns API keys and settings for custom AI providers.
* Used to override server defaults when user provides their own API key.
*/
export function getAIConfig() {
if (typeof window === "undefined") {
return {
accessCode: "",
aiProvider: "",
aiBaseUrl: "",
aiApiKey: "",
aiModel: "",
}
}
return {
accessCode: localStorage.getItem(STORAGE_KEYS.accessCode) || "",
aiProvider: localStorage.getItem(STORAGE_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(STORAGE_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(STORAGE_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(STORAGE_KEYS.aiModel) || "",
}
}
+70 -584
View File
@@ -4,57 +4,31 @@ import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export type { ProviderName }
export type ProviderName =
| "bedrock"
| "openai"
| "anthropic"
| "google"
| "azure"
| "ollama"
| "openrouter"
| "deepseek"
| "siliconflow"
| "sglang"
| "gateway"
| "edgeone"
| "doubao"
interface ModelConfig {
model: any
providerOptions?: any
headers?: Record<string, string>
modelId: string
provider: ProviderName
}
// Providers that only support a single system message
export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
"minimax",
"glm",
"qwen",
"kimi",
"qiniu",
"novita",
])
/**
* Normalize MiniMax base URL for AI SDK compatibility.
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
*/
export function normalizeMiniMaxBaseURL(rawUrl: string): {
baseURL: string
isAnthropicCompatible: boolean
} {
const isAnthropicCompatible = rawUrl.includes("/anthropic")
let baseURL = rawUrl.replace(/\/$/, "")
if (isAnthropicCompatible) {
if (!baseURL.endsWith("/anthropic/v1")) {
if (baseURL.endsWith("/anthropic")) {
baseURL = `${baseURL}/v1`
} else {
baseURL = `${baseURL}/anthropic/v1`
}
}
} else {
if (!baseURL.endsWith("/v1")) {
baseURL = `${baseURL}/v1`
}
}
return { baseURL, isAnthropicCompatible }
}
export interface ClientOverrides {
@@ -67,22 +41,15 @@ export interface ClientOverrides {
awsSecretAccessKey?: string | null
awsRegion?: 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 name(s) for server models
// Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
baseUrlEnv?: string
}
// Providers that can be selected from client settings
// Providers that can be used with client-provided API keys
const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openai",
"anthropic",
"google",
"vertexai",
"azure",
"bedrock",
"openrouter",
@@ -91,15 +58,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"sglang",
"gateway",
"edgeone",
"ollama",
"doubao",
"modelscope",
"glm",
"qwen",
"qiniu",
"kimi",
"minimax",
"novita",
]
// Bedrock provider options for Anthropic beta features
@@ -114,87 +73,6 @@ const ANTHROPIC_BETA_HEADERS = {
"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.
* When multiple keys are configured, randomly selects one for load balancing.
*
* Priority:
* 1. User-provided API key (overrides.apiKey)
* 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv)
* - If array, randomly picks one with a valid value
* 3. Default provider env var (defaultEnvVar)
*/
function resolveApiKey(
overrides: ClientOverrides | undefined,
defaultEnvVar: string,
): string | undefined {
if (overrides?.apiKey) return overrides.apiKey
if (overrides?.apiKeyEnv) {
// Handle array of env var names - randomly select one
if (Array.isArray(overrides.apiKeyEnv)) {
// Filter to only env vars that have values
const validEnvVars = overrides.apiKeyEnv.filter(
(envVar) => process.env[envVar],
)
if (validEnvVars.length > 0) {
// Randomly select one
const selectedEnvVar =
validEnvVars[
Math.floor(Math.random() * validEnvVars.length)
]
console.log(
`[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`,
)
return process.env[selectedEnvVar]
}
} else {
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
*/
@@ -229,8 +107,6 @@ function parseIntSafe(
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
* - 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_SUMMARY: Azure reasoning summary (none/brief/detailed)
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
@@ -395,46 +271,7 @@ function buildProviderOptions(
}
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": {
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
@@ -516,14 +353,7 @@ function buildProviderOptions(
case "siliconflow":
case "sglang":
case "gateway":
case "modelscope":
case "doubao":
case "minimax":
case "glm":
case "qwen":
case "kimi":
case "qiniu":
case "novita": {
case "doubao": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break
@@ -537,12 +367,11 @@ function buildProviderOptions(
}
// Map of provider to required environment variable
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
google: "GOOGLE_GENERATIVE_AI_API_KEY",
vertexai: "GOOGLE_VERTEX_API_KEY",
azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY",
@@ -552,13 +381,6 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
gateway: "AI_GATEWAY_API_KEY",
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
doubao: "DOUBAO_API_KEY",
modelscope: "MODELSCOPE_API_KEY",
glm: "GLM_API_KEY",
qwen: "QWEN_API_KEY",
qiniu: "QINIU_API_KEY",
kimi: "KIMI_API_KEY",
minimax: "MINIMAX_API_KEY",
novita: "NOVITA_API_KEY",
}
/**
@@ -573,15 +395,7 @@ function detectProvider(): ProviderName | null {
// Skip ollama - it doesn't require credentials
continue
}
// Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY
const hasCredential =
provider === "anthropic"
? !!(
process.env.ANTHROPIC_API_KEY ||
process.env.ANTHROPIC_AUTH_TOKEN
)
: !!process.env[envVar]
if (hasCredential) {
if (process.env[envVar]) {
// Azure requires additional config (baseURL or resourceName)
if (provider === "azure") {
const hasBaseUrl = !!process.env.AZURE_BASE_URL
@@ -604,46 +418,15 @@ function detectProvider(): ProviderName | null {
/**
* Validate that required API keys are present for the selected provider
* @param provider - The provider to validate
* @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv)
*/
function validateProviderCredentials(
provider: ProviderName,
customApiKeyEnv?: string | string[],
): void {
// Handle array of env var names - at least one must be set
if (Array.isArray(customApiKeyEnv)) {
const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar])
if (!hasAnyKey) {
throw new Error(
`At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` +
`Please set at least one in your .env.local file.`,
)
}
return
}
// Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY
if (provider === "anthropic" && !customApiKeyEnv) {
const hasCredential = !!(
process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
)
if (!hasCredential) {
throw new Error(
`Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. ` +
`Please set one in your .env.local file.`,
)
}
} else {
// Use custom env var name if provided, otherwise use default
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
function validateProviderCredentials(provider: ProviderName): void {
const requiredVar = PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) {
throw new Error(
`${requiredVar} environment variable is required for ${provider} provider. ` +
`Please set it in your .env.local file.`,
)
}
}
// Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key
if (provider === "azure") {
@@ -662,7 +445,7 @@ function validateProviderCredentials(
* Get the AI model based on environment variables
*
* Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, 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
*
* Provider-specific env vars:
@@ -672,30 +455,24 @@ function validateProviderCredentials(
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to http://localhost:11434)
* - OPENROUTER_API_KEY: OpenRouter API key
* - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - 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_BASE_URL: SGLang endpoint (optional)
* - MODELSCOPE_API_KEY: ModelScope API key
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
*/
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
// If a custom baseUrl is provided, an API key MUST also be provided.
// This prevents attackers from redirecting server API keys to malicious endpoints.
// Exception: EdgeOne doesn't require API keys.
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
// Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime)
if (
overrides?.baseUrl &&
!overrides?.apiKey &&
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
overrides?.provider !== "edgeone" &&
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
overrides?.provider !== "edgeone"
) {
throw new Error(
`API key is required when using a custom base URL. ` +
@@ -704,11 +481,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
// Check if client is providing their own provider override
const isClientOverride = !!(
overrides?.provider &&
(overrides?.apiKey ||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
)
const isClientOverride = !!(overrides?.provider && overrides?.apiKey)
// Use client override if provided, otherwise fall back to env vars
const modelId = overrides?.modelId || process.env.AI_MODEL
@@ -764,7 +537,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`- MODELSCOPE_API_KEY for ModelScope\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`,
)
} else {
@@ -778,7 +550,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Only validate server credentials if client isn't providing their own API key
if (!isClientOverride) {
validateProviderCredentials(provider, overrides?.apiKeyEnv)
validateProviderCredentials(provider)
}
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
@@ -828,16 +600,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "openai": {
const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"OPENAI_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
if (baseURL) {
// Custom base URL = third-party proxy, use Chat Completions API
// for compatibility (most proxies don't support /responses endpoint)
@@ -855,27 +619,13 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "anthropic": {
const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"ANTHROPIC_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
"https://api.anthropic.com/v1",
)
// Anthropic supports two auth methods (mutually exclusive):
// - apiKey: sends as `x-api-key` header
// - authToken: sends as `Authorization: Bearer <token>` header
// Prefer apiKey if present (including client overrides); fall back
// to ANTHROPIC_AUTH_TOKEN env var only when no apiKey is available.
const authToken = !apiKey
? process.env.ANTHROPIC_AUTH_TOKEN
: undefined
const apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY
const baseURL =
overrides?.baseUrl ||
process.env.ANTHROPIC_BASE_URL ||
"https://api.anthropic.com/v1"
const customProvider = createAnthropic({
...(authToken ? { authToken } : { apiKey }),
apiKey,
baseURL,
headers: ANTHROPIC_BETA_HEADERS,
})
@@ -886,19 +636,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "google": {
const apiKey = resolveApiKey(
overrides,
"GOOGLE_GENERATIVE_AI_API_KEY",
)
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"GOOGLE_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const apiKey =
overrides?.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY
const baseURL = overrides?.baseUrl || process.env.GOOGLE_BASE_URL
if (baseURL || overrides?.apiKey) {
const customGoogle = createGoogleGenerativeAI({
apiKey,
@@ -910,42 +650,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
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": {
const apiKey = resolveApiKey(overrides, "AZURE_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL")
const baseURL = resolveBaseURL(
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
const apiKey = overrides?.apiKey || process.env.AZURE_API_KEY
const baseURL = overrides?.baseUrl || process.env.AZURE_BASE_URL
const resourceName = process.env.AZURE_RESOURCE_NAME
// Azure requires either baseURL or resourceName to construct the endpoint
// resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path}
if (baseURL || resourceName || overrides?.apiKey) {
@@ -962,39 +671,21 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "ollama": {
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
// SECURITY: When client provides a custom base URL, only use
// client-provided API key. Never fall back to server OLLAMA_API_KEY
// to prevent leaking server credentials to user-controlled endpoints.
const apiKey = overrides?.baseUrl
? overrides?.apiKey || undefined
: resolveApiKey(overrides, "OLLAMA_API_KEY")
if (baseURL || apiKey) {
case "ollama":
if (process.env.OLLAMA_BASE_URL) {
const customOllama = createOllama({
...(baseURL && { baseURL }),
...(apiKey && {
headers: { Authorization: `Bearer ${apiKey}` },
}),
baseURL: process.env.OLLAMA_BASE_URL,
})
model = customOllama(modelId)
} else {
model = ollama(modelId)
}
break
}
case "openrouter": {
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"OPENROUTER_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const apiKey = overrides?.apiKey || process.env.OPENROUTER_API_KEY
const baseURL =
overrides?.baseUrl || process.env.OPENROUTER_BASE_URL
const openrouter = createOpenRouter({
apiKey,
...(baseURL && { baseURL }),
@@ -1004,16 +695,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "deepseek": {
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"DEEPSEEK_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const apiKey = overrides?.apiKey || process.env.DEEPSEEK_API_KEY
const baseURL = overrides?.baseUrl || process.env.DEEPSEEK_BASE_URL
if (baseURL || overrides?.apiKey) {
const customDeepSeek = createDeepSeek({
apiKey,
@@ -1027,17 +710,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "siliconflow": {
const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"SILICONFLOW_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
"https://api.siliconflow.cn/v1",
)
const apiKey = overrides?.apiKey || process.env.SILICONFLOW_API_KEY
const baseURL =
overrides?.baseUrl ||
process.env.SILICONFLOW_BASE_URL ||
"https://api.siliconflow.com/v1"
const siliconflowProvider = createOpenAI({
apiKey,
baseURL,
@@ -1047,20 +724,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "sglang": {
const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"SGLANG_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
const sglangProvider = createOpenAI({
apiKey,
...(baseURL && { baseURL }),
baseURL,
// Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => {
const response = await fetch(url, options)
@@ -1164,16 +833,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway
const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"AI_GATEWAY_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
)
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const baseURL =
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
// Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
if (baseURL || overrides?.apiKey) {
@@ -1204,138 +866,22 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
}
case "doubao": {
const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"DOUBAO_BASE_URL",
)
const baseURL = resolveBaseURL(
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 apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
const baseURL =
overrides?.baseUrl ||
process.env.DOUBAO_BASE_URL ||
"https://ark.cn-beijing.volces.com/api/v3"
const doubaoProvider = createDeepSeek({
apiKey,
baseURL,
})
model = doubaoProvider(modelId)
} 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,
baseURL,
})
model = modelscopeProvider.chat(modelId)
break
}
case "minimax": {
const apiKey = resolveApiKey(overrides, "MINIMAX_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"MINIMAX_BASE_URL",
)
const rawBaseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
PROVIDER_INFO.minimax.defaultBaseUrl,
)
if (!rawBaseURL) {
throw new Error(
"MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.",
)
}
const { baseURL, isAnthropicCompatible } =
normalizeMiniMaxBaseURL(rawBaseURL)
if (isAnthropicCompatible) {
const minimax = createAnthropic({ apiKey, baseURL })
model = minimax.chat(modelId)
} else {
const minimax = createOpenAI({ apiKey, baseURL })
model = minimax.chat(modelId)
}
break
}
case "glm":
case "qwen":
case "qiniu":
case "novita": {
const envVar = PROVIDER_ENV_VARS[provider]
if (!envVar) {
throw new Error(
`API key environment variable not defined for provider: ${provider}`,
)
}
const apiKey = resolveApiKey(overrides, envVar)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(
overrides,
`${provider.toUpperCase()}_BASE_URL`,
),
PROVIDER_INFO[provider]?.defaultBaseUrl,
)
const customProvider = createOpenAI({
apiKey,
baseURL,
})
model = customProvider.chat(modelId)
break
}
case "kimi": {
const apiKey = resolveApiKey(overrides, "KIMI_API_KEY")
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
PROVIDER_INFO["kimi"]?.defaultBaseUrl,
)
// Use createDeepSeek to properly handle reasoning_content for Kimi
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
// reasoning_content field as DeepSeek, so this provider correctly
// captures and replays reasoning in multi-turn conversations.
const customProvider = createDeepSeek({ apiKey, baseURL })
model = customProvider(modelId)
break
}
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao`,
)
}
@@ -1344,7 +890,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
providerOptions = customProviderOptions
}
return { model, providerOptions, headers, modelId, provider }
return { model, providerOptions, headers, modelId }
}
/**
@@ -1373,29 +919,8 @@ export function supportsImageInput(modelId: string): boolean {
lowerModelId.includes("vision") || lowerModelId.includes("vl")
// Models that DON'T support image/vision input (unless vision variant)
// Kimi K2 doesn't support images, but K2.5 does
// Only block kimi-k2 specifically, not other Kimi models
if (
(lowerModelId.includes("kimi-k2") ||
lowerModelId.includes("kimi_k2")) &&
!hasVisionIndicator &&
!lowerModelId.includes("2.5") &&
!lowerModelId.includes("k2.5")
) {
return false
}
// Moonshot text models (moonshot-v1 series are text-only)
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
return false
}
// MiniMax text models (MiniMax-M2.x series are text-only; M3 supports image input)
if (
lowerModelId.includes("minimax") &&
!hasVisionIndicator &&
!lowerModelId.includes("m3")
) {
// Kimi K2 models don't support images
if (lowerModelId.includes("kimi") && !hasVisionIndicator) {
return false
}
@@ -1405,49 +930,10 @@ export function supportsImageInput(modelId: string): boolean {
}
// Qwen text models (not vision variants like qwen-vl)
// Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
// QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/"
if (
lowerModelId.includes("qwen") &&
!hasVisionIndicator &&
!lowerModelId.includes("qwen3.5") &&
!lowerModelId.includes("qvq")
) {
if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
return false
}
// GLM text models (not vision variants)
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
if (!/[\d.]v/.test(lowerModelId)) {
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
}
-64
View File
@@ -1,64 +0,0 @@
/**
* Types and utilities for VLM-based diagram validation.
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
*/
// Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema"
/**
* Format validation feedback for display to the AI model.
* This creates a human-readable error message that guides the AI to fix issues.
*
* @param result - The validation result from VLM
* @returns Formatted string for tool error output
*/
export function formatValidationFeedback(result: ValidationResult): string {
// If validation passed with no issues, return empty string
if (result.valid && result.issues.length === 0) {
return ""
}
const lines: string[] = []
lines.push("DIAGRAM VISUAL VALIDATION FAILED")
lines.push("")
// Group issues by severity
const criticalIssues = result.issues.filter(
(i) => i.severity === "critical",
)
const warnings = result.issues.filter((i) => i.severity === "warning")
if (criticalIssues.length > 0) {
lines.push("Critical Issues (must fix):")
for (const issue of criticalIssues) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (warnings.length > 0) {
lines.push("Warnings:")
for (const issue of warnings) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (result.suggestions.length > 0) {
lines.push("Suggestions to fix:")
for (const suggestion of result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push(
"Please regenerate the diagram with corrected layout to fix these visual issues.",
)
return lines.join("\n")
}
-17
View File
@@ -1,17 +0,0 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
export const i18n = {
defaultLocale: "en",
locales: ["en", "zh", "ja", "zh-Hant"],
locales: ["en", "zh", "ja"],
} as const
export type Locale = (typeof i18n)["locales"][number]
-2
View File
@@ -6,8 +6,6 @@ const dictionaries = {
en: () => import("./dictionaries/en.json").then((m) => m.default),
zh: () => import("./dictionaries/zh.json").then((m) => m.default),
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
"zh-Hant": () =>
import("./dictionaries/zh-Hant.json").then((m) => m.default),
}
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>
+14 -272
View File
@@ -28,18 +28,12 @@
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
"send": "Send",
"stopGeneration": "Stop generation",
"sending": "Sending...",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"diagramHistory": "Diagram history",
@@ -57,8 +51,7 @@
"badResponse": "Bad response",
"clickToEdit": "Click to edit",
"editMessage": "Edit message",
"saveAndSubmit": "Save & Submit",
"ExtractURL": "Extract from URL"
"saveAndSubmit": "Save & Submit"
},
"examples": {
"title": "Create diagrams with AI",
@@ -76,13 +69,12 @@
"creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples are cached for instant response",
"mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
"preview": "PREVIEW"
},
"settings": {
"title": "Settings",
"description": "Configure your application settings.",
"apiKeysModels": "API Keys & Models",
"apiKeysModelsDescription": "Configure AI providers and API keys.",
"accessCode": "Access Code",
"accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.",
@@ -102,40 +94,18 @@
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style",
"themeDefault": "Default",
"themeDark": "Dark",
"themeMinimal": "Minimal",
"themeSketch": "Sketch",
"themeSimple": "Simple",
"drawioStyleDescription": "Canvas style:",
"switchTo": "Switch to",
"minimal": "Minimal",
"sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page.",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut",
"sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"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",
"customSystemMessage": "Custom System Message",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
"panelVisibility": "Lobby Panels",
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
"showRecentChats": "Recent Chats",
"showMyTemplates": "My Templates",
"showQuickExamples": "Quick Examples"
"download": "Download"
},
"save": {
"title": "Save Diagram",
@@ -146,8 +116,7 @@
"formats": {
"drawio": "Draw.io XML",
"png": "PNG Image",
"svg": "SVG Image",
"xmlsvg": "Editable SVG"
"svg": "SVG Image"
},
"savedSuccessfully": "Saved successfully!"
},
@@ -194,11 +163,8 @@
"tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
"messageApiSelfHosted": null,
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
"messageTokenSelfHosted": null,
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
"reset": "Your limit resets tomorrow. Thanks for understanding.",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
"configModel": "Use Your API Key",
@@ -220,15 +186,6 @@
"chars": "chars",
"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": {
"thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds",
@@ -272,70 +229,6 @@
"searchPlaceholder": "Search chats...",
"noResults": "No chats found"
},
"templates": {
"title": "My Templates",
"subtitle": "Your personal prompt library for quick diagram creation",
"emptyTitle": "No templates yet",
"emptyDescription": "Create your first template to start building your personal prompt library",
"createFirst": "Create First Template",
"neverUsed": "Not used yet",
"usedCount": "{count} uses",
"myTemplates": "My Templates",
"createTitle": "Create Template",
"createDescription": "Save a prompt for repeated use. Templates help you quickly start common workflows.",
"promptLabel": "Prompt",
"promptPlaceholder": "Describe the diagram you want to create...",
"promptRequired": "Prompt is required",
"titleLabel": "Title",
"titlePlaceholder": "Enter a title",
"titleHint": "Leave empty to use the first 20 characters of prompt",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Add a description for this template",
"pinnedLabel": "Pin Template",
"pinnedHint": "Pinned templates appear at the top of the list",
"createButton": "Create Template",
"createFailed": "Failed to create template. Please try again.",
"editTitle": "Edit Template",
"editDescription": "Update your template content and settings.",
"updateFailed": "Failed to update template. Please try again.",
"duplicate": "Duplicate",
"copySuffix": "(copy)",
"deleteTitle": "Delete this template?",
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
"confirmSendTitle": "Replace current input?",
"confirmSendDescription": "You have unsent content in the input. Sending this template will replace it.",
"confirmSendButton": "Send Template",
"searchPlaceholder": "Search templates...",
"searchNoResults": "No templates match your search",
"pin": "Pin to top",
"unpin": "Unpin from top",
"saveAsTemplate": "Save as Template",
"exportTemplates": "Export Templates",
"importTemplates": "Import Templates",
"exportEmpty": "No templates to export",
"exportSuccess": "Exported {count} template(s) successfully",
"importNoFile": "Please select a JSON file",
"importFailed": "Import failed: {error}",
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
},
"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": {
"title": "AI Model Configuration",
"description": "Configure multiple AI providers and models",
@@ -368,9 +261,7 @@
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL",
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
"models": "Models",
"customModelId": "Custom model ID...",
"allAdded": "All added",
@@ -395,159 +286,10 @@
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server Default",
"serverModels": "Server Models",
"userModels": "User Models",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model",
"showValue": "Show value",
"hideValue": "Hide value"
},
"admin": {
"title": "Admin Settings",
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.",
"password": "Password",
"signIn": "Sign In",
"signingIn": "Signing In…",
"loginFailed": "Login failed",
"precedence": "File overrides env · env overrides defaults",
"notWritable": "The settings file is not writable on this deployment (serverless platforms have no persistent disk). Settings are shown read-only — configure via environment variables instead.",
"settingGroups": "Setting groups",
"enabled": "Enabled",
"disabled": "Disabled",
"enableGroup": "Enable {group}",
"unsavedChanges": "Unsaved changes",
"saved": "Settings saved. Changes apply immediately.",
"saveFailed": "Save failed. Check your connection and try again.",
"invalidSettings": "Some settings are invalid.",
"discard": "Discard",
"saveChanges": "Save Changes",
"saving": "Saving…",
"sourceSaved": "Saved",
"sourceEnv": "Env",
"sourceSavedTitle": "Set in the admin settings file",
"sourceEnvTitle": "Set by an environment variable",
"restartRequired": "Restart Required",
"modified": "Modified",
"notSet": "Not set",
"savedReplace": "Saved ({hint}) — type to replace",
"showValue": "Show value",
"hideValue": "Hide value",
"removeValue": "Remove value",
"removeValueTitle": "Remove the stored value",
"resetToDefault": "Reset to default",
"models": "Models",
"modelsDescription": "Server-side providers and models available to all users — no personal API key needed. The default provider's first model is used when users don't pick one.",
"addProviderHint": "Add a provider to offer server-side models to all users.",
"selectProviderHint": "Select or add a provider to configure its credentials and models.",
"addProviderToOfferModels": "Add at least one model to expose this provider to users.",
"managedViaEnv": "(managed via env)",
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.",
"defaultModel": "Default Model",
"noModelsConfigured": "No models configured",
"modelCount": "{count} model",
"modelCountPlural": "{count} models",
"default": "Default",
"setAsDefault": "Set as default provider",
"defaultProvider": "Default provider",
"modelIdPlaceholder": "Model ID…",
"addModel": "Add model",
"suggested": "Suggested",
"test": "Test",
"testOk": "OK ({ms}ms)",
"testFailed": "Failed",
"removeModel": "Remove {model}",
"deleteProviderTitle": "Delete {name}?",
"deleteProviderDesc": "Its credentials and models will be removed from the server after you save.",
"cancel": "Cancel",
"delete": "Delete",
"groups": {
"generation": {
"title": "Generation",
"description": "Output parameters applied to all chat requests."
},
"access": {
"title": "Access Control",
"description": "Restrict who can use this deployment."
},
"features": {
"title": "Features",
"description": "Optional features and security toggles."
},
"observability": {
"title": "Observability",
"description": "Langfuse tracing for LLM calls."
},
"quota": {
"title": "Quota & Rate Limits",
"description": "Per-IP usage limits. Enforcement requires a DynamoDB table."
}
},
"settings": {
"TEMPERATURE": {
"label": "Temperature",
"description": "Leave unset for reasoning models that reject temperature."
},
"MAX_OUTPUT_TOKENS": {
"label": "Max Output Tokens"
},
"ACCESS_CODE_LIST": {
"label": "Access Codes",
"description": "Comma-separated list. Users must enter one to chat. Empty = open access."
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM Diagram Validation",
"description": "Visually validate generated diagrams with a vision model."
},
"VALIDATION_MODEL": {
"label": "Validation Model",
"description": "Falls back to the default AI model when empty."
},
"VALIDATION_TIMEOUT": {
"label": "Validation Timeout (ms)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "History XML Compression",
"description": "Replace old diagram XML in history with placeholders."
},
"ALLOW_PRIVATE_URLS": {
"label": "Allow Private URLs",
"description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)."
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "Daily Request Limit",
"description": "Per IP per day."
},
"DAILY_TOKEN_LIMIT": {
"label": "Daily Token Limit",
"description": "Per IP per day."
},
"TPM_LIMIT": {
"label": "Tokens Per Minute"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB Table",
"description": "Quota enforcement is disabled when empty."
},
"DYNAMODB_REGION": {
"label": "DynamoDB Region"
},
"QUOTA_TIMEZONE": {
"label": "Quota Timezone",
"description": "Timezone for the daily reset boundary."
}
}
"unvalidatedModelWarning": "This model has not been validated"
}
}
+14 -272
View File
@@ -28,18 +28,12 @@
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"send": "送信",
"stopGeneration": "生成を停止",
"sending": "送信中...",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"diagramHistory": "ダイアグラム履歴",
@@ -57,8 +51,7 @@
"badResponse": "悪い応答",
"clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信",
"ExtractURL": "URLから抽出"
"saveAndSubmit": "保存して送信"
},
"examples": {
"title": "AI でダイアグラムを作成",
@@ -76,13 +69,12 @@
"creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はキャッシュされ、即座に応答します",
"mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
"preview": "プレビュー"
},
"settings": {
"title": "設定",
"description": "アプリケーション設定を構成します。",
"apiKeysModels": "API キーとモデル",
"apiKeysModelsDescription": "AI プロバイダーと API キーを設定します。",
"accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
@@ -102,40 +94,18 @@
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル",
"themeDefault": "デフォルト",
"themeDark": "ダーク",
"themeMinimal": "ミニマル",
"themeSketch": "スケッチ",
"themeSimple": "シンプル",
"drawioStyleDescription": "キャンバススタイル",
"switchTo": "切り替え",
"minimal": "ミニマル",
"sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット",
"sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード",
"proxy": "プロキシ設定",
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
"httpProxy": "HTTP プロキシ",
"httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました",
"diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"enabled": "有効",
"disabled": "無効",
"customSystemMessage": "カスタムシステムメッセージ",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"panelVisibility": "ロビーパネル",
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
"showRecentChats": "最近のチャット",
"showMyTemplates": "マイテンプレート",
"showQuickExamples": "クイック例"
"download": "ダウンロード"
},
"save": {
"title": "ダイアグラムを保存",
@@ -146,8 +116,7 @@
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 画像",
"svg": "SVG 画像",
"xmlsvg": "編集可能 SVG"
"svg": "SVG 画像"
},
"savedSuccessfully": "保存完了!"
},
@@ -194,11 +163,8 @@
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
"messageApiSelfHosted": null,
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
"messageTokenSelfHosted": null,
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
"configModel": "APIキーを使用",
@@ -220,15 +186,6 @@
"chars": "文字",
"removeFile": "ファイルを削除"
},
"url": {
"title": "URLからコンテンツを抽出",
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
"Extracting": "抽出中...",
"extract": "抽出",
"Cancel": "キャンセル",
"enterUrl": "URLを入力してください",
"invalidFormat": "無効なURL形式です"
},
"reasoning": {
"thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました",
@@ -272,24 +229,6 @@
"searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません"
},
"validation": {
"title": "ダイアグラムを検証",
"capturing": "キャプチャ中",
"validating": "検証中",
"validatingWithAttempt": "検証中 ({attempt}/{max})",
"valid": "有効",
"validWithWarnings": "有効(警告あり)",
"issuesFound": "問題が見つかりました",
"error": "エラー",
"skipped": "スキップ",
"capturedScreenshot": "キャプチャした画像:",
"issuesFoundLabel": "検出された問題:",
"suggestions": "提案:",
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
"improveWithSuggestions": "提案で改善",
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": {
"title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定",
@@ -322,9 +261,7 @@
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"baseUrlWithExample": "ベース URL(オプション、例: {example}",
"customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み",
@@ -349,205 +286,10 @@
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"serverModels": "サーバーモデル",
"userModels": "ユーザーモデル",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示",
"hideValue": "値を非表示"
},
"templates": {
"title": "マイテンプレート",
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
"emptyTitle": "テンプレートがありません",
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
"createFirst": "最初のテンプレートを作成",
"neverUsed": "未使用",
"usedCount": "{count} 回使用",
"myTemplates": "マイテンプレート",
"createTitle": "テンプレート作成",
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
"promptLabel": "プロンプト",
"promptPlaceholder": "作成するダイアグラムを説明してください...",
"promptRequired": "プロンプトは必須です",
"titleLabel": "タイトル",
"titlePlaceholder": "タイトルを入力",
"titleHint": "空欄の場合はプロンプトの最初の20文字が使用されます",
"descriptionLabel": "説明",
"descriptionPlaceholder": "このテンプレートの説明を追加",
"pinnedLabel": "テンプレートをピン留め",
"pinnedHint": "ピン留めされたテンプレートはリストの上部に表示されます",
"createButton": "テンプレート作成",
"createFailed": "テンプレートの作成に失敗しました。もう一度お試しください。",
"editTitle": "テンプレート編集",
"editDescription": "テンプレートの内容と設定を更新します。",
"updateFailed": "テンプレートの更新に失敗しました。もう一度お試しください。",
"duplicate": "複製",
"copySuffix": "(コピー)",
"deleteTitle": "このテンプレートを削除しますか?",
"deleteDescription": "このテンプレートは完全に削除されます。この操作は取り消せません。",
"confirmSendTitle": "現在の入力を置き換えますか?",
"confirmSendDescription": "未送信のコンテンツがあります。このテンプレートを送信すると置き換えられます。",
"confirmSendButton": "テンプレートを送信",
"searchPlaceholder": "テンプレートを検索...",
"searchNoResults": "検索に一致するテンプレートがありません",
"pin": "上部にピン留め",
"unpin": "ピン留め解除",
"saveAsTemplate": "テンプレートとして保存",
"exportTemplates": "テンプレートをエクスポート",
"importTemplates": "テンプレートをインポート",
"exportEmpty": "エクスポートするテンプレートがありません",
"exportSuccess": "{count} 件のテンプレートをエクスポートしました",
"importNoFile": "JSON ファイルを選択してください",
"importFailed": "インポートに失敗しました:{error}",
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
},
"admin": {
"title": "管理者設定",
"loginPrompt": "サーバー設定を管理するには、管理者パスワード(ADMIN_PASSWORD 環境変数)を入力してください。",
"password": "パスワード",
"signIn": "ログイン",
"signingIn": "ログイン中…",
"loginFailed": "ログインに失敗しました",
"precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き",
"notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。",
"settingGroups": "設定グループ",
"enabled": "有効",
"disabled": "無効",
"enableGroup": "{group} を有効化",
"unsavedChanges": "未保存の変更があります",
"saved": "設定を保存しました。変更は即座に反映されます。",
"saveFailed": "保存に失敗しました。接続を確認して再試行してください。",
"invalidSettings": "一部の設定が無効です。",
"discard": "破棄",
"saveChanges": "変更を保存",
"saving": "保存中…",
"sourceSaved": "保存済み",
"sourceEnv": "環境変数",
"sourceSavedTitle": "管理者設定ファイルで設定",
"sourceEnvTitle": "環境変数で設定",
"restartRequired": "再起動が必要",
"modified": "変更済み",
"notSet": "未設定",
"savedReplace": "保存済み({hint})——入力して置き換え",
"showValue": "値を表示",
"hideValue": "値を非表示",
"removeValue": "値を削除",
"removeValueTitle": "保存された値を削除",
"resetToDefault": "デフォルトに戻す",
"models": "モデル",
"modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。",
"addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。",
"selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。",
"addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。",
"managedViaEnv": "(環境変数で管理)",
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。",
"defaultModel": "デフォルトモデル",
"noModelsConfigured": "モデルが構成されていません",
"modelCount": "{count} 個のモデル",
"modelCountPlural": "{count} 個のモデル",
"default": "デフォルト",
"setAsDefault": "デフォルトプロバイダーに設定",
"defaultProvider": "デフォルトプロバイダー",
"modelIdPlaceholder": "モデル ID…",
"addModel": "モデルを追加",
"suggested": "おすすめ",
"test": "テスト",
"testOk": "正常({ms}ms",
"testFailed": "失敗",
"removeModel": "{model} を削除",
"deleteProviderTitle": "{name} を削除しますか?",
"deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。",
"cancel": "キャンセル",
"delete": "削除",
"groups": {
"generation": {
"title": "生成",
"description": "すべてのチャットリクエストに適用される出力パラメーター。"
},
"access": {
"title": "アクセス制御",
"description": "このデプロイを使用できるユーザーを制限します。"
},
"features": {
"title": "機能",
"description": "オプション機能とセキュリティの切り替え。"
},
"observability": {
"title": "オブザーバビリティ",
"description": "LLM 呼び出しの Langfuse トレース。"
},
"quota": {
"title": "クォータとレート制限",
"description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。"
}
},
"settings": {
"TEMPERATURE": {
"label": "温度",
"description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大出力トークン数"
},
"ACCESS_CODE_LIST": {
"label": "アクセスコード",
"description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 図検証",
"description": "ビジョンモデルで生成された図を視覚的に検証します。"
},
"VALIDATION_MODEL": {
"label": "検証モデル",
"description": "空の場合はデフォルトの AI モデルにフォールバックします。"
},
"VALIDATION_TIMEOUT": {
"label": "検証タイムアウト(ms"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "履歴 XML 圧縮",
"description": "履歴内の古い図 XML をプレースホルダーで置き換えます。"
},
"ALLOW_PRIVATE_URLS": {
"label": "プライベート URL を許可",
"description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックします(SSRF 保護)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "1 日あたりのリクエスト上限",
"description": "IP ごと 1 日あたり。"
},
"DAILY_TOKEN_LIMIT": {
"label": "1 日あたりのトークン上限",
"description": "IP ごと 1 日あたり。"
},
"TPM_LIMIT": {
"label": "1 分あたりのトークン数"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB テーブル",
"description": "空の場合、クォータの強制は無効になります。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB リージョン"
},
"QUOTA_TIMEZONE": {
"label": "クォータタイムゾーン",
"description": "1 日のリセット境界に使用するタイムゾーン。"
}
}
"unvalidatedModelWarning": "このモデルは検証されていません"
}
}
-553
View File
@@ -1,553 +0,0 @@
{
"common": {
"save": "儲存",
"cancel": "取消",
"close": "關閉",
"confirm": "確認",
"clear": "清除",
"edit": "編輯",
"delete": "刪除",
"loading": "載入中...",
"new": "新建"
},
"nav": {
"about": "關於",
"editor": "編輯器",
"newChat": "開始新對話",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
"showPanel": "顯示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"send": "傳送",
"stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"diagramHistory": "圖表歷史",
"saveDiagram": "儲存圖表",
"uploadFile": "上傳檔案(圖片、PDF、文字)",
"minimalStyle": "簡約",
"styledMode": "精緻",
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
"regenerate": "重新產生回應",
"copyResponse": "複製回應",
"copied": "已複製!",
"failedToCopy": "複製失敗",
"failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。",
"goodResponse": "有幫助",
"badResponse": "無幫助",
"clickToEdit": "點擊編輯",
"editMessage": "編輯訊息",
"saveAndSubmit": "儲存並提交",
"ExtractURL": "從 URL 擷取"
},
"examples": {
"title": "用 AI 建立圖表",
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
"quickExamples": "快速範例",
"paperToDiagram": "文件轉圖表",
"paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案",
"animatedDiagram": "動畫圖表",
"animatedDescription": "繪製帶有動畫連接器的 Transformer 架構",
"awsArchitecture": "AWS 架構",
"awsDescription": "使用 AWS 圖示建立雲端架構圖",
"replicateFlowchart": "複製流程圖",
"replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪圖",
"creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例已快取,可即時回應",
"mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
},
"settings": {
"title": "設定",
"description": "配置您的應用程式設定。",
"apiKeysModels": "API 金鑰和模型",
"apiKeysModelsDescription": "配置 AI 提供商和 API 金鑰。",
"accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。",
"aiProvider": "AI 提供商設定",
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
"provider": "提供商",
"modelId": "模型 ID",
"apiKey": "API 金鑰",
"apiKeyPlaceholder": "您的 API 金鑰",
"baseUrl": "基礎 URL(可選)",
"customEndpoint": "自訂端點 URL",
"overrides": "覆寫",
"clearSettings": "清除設定",
"useServerDefault": "使用伺服器預設值",
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式",
"themeDefault": "預設",
"themeDark": "深色",
"themeMinimal": "簡約",
"themeSketch": "草圖",
"themeSimple": "簡單",
"diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵",
"sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
"diagramActions": "圖表操作",
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
"history": "歷史紀錄",
"download": "下載",
"proxy": "代理設定",
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
"httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理",
"applyProxy": "套用",
"proxyApplied": "代理設定已套用",
"diagramValidation": "圖表驗證(實驗性)",
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已啟用",
"disabled": "已停用",
"customSystemMessage": "自訂系統訊息",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"panelVisibility": "大廳面板",
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的範本",
"showQuickExamples": "快速範例"
},
"save": {
"title": "儲存圖表",
"description": "選擇格式和檔案名稱以儲存您的圖表。",
"format": "格式",
"filename": "檔案名稱",
"filenamePlaceholder": "輸入檔案名稱",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 圖片",
"svg": "SVG 圖片",
"xmlsvg": "可編輯 SVG"
},
"savedSuccessfully": "儲存成功!"
},
"history": {
"title": "圖表歷史",
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
"version": "版本",
"restoreTo": "還原到版本 {version}"
},
"dialogs": {
"clearTitle": "清除所有內容?",
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
"clearEverything": "清除所有內容",
"clearSuccess": "已開始新對話"
},
"errors": {
"maxFiles": "檔案太多。最多允許 {max} 個。",
"onlyMoreAllowed": "只能再新增 {slots} 個檔案",
"fileExceeds": "「{name}」大小為 {size}(超過 {max}MB",
"unsupportedType": "「{name}」不是支援的檔案類型",
"filesRejected": "{count} 個檔案被拒絕:",
"andMore": "...還有 {count} 個",
"invalidAccessCode": "無效或缺少存取碼。請在設定中配置。",
"networkError": "網路錯誤。請檢查您的連線。",
"retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
"failedToPersist": "卸載前無法持久化狀態",
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
},
"quota": {
"dailyLimit": "已達每日配額",
"tokenLimit": "已達每日令牌限制",
"tpmLimit": "速率限制",
"tpmMessage": "請求過多。請稍等片刻。",
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
"messageApiSelfHosted": null,
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
"messageTokenSelfHosted": null,
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
"reset": "您的限制將在明天重設。感謝您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
"configModel": "使用您的金鑰",
"selfHost": "自行託管",
"sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "產生圖表",
"editDiagram": "編輯圖表",
"appendDiagram": "繼續圖表",
"complete": "完成",
"error": "錯誤",
"truncated": "已截斷"
},
"file": {
"reading": "讀取中...",
"chars": "字元",
"removeFile": "移除檔案"
},
"url": {
"title": "從 URL 擷取內容",
"description": "貼上 URL 以擷取和分析其內容",
"Extracting": "擷取中...",
"extract": "擷取",
"Cancel": "取消",
"enterUrl": "請輸入 URL",
"invalidFormat": "URL 格式無效"
},
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtBrief": "思考了幾秒鐘"
},
"dev": {
"title": "開發:XML 串流模擬器",
"preset": "預設:",
"selectPreset": "選擇預設...",
"clear": "清除",
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
"interval": "間隔:",
"chars": "字元:",
"streaming": "串流傳輸中...",
"simulate": "模擬",
"stop": "停止",
"testQuotaToast": "測試配額提示",
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
"successMessage": "成功顯示圖表。"
},
"about": {
"modelChange": "模型變更與用量限制",
"walletCrying": "(別名:我的錢包頂不住了)",
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
"contactMe": "聯絡我",
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": {
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "暫無聊天紀錄",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除此對話?",
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
"recentChats": "最近對話",
"justNow": "剛剛",
"searchPlaceholder": "搜尋對話...",
"noResults": "未找到對話"
},
"templates": {
"title": "我的範本",
"subtitle": "快速建立圖表的個人提示庫",
"emptyTitle": "尚無範本",
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
"createFirst": "建立第一個範本",
"neverUsed": "未使用過",
"usedCount": "使用 {count} 次",
"myTemplates": "我的範本",
"createTitle": "建立範本",
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
"promptLabel": "提示",
"promptPlaceholder": "描述您想建立的圖表...",
"promptRequired": "提示為必填項",
"titleLabel": "標題",
"titlePlaceholder": "輸入標題",
"titleHint": "留空則使用提示的前 20 個字元",
"descriptionLabel": "描述",
"descriptionPlaceholder": "為此範本新增描述",
"pinnedLabel": "釘選範本",
"pinnedHint": "釘選的範本會顯示在清單頂部",
"createButton": "建立範本",
"createFailed": "建立範本失敗,請重試。",
"editTitle": "編輯範本",
"editDescription": "更新範本內容和設定。",
"updateFailed": "更新範本失敗,請重試。",
"duplicate": "複製",
"copySuffix": "(副本)",
"deleteTitle": "刪除此範本?",
"deleteDescription": "此操作將永久刪除該範本,無法復原。",
"confirmSendTitle": "取代目前輸入?",
"confirmSendDescription": "您有未傳送的內容。傳送此範本將取代它。",
"confirmSendButton": "傳送範本",
"searchPlaceholder": "搜尋範本...",
"searchNoResults": "沒有符合的範本",
"pin": "置頂",
"unpin": "取消置頂",
"saveAsTemplate": "儲存為範本",
"exportTemplates": "匯出範本",
"importTemplates": "匯入範本",
"exportEmpty": "沒有可匯出的範本",
"exportSuccess": "成功匯出 {count} 個範本",
"importNoFile": "請選擇一個 JSON 檔案",
"importFailed": "匯入失敗:{error}",
"importSuccess": "成功匯入 {imported} 個範本,跳過 {skipped} 個重複"
},
"validation": {
"title": "驗證圖表",
"capturing": "截圖中",
"validating": "驗證中",
"validatingWithAttempt": "驗證中 ({attempt}/{max})",
"valid": "通過",
"validWithWarnings": "通過(有警告)",
"issuesFound": "發現問題",
"error": "錯誤",
"skipped": "已跳過",
"capturedScreenshot": "截圖預覽:",
"issuesFoundLabel": "發現的問題:",
"suggestions": "建議:",
"passedValidation": "圖表通過視覺驗證 - 未發現問題。",
"improvementRequested": "改進請求已傳送 - 請查看下方新圖表",
"improveWithSuggestions": "根據建議改進",
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多個 AI 提供商和模型",
"configure": "配置",
"addProvider": "新增提供商",
"addModel": "新增模型",
"modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除提供商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"selectProvider": "選擇一個提供商或新增",
"configureMultiple": "配置多個 AI 提供商並輕鬆切換",
"apiKeyStored": "API 金鑰儲存在您的瀏覽器本機",
"test": "測試",
"validationError": "驗證失敗",
"addModelFirst": "請先新增至少一個模型以進行驗證",
"providers": "提供商",
"addProviderHint": "新增提供商即可開始使用",
"verified": "已驗證",
"configuration": "配置",
"displayName": "顯示名稱",
"awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
"selectRegion": "選擇區域",
"apiKey": "API 金鑰",
"enterApiKey": "輸入您的 API 金鑰",
"enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL",
"optional": "(可選)",
"baseUrlWithExample": "基礎 URL(可選,例如 {example}",
"customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
"models": "模型",
"customModelId": "自訂模型 ID...",
"allAdded": "已全部新增",
"suggested": "推薦",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入提供商名稱...",
"modelsConfiguredCount": "已配置 {count} 個模型",
"validationFailedCount": "{count} 個模型驗證失敗",
"cancel": "取消",
"delete": "刪除",
"clickToChange": "(點擊變更)",
"usingServerDefault": "使用伺服器預設模型",
"selectModel": "選擇模型",
"searchModels": "搜尋模型...",
"noVerifiedModels": "沒有已驗證的模型。請先測試您的模型。",
"noModelsFound": "未找到模型。",
"default": "預設",
"serverDefault": "伺服器預設",
"serverModels": "伺服器模型",
"userModels": "使用者模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "僅顯示已驗證的模型",
"showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型",
"showValue": "顯示值",
"hideValue": "隱藏值"
},
"admin": {
"title": "管理員設定",
"loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。",
"password": "密碼",
"signIn": "登入",
"signingIn": "正在登入…",
"loginFailed": "登入失敗",
"precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值",
"notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。",
"settingGroups": "設定分組",
"enabled": "已啟用",
"disabled": "已停用",
"enableGroup": "啟用 {group}",
"unsavedChanges": "有未儲存的變更",
"saved": "設定已儲存,變更立即生效。",
"saveFailed": "儲存失敗。請檢查網路連線後重試。",
"invalidSettings": "部分設定無效。",
"discard": "捨棄",
"saveChanges": "儲存變更",
"saving": "正在儲存…",
"sourceSaved": "已儲存",
"sourceEnv": "環境變數",
"sourceSavedTitle": "在管理員設定檔中設定",
"sourceEnvTitle": "透過環境變數設定",
"restartRequired": "需要重新啟動",
"modified": "已修改",
"notSet": "未設定",
"savedReplace": "已儲存({hint})——輸入以取代",
"showValue": "顯示值",
"hideValue": "隱藏值",
"removeValue": "移除值",
"removeValueTitle": "移除已儲存的值",
"resetToDefault": "重設為預設",
"models": "模型",
"modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。",
"addProviderHint": "新增一個 provider,為所有使用者提供伺服器端模型。",
"selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。",
"addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。",
"managedViaEnv": "(透過環境變數管理)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。",
"defaultModel": "預設模型",
"noModelsConfigured": "未設定模型",
"modelCount": "{count} 個模型",
"modelCountPlural": "{count} 個模型",
"default": "預設",
"setAsDefault": "設為預設 provider",
"defaultProvider": "預設 provider",
"modelIdPlaceholder": "模型 ID…",
"addModel": "新增模型",
"suggested": "推薦",
"test": "測試",
"testOk": "正常({ms} 毫秒)",
"testFailed": "失敗",
"removeModel": "移除 {model}",
"deleteProviderTitle": "刪除 {name}",
"deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。",
"cancel": "取消",
"delete": "刪除",
"groups": {
"generation": {
"title": "生成",
"description": "套用於所有聊天請求的輸出參數。"
},
"access": {
"title": "存取控制",
"description": "限制誰可以使用此部署。"
},
"features": {
"title": "功能",
"description": "選用功能和安全開關。"
},
"observability": {
"title": "可觀測性",
"description": "對 LLM 呼叫進行 Langfuse 追蹤。"
},
"quota": {
"title": "配額與速率限制",
"description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。"
}
},
"settings": {
"TEMPERATURE": {
"label": "溫度",
"description": "對於拒絕溫度參數的推理模型,請留空。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大輸出 token 數"
},
"ACCESS_CODE_LIST": {
"label": "存取碼",
"description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 圖表驗證",
"description": "使用視覺模型對產生的圖表進行視覺化驗證。"
},
"VALIDATION_MODEL": {
"label": "驗證模型",
"description": "留空時回退到預設 AI 模型。"
},
"VALIDATION_TIMEOUT": {
"label": "驗證逾時(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "歷史 XML 壓縮",
"description": "用占位符取代歷史記錄中的舊圖表 XML。"
},
"ALLOW_PRIVATE_URLS": {
"label": "允許私有 URL",
"description": "關閉以阻擋對私有 IP 和內部主機名的請求(SSRF 防護)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "每日請求上限",
"description": "每個 IP 每天。"
},
"DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限",
"description": "每個 IP 每天。"
},
"TPM_LIMIT": {
"label": "每分鐘 token 數"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表",
"description": "留空時配額強制執行被停用。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB 區域"
},
"QUOTA_TIMEZONE": {
"label": "配額時區",
"description": "每日重置邊界所用的時區。"
}
}
}
}
+14 -272
View File
@@ -28,18 +28,12 @@
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu"
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
"send": "发送",
"stopGeneration": "停止生成",
"sending": "发送中...",
"sendMessage": "发送消息",
"clearConversation": "清除对话",
"diagramHistory": "图表历史",
@@ -57,8 +51,7 @@
"badResponse": "无帮助",
"clickToEdit": "点击编辑",
"editMessage": "编辑消息",
"saveAndSubmit": "保存并提交",
"ExtractURL": "从 URL 提取"
"saveAndSubmit": "保存并提交"
},
"examples": {
"title": "用 AI 创建图表",
@@ -76,13 +69,12 @@
"creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例已缓存,可即时响应",
"mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
"preview": "预览"
},
"settings": {
"title": "设置",
"description": "配置您的应用程序设置。",
"apiKeysModels": "API 密钥和模型",
"apiKeysModelsDescription": "配置 AI 提供商和 API 密钥。",
"accessCode": "访问码",
"accessCodePlaceholder": "输入访问码",
"accessCodeDescription": "使用此应用程序需要访问码。",
@@ -102,40 +94,18 @@
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式",
"themeDefault": "默认",
"themeDark": "深色",
"themeMinimal": "简约",
"themeSketch": "草图",
"themeSimple": "简单",
"drawioStyleDescription": "画布样式",
"switchTo": "切换到",
"minimal": "简约",
"sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键",
"sendShortcutDescription": "选择发送消息的方式。",
"enterToSend": "回车发送",
"ctrlEnterToSend": "Cmd/Ctrl+回车发送",
"diagramActions": "图表操作",
"diagramActionsDescription": "管理图表历史记录和导出",
"history": "历史记录",
"download": "下载",
"proxy": "代理设置",
"proxyDescription": "配置 API 请求的 HTTP/HTTPS 代理(仅桌面版)",
"httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理",
"applyProxy": "应用",
"proxyApplied": "代理设置已应用",
"diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已启用",
"disabled": "已禁用",
"customSystemMessage": "自定义系统消息",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"panelVisibility": "大厅面板",
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的模板",
"showQuickExamples": "快速示例"
"download": "下载"
},
"save": {
"title": "保存图表",
@@ -146,8 +116,7 @@
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 图片",
"svg": "SVG 图片",
"xmlsvg": "可编辑 SVG"
"svg": "SVG 图片"
},
"savedSuccessfully": "保存成功!"
},
@@ -194,11 +163,8 @@
"tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
"messageApiSelfHosted": null,
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
"messageTokenSelfHosted": null,
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
"reset": "您的限制将在明天重置。感谢您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
"configModel": "使用您的密钥",
@@ -220,15 +186,6 @@
"chars": "字符",
"removeFile": "移除文件"
},
"url": {
"title": "从 URL 提取内容",
"description": "粘贴 URL 以提取和分析其内容",
"Extracting": "提取中...",
"extract": "提取",
"Cancel": "取消",
"enterUrl": "请输入 URL",
"invalidFormat": "URL 格式无效"
},
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
@@ -272,70 +229,6 @@
"searchPlaceholder": "搜索对话...",
"noResults": "未找到对话"
},
"templates": {
"title": "我的模板库",
"subtitle": "您的个人 prompt 库,快速创建图表",
"emptyTitle": "暂无模板",
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
"createFirst": "创建第一个模板",
"neverUsed": "未使用过",
"usedCount": "使用 {count} 次",
"myTemplates": "我的模板",
"createTitle": "创建模板",
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
"promptLabel": "提示词",
"promptPlaceholder": "描述您想要创建的图表...",
"promptRequired": "提示词不能为空",
"titleLabel": "标题",
"titlePlaceholder": "输入标题",
"titleHint": "留空则使用提示词前 20 个字符作为标题",
"descriptionLabel": "描述",
"descriptionPlaceholder": "为此模板添加描述",
"pinnedLabel": "置顶模板",
"pinnedHint": "置顶的模板会显示在列表顶部",
"createButton": "创建模板",
"createFailed": "创建模板失败,请重试。",
"editTitle": "编辑模板",
"editDescription": "更新模板内容和设置。",
"updateFailed": "更新模板失败,请重试。",
"duplicate": "复制",
"copySuffix": "(副本)",
"deleteTitle": "删除此模板?",
"deleteDescription": "此操作将永久删除该模板,无法撤销。",
"confirmSendTitle": "替换当前输入?",
"confirmSendDescription": "您有未发送的内容。发送此模板将替换它。",
"confirmSendButton": "发送模板",
"searchPlaceholder": "搜索模板...",
"searchNoResults": "没有匹配的模板",
"pin": "置顶",
"unpin": "取消置顶",
"saveAsTemplate": "保存为模板",
"exportTemplates": "导出模板",
"importTemplates": "导入模板",
"exportEmpty": "没有可导出的模板",
"exportSuccess": "成功导出 {count} 个模板",
"importNoFile": "请选择一个 JSON 文件",
"importFailed": "导入失败:{error}",
"importSuccess": "成功导入 {imported} 个模板,跳过 {skipped} 个重复"
},
"validation": {
"title": "验证图表",
"capturing": "截图中",
"validating": "验证中",
"validatingWithAttempt": "验证中 ({attempt}/{max})",
"valid": "通过",
"validWithWarnings": "通过(有警告)",
"issuesFound": "发现问题",
"error": "错误",
"skipped": "已跳过",
"capturedScreenshot": "截图预览:",
"issuesFoundLabel": "发现的问题:",
"suggestions": "建议:",
"passedValidation": "图表通过视觉验证 - 未发现问题。",
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
"improveWithSuggestions": "根据建议改进",
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型",
@@ -368,9 +261,7 @@
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
"optional": "(可选)",
"baseUrlWithExample": "基础 URL(可选,例如 {example}",
"customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
"models": "模型",
"customModelId": "自定义模型 ID...",
"allAdded": "已全部添加",
@@ -395,159 +286,10 @@
"noModelsFound": "未找到模型。",
"default": "默认",
"serverDefault": "服务器默认",
"serverModels": "服务器模型",
"userModels": "用户模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型",
"showValue": "显示值",
"hideValue": "隐藏值"
},
"admin": {
"title": "管理员设置",
"loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。",
"password": "密码",
"signIn": "登录",
"signingIn": "正在登录…",
"loginFailed": "登录失败",
"precedence": "文件覆盖环境变量 · 环境变量覆盖默认值",
"notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。",
"settingGroups": "设置分组",
"enabled": "已启用",
"disabled": "已禁用",
"enableGroup": "启用 {group}",
"unsavedChanges": "有未保存的更改",
"saved": "设置已保存,更改立即生效。",
"saveFailed": "保存失败。请检查网络连接后重试。",
"invalidSettings": "部分设置无效。",
"discard": "放弃",
"saveChanges": "保存更改",
"saving": "正在保存…",
"sourceSaved": "已保存",
"sourceEnv": "环境变量",
"sourceSavedTitle": "在管理员设置文件中设置",
"sourceEnvTitle": "通过环境变量设置",
"restartRequired": "需要重启",
"modified": "已修改",
"notSet": "未设置",
"savedReplace": "已保存({hint})——输入以替换",
"showValue": "显示值",
"hideValue": "隐藏值",
"removeValue": "移除值",
"removeValueTitle": "移除已保存的值",
"resetToDefault": "恢复默认",
"models": "模型",
"modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。",
"addProviderHint": "添加一个 provider,为所有用户提供服务端模型。",
"selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。",
"addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。",
"managedViaEnv": "(通过环境变量管理)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。",
"defaultModel": "默认模型",
"noModelsConfigured": "未配置模型",
"modelCount": "{count} 个模型",
"modelCountPlural": "{count} 个模型",
"default": "默认",
"setAsDefault": "设为默认 provider",
"defaultProvider": "默认 provider",
"modelIdPlaceholder": "模型 ID…",
"addModel": "添加模型",
"suggested": "推荐",
"test": "测试",
"testOk": "正常({ms} 毫秒)",
"testFailed": "失败",
"removeModel": "移除 {model}",
"deleteProviderTitle": "删除 {name}",
"deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。",
"cancel": "取消",
"delete": "删除",
"groups": {
"generation": {
"title": "生成",
"description": "应用于所有聊天请求的输出参数。"
},
"access": {
"title": "访问控制",
"description": "限制谁可以使用此部署。"
},
"features": {
"title": "功能",
"description": "可选功能和安全开关。"
},
"observability": {
"title": "可观测性",
"description": "对 LLM 调用进行 Langfuse 追踪。"
},
"quota": {
"title": "配额与速率限制",
"description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。"
}
},
"settings": {
"TEMPERATURE": {
"label": "温度",
"description": "对于拒绝温度参数的推理模型,请留空。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大输出 token 数"
},
"ACCESS_CODE_LIST": {
"label": "访问码",
"description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 图表验证",
"description": "使用视觉模型对生成的图表进行可视化验证。"
},
"VALIDATION_MODEL": {
"label": "验证模型",
"description": "留空时回退到默认 AI 模型。"
},
"VALIDATION_TIMEOUT": {
"label": "验证超时(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "历史 XML 压缩",
"description": "用占位符替换历史记录中的旧图表 XML。"
},
"ALLOW_PRIVATE_URLS": {
"label": "允许私有 URL",
"description": "关闭以阻止对私有 IP 和内部主机名的请求(SSRF 防护)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "每日请求上限",
"description": "每个 IP 每天。"
},
"DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限",
"description": "每个 IP 每天。"
},
"TPM_LIMIT": {
"label": "每分钟 token 数"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表",
"description": "留空时配额强制执行被禁用。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB 区域"
},
"QUOTA_TIMEZONE": {
"label": "配额时区",
"description": "每日重置边界所用的时区。"
}
}
"unvalidatedModelWarning": "此模型尚未验证"
}
}

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