mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
104 Commits
feat/mater
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb91c54ce | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
96bca2b37b | ||
|
|
fd758b9e87 | ||
|
|
81da9fad83 | ||
|
|
6493652ff0 | ||
|
|
be8f26d6b1 | ||
|
|
cd02b2de92 | ||
|
|
6e653942b0 | ||
|
|
c8463aefa7 | ||
|
|
4b07228320 | ||
|
|
f3a85558d8 | ||
|
|
4f09d9461a | ||
|
|
4984be82a1 | ||
|
|
5bfd7b2468 | ||
|
|
80baf43827 | ||
|
|
5c884766a8 | ||
|
|
8e42dd9da8 | ||
|
|
0f9699843f | ||
|
|
988034cc3e | ||
|
|
8bc9871829 | ||
|
|
449e4c4e26 | ||
|
|
54ff8d982c | ||
|
|
410993a3bf | ||
|
|
77e7766f9a | ||
|
|
a9ffd6a1de | ||
|
|
277ad83552 | ||
|
|
7b6eb39fa5 | ||
|
|
1115b2d2cd | ||
|
|
08afb6dd34 | ||
|
|
c703159e00 | ||
|
|
ee75408136 | ||
|
|
2ed1a64ae3 | ||
|
|
2f2d75961d | ||
|
|
5406778dd6 | ||
|
|
bb65a8c07a | ||
|
|
4e223b6237 | ||
|
|
c60e3930a3 | ||
|
|
5c8ae4d6d7 | ||
|
|
f965f3fa2e | ||
|
|
73eefc7aa6 | ||
|
|
a8d27088ef | ||
|
|
d4454beb9a | ||
|
|
171174378c | ||
|
|
eadc2c2629 | ||
|
|
c77af86011 | ||
|
|
0cd1260172 | ||
|
|
49bfb51b10 | ||
|
|
ccd9c1f48e | ||
|
|
622aa8683d | ||
|
|
43ddb7a999 | ||
|
|
b9fdf9538c | ||
|
|
1f31692701 | ||
|
|
31819f413c | ||
|
|
41c410c2ba | ||
|
|
f593901fee | ||
|
|
6c6cf98019 | ||
|
|
f5ea5a0edd | ||
|
|
cb8127920c | ||
|
|
29538782fc | ||
|
|
8d6d33bfc9 | ||
|
|
0690f05399 | ||
|
|
f9c30d95b2 | ||
|
|
95e3e97b8b | ||
|
|
16801f69b4 | ||
|
|
4f41cd1f01 | ||
|
|
3ca46f44c1 | ||
|
|
a5871ded9b | ||
|
|
c3ff41ce72 | ||
|
|
9efec68cbc | ||
|
|
529a3fe2e5 | ||
|
|
571167f9dd | ||
|
|
67b0d77fa1 | ||
|
|
524b77a948 | ||
|
|
9bf0c7f23f | ||
|
|
9b9a291e2b | ||
|
|
ada2c840b9 | ||
|
|
43cc4cb657 | ||
|
|
fd84aa70db | ||
|
|
8b0beb68c0 | ||
|
|
7a6a933cf6 | ||
|
|
05bda50b2a | ||
|
|
e7453e86a6 | ||
|
|
be4bc916fd | ||
|
|
c4ba5d4ea8 | ||
|
|
ff07975574 | ||
|
|
ffac74b0ff | ||
|
|
69bd13bc93 | ||
|
|
dd9d79d2d6 | ||
|
|
f3c3614b53 | ||
|
|
6586c53093 | ||
|
|
c083782802 | ||
|
|
a5d1554c3f | ||
|
|
e171fbcdd8 | ||
|
|
89d3968733 | ||
|
|
ac3570c1b0 | ||
|
|
41dc0b2b42 | ||
|
|
f19dc919e1 | ||
|
|
8d92474f73 | ||
|
|
cc6f09615d | ||
|
|
be20c09c89 | ||
|
|
76487fb7ea | ||
|
|
c5de1a16ad |
19
.github/CONTRIBUTING.md
vendored
19
.github/CONTRIBUTING.md
vendored
@@ -47,6 +47,10 @@ 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
|
||||
@@ -57,6 +61,21 @@ npx playwright test --ui
|
||||
|
||||
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.
|
||||
|
||||
5
.github/renovate.json
vendored
5
.github/renovate.json
vendored
@@ -33,6 +33,11 @@
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@biomejs/biome"],
|
||||
"groupName": "Biome",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
|
||||
4
.github/workflows/auto-format.yml
vendored
4
.github/workflows/auto-format.yml
vendored
@@ -23,7 +23,9 @@ jobs:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run Biome format
|
||||
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
|
||||
# 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 .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -40,5 +40,3 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Security audit
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
|
||||
3
.github/workflows/docker-build.yml
vendored
3
.github/workflows/docker-build.yml
vendored
@@ -58,6 +58,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -89,4 +91,3 @@ jobs:
|
||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
|
||||
|
||||
4
.github/workflows/electron-release.yml
vendored
4
.github/workflows/electron-release.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Download draw.io static files for offline use
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
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
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/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
|
||||
|
||||
67
.github/workflows/publish-mcp.yml
vendored
Normal file
67
.github/workflows/publish-mcp.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Publish MCP Server
|
||||
|
||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||
# skips silently if the package.json version is already on npm — so a
|
||||
# release is just "bump the version in a PR and merge".
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/mcp-server/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token for npm trusted publishing
|
||||
|
||||
concurrency:
|
||||
group: publish-mcp
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/mcp-server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Trusted publishing requires npm >= 11.5.1
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Check if version is already published
|
||||
id: version
|
||||
run: |
|
||||
LOCAL=$(node -p "require('./package.json').version")
|
||||
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
||||
echo "Version ${LOCAL} already on npm - nothing to publish"
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version ${LOCAL} not on npm - publishing"
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Test
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm publish
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -28,6 +28,16 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: npm run test -- --run
|
||||
|
||||
# The MCP server package ships its own vitest because its DOM polyfill
|
||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||
# for the Next.js app. Install + run its tests separately so CI catches
|
||||
# multi-page mxfile regressions.
|
||||
- name: Install MCP server dependencies
|
||||
run: npm --prefix packages/mcp-server ci
|
||||
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -72,3 +72,10 @@ CLAUDE.md
|
||||
.edgeone
|
||||
opencode.json
|
||||
ai-models.json
|
||||
|
||||
# local backups
|
||||
*.bak
|
||||
.gstack/
|
||||
|
||||
# admin panel settings (contains secrets)
|
||||
data/
|
||||
|
||||
@@ -9,6 +9,7 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||
RUN npm install
|
||||
|
||||
# Stage 2: Build application
|
||||
@@ -34,6 +35,11 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||
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
|
||||
|
||||
@@ -55,6 +61,9 @@ 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
|
||||
|
||||
57
README.md
57
README.md
@@ -19,7 +19,18 @@ 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/newyear-referral?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!
|
||||
> 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!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
||||
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
@@ -31,7 +42,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Examples](#examples)
|
||||
- [Features](#features)
|
||||
- [MCP Server (Preview)](#mcp-server-preview)
|
||||
- [MCP Server](#mcp-server)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Try it Online](#try-it-online)
|
||||
@@ -43,6 +54,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
- [Deploy on Vercel](#deploy-on-vercel)
|
||||
- [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)
|
||||
@@ -63,24 +76,24 @@ Here are some example prompts and their generated diagrams:
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<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" />
|
||||
<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" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<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" />
|
||||
<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" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<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" />
|
||||
<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" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Cat sketch prompt</strong><br />
|
||||
<strong>Cat sketch</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>
|
||||
@@ -99,9 +112,7 @@ 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 (Preview)
|
||||
|
||||
> **Preview Feature**: This feature is experimental and may not be stable.
|
||||
## MCP Server
|
||||
|
||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||
|
||||
@@ -202,7 +213,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
|
||||
## Multi-Provider Support
|
||||
|
||||
- [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- [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)
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -211,11 +222,13 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
||||
|
||||
|
||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
@@ -224,7 +237,13 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
|
||||
### 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.
|
||||
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. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -244,7 +263,9 @@ 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/newyear-referral?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!
|
||||
**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 [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function AboutCN() {
|
||||
<p>
|
||||
好消息!感谢{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -87,7 +87,7 @@ export default function AboutCN() {
|
||||
</a>
|
||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
K2-thinking
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
@@ -97,6 +97,23 @@ 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>
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
@@ -158,92 +175,106 @@ export default function AboutCN() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
动画Transformer连接器
|
||||
ResNet50模型架构动画
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>提示词:</strong> 给我一个带有
|
||||
<strong>动画连接器</strong>的Transformer架构图。
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="带动画连接器的Transformer架构"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50模型架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
{/* Diagram 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">
|
||||
GCP架构图
|
||||
RAG技术图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>GCP图标</strong>
|
||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS架构图
|
||||
React和AWS认证流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>AWS图标</strong>
|
||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure架构图
|
||||
敏捷Scrum流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>Azure图标</strong>
|
||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</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>提示词:</strong>{" "}
|
||||
给我画一只可爱的猫。
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫咪绘图"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,7 +308,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/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -312,7 +343,7 @@ export default function AboutCN() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
特别感谢{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function AboutJA() {
|
||||
<p>
|
||||
朗報です!
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -95,7 +95,7 @@ export default function AboutJA() {
|
||||
</a>
|
||||
様のご支援により、デモサイトでは強力な{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
K2-thinking
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
@@ -168,93 +168,106 @@ export default function AboutJA() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
アニメーションTransformerコネクタ
|
||||
ResNet50モデルアーキテクチャアニメーション
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
付きのTransformerアーキテクチャ図を作成してください。
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50モデルアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
{/* Diagram 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">
|
||||
GCPアーキテクチャ図
|
||||
RAG技術ダイアグラム
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>GCPアイコン</strong>
|
||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCPアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWSアーキテクチャ図
|
||||
ReactとAWSによる認証
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>AWSアイコン</strong>
|
||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWSアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</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>プロンプト:</strong>{" "}
|
||||
<strong>Azureアイコン</strong>
|
||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azureアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</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>プロンプト:</strong>{" "}
|
||||
かわいい猫を描いてください。
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫の絵"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,7 +305,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/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -327,7 +340,7 @@ export default function AboutJA() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function About() {
|
||||
Great news! Thanks to the generous
|
||||
sponsorship from{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -96,7 +96,7 @@ export default function About() {
|
||||
</a>
|
||||
, the demo site now uses the powerful{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
K2-thinking
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
model for better diagram generation! Sign up
|
||||
via the link to get{" "}
|
||||
@@ -182,96 +182,106 @@ export default function About() {
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Animated Transformer Connectors
|
||||
Animated ResNet50 Model Architecture
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated connector</strong> diagram of
|
||||
transformer's architecture.
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="Transformer Architecture with Animated Connectors"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="Architecture diagram for ResNet50 model"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture Grid */}
|
||||
{/* Diagram 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">
|
||||
GCP Architecture Diagram
|
||||
RAG Technique Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a GCP
|
||||
architecture diagram with{" "}
|
||||
<strong>GCP icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS Architecture Diagram
|
||||
Authentication using React and AWS
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an AWS
|
||||
architecture diagram with{" "}
|
||||
<strong>AWS icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure Architecture Diagram
|
||||
Agile Scrum Process
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an Azure
|
||||
architecture diagram with{" "}
|
||||
<strong>Azure icons</strong>. Users connect
|
||||
to a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Cat Sketch
|
||||
Open Innovation
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Draw a cute cat for
|
||||
me.
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="Cat Drawing"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,7 +321,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/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -348,7 +358,7 @@ export default function About() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
Special thanks to{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
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"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
65
app/[lang]/admin/admin-shared.ts
Normal file
65
app/[lang]/admin/admin-shared.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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
app/[lang]/admin/models-section.tsx
Normal file
609
app/[lang]/admin/models-section.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
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
app/[lang]/admin/page.tsx
Normal file
610
app/[lang]/admin/page.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
"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
app/[lang]/admin/setting-field.tsx
Normal file
312
app/[lang]/admin/setting-field.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -10,18 +10,24 @@ import {
|
||||
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"
|
||||
|
||||
export default function Home() {
|
||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
||||
useDiagram()
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
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<"min" | "sketch">("min")
|
||||
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
@@ -48,7 +54,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const savedUi = localStorage.getItem("drawio-theme")
|
||||
if (savedUi === "min" || savedUi === "sketch") {
|
||||
if (isDrawioTheme(savedUi)) {
|
||||
setDrawioUi(savedUi)
|
||||
}
|
||||
|
||||
@@ -93,10 +99,9 @@ export default function Home() {
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
const handleDrawioUiChange = () => {
|
||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||
localStorage.setItem("drawio-theme", newUi)
|
||||
setDrawioUi(newUi)
|
||||
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
||||
localStorage.setItem("drawio-theme", theme)
|
||||
setDrawioUi(theme)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
@@ -174,6 +179,8 @@ export default function Home() {
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||
ref={drawioRef}
|
||||
autosave
|
||||
onAutoSave={handleDiagramAutoSave}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
baseUrl={drawioBaseUrl}
|
||||
@@ -184,7 +191,8 @@ export default function Home() {
|
||||
saveAndExit: false,
|
||||
noSaveBtn: true,
|
||||
noExitBtn: true,
|
||||
dark: darkMode,
|
||||
dark:
|
||||
darkMode || drawioUi === "dark",
|
||||
lang: currentLang,
|
||||
// Enable offline mode in Electron to disable external service calls
|
||||
...(isElectron && {
|
||||
@@ -232,7 +240,7 @@ export default function Home() {
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={handleDrawioUiChange}
|
||||
onDrawioUiChange={handleDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={handleDarkModeChange}
|
||||
isMobile={isMobile}
|
||||
|
||||
89
app/api/admin/providers/route.ts
Normal file
89
app/api/admin/providers/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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
app/api/admin/settings/route.ts
Normal file
126
app/api/admin/settings/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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
app/api/admin/test-model/route.ts
Normal file
66
app/api/admin/test-model/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
61
app/api/aihubmix-models/route.ts
Normal file
61
app/api/aihubmix-models/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
AIHUBMIX_MODELS_ENDPOINT,
|
||||
extractAihubmixModelIds,
|
||||
} from "@/lib/aihubmix-models"
|
||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
|
||||
const SUCCESS_CACHE_CONTROL =
|
||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
|
||||
|
||||
function fallbackResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
models: SUGGESTED_MODELS.aihubmix || [],
|
||||
source: "fallback",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[aihubmix-models] Failed to fetch models: ${response.status}`,
|
||||
)
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
const models = extractAihubmixModelIds(payload)
|
||||
|
||||
if (models.length === 0) {
|
||||
console.warn("[aihubmix-models] Model list response was empty")
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
models,
|
||||
source: "aihubmix",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": SUCCESS_CACHE_CONTROL,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn("[aihubmix-models] Failed to load models:", error)
|
||||
return fallbackResponse()
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import path from "path"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getAIModel,
|
||||
supportsImageInput,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsPromptCaching,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
@@ -34,11 +34,17 @@ import {
|
||||
setTraceOutput,
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import {
|
||||
resolveMaxOutputTokens,
|
||||
withOutputTokenLimitFallback,
|
||||
} from "@/lib/output-token-limit"
|
||||
import { findServerModelById } from "@/lib/server-model-config"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const maxDuration = 120
|
||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
@@ -89,7 +95,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
||||
const body = await req.json()
|
||||
const { messages, xml, previousXml, sessionId } = body
|
||||
const customSystemMessage =
|
||||
typeof body.customSystemMessage === "string"
|
||||
? body.customSystemMessage.slice(0, 5000)
|
||||
: ""
|
||||
|
||||
// Get user ID for Langfuse tracking and quota
|
||||
const userId = getUserIdFromRequest(req)
|
||||
@@ -235,8 +246,22 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
)
|
||||
|
||||
// Get AI model with optional client overrides
|
||||
const { model, providerOptions, headers, modelId } =
|
||||
getAIModel(clientOverrides)
|
||||
const {
|
||||
model: baseModel,
|
||||
providerOptions,
|
||||
headers,
|
||||
modelId,
|
||||
provider: resolvedProvider,
|
||||
} = getAIModel(clientOverrides)
|
||||
|
||||
// Retry with a smaller budget if the provider rejects the requested one
|
||||
const model = withOutputTokenLimitFallback(baseModel)
|
||||
|
||||
// User setting wins over server env, so desktop users can raise it themselves
|
||||
const maxOutputTokens = resolveMaxOutputTokens(
|
||||
req.headers.get("x-max-output-tokens"),
|
||||
)
|
||||
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
||||
|
||||
// Check if model supports prompt caching
|
||||
const shouldCache = supportsPromptCaching(modelId)
|
||||
@@ -246,22 +271,19 @@ 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 =
|
||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||
[]
|
||||
|
||||
// Check if user is sending images to a model that doesn't support them
|
||||
// AI SDK silently drops unsupported parts, so we need to catch this early
|
||||
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// Note: we used to pre-emptively reject images for models we guessed were
|
||||
// text-only (by name matching). That heuristic misfired on newer models
|
||||
// (see issue #874), so we now let the request through and surface the real
|
||||
// provider error if the model genuinely can't accept images.
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
@@ -418,41 +440,77 @@ ${userInputText}
|
||||
}
|
||||
|
||||
// System messages with multiple cache breakpoints for optimal caching:
|
||||
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
|
||||
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message
|
||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
||||
// 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: systemMessage,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
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" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
// 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
|
||||
? [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: `${finalSystemMessage}\n\n${xmlContext}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
// Cache breakpoint 1: Instructions (+ optional custom instructions)
|
||||
{
|
||||
role: "system" as const,
|
||||
content: finalSystemMessage,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
content: xmlContext,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
abortSignal: req.signal,
|
||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
||||
}),
|
||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||
maxOutputTokens,
|
||||
stopWhen: stepCountIs(5),
|
||||
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
||||
experimental_repairToolCall: async ({ toolCall, error }) => {
|
||||
@@ -663,7 +721,7 @@ Available libraries:
|
||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||
- Business: bpmn, lean_mapping
|
||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||
- UI/Mockups: android
|
||||
- UI/Mockups: android, material_design
|
||||
- Enterprise: citrix, sap, mscae, atlassian
|
||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||
- Icons: webicons
|
||||
@@ -708,7 +766,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, 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, material_design, lean_mapping, openstack, rack`
|
||||
}
|
||||
console.error(
|
||||
`[get_shape_library] Error loading "${library}":`,
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { extract } from "@extractus/article-extractor"
|
||||
import { extractFromHtml } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
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)"
|
||||
|
||||
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
||||
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
||||
// would produce mojibake; the article-extractor library does the same detection
|
||||
// when it fetches the page itself, which we no longer rely on.
|
||||
function detectCharset(
|
||||
contentType: string | null,
|
||||
buffer: ArrayBuffer,
|
||||
): string {
|
||||
// 1. HTTP Content-Type header charset (most authoritative).
|
||||
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
||||
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
||||
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
||||
const metaCharset =
|
||||
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
||||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
||||
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
||||
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
||||
try {
|
||||
new TextDecoder(charset)
|
||||
return charset
|
||||
} catch {
|
||||
return "utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -27,28 +53,53 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// SSRF protection
|
||||
if (!allowPrivateUrls && isPrivateUrl(url)) {
|
||||
// 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 (await isPrivateUrl(url)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot access private/internal URLs" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Extract article content with timeout to avoid tying up server resources
|
||||
// Fetch the page ourselves so we control redirect handling. The
|
||||
// article-extractor library follows redirects internally and ignores a
|
||||
// `redirect` option, which would let a public URL 302 to an internal
|
||||
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
||||
// redirect outright.
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, EXTRACT_TIMEOUT_MS)
|
||||
|
||||
let article
|
||||
let html: string
|
||||
try {
|
||||
article = await extract(url, undefined, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
||||
},
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const contentType = response.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 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const charset = detectCharset(contentType, buffer)
|
||||
html = new TextDecoder(charset).decode(buffer)
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
return NextResponse.json(
|
||||
@@ -56,11 +107,25 @@ export async function POST(req: Request) {
|
||||
{ status: 504 },
|
||||
)
|
||||
}
|
||||
throw err
|
||||
// Redirects are rejected with a TypeError ("failed to fetch" /
|
||||
// "unexpected redirect") when redirect: "error" is set.
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||
// so map any parse error to the same 400 as the no-content case.
|
||||
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
||||
try {
|
||||
article = await extractFromHtml(html, url)
|
||||
} catch {
|
||||
article = null
|
||||
}
|
||||
|
||||
if (!article || !article.content) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not extract content from URL" },
|
||||
|
||||
@@ -5,11 +5,18 @@ 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 { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { generateText } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
import {
|
||||
AIHUBMIX_APP_CODE,
|
||||
isAihubmixStandardBaseURL,
|
||||
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"
|
||||
|
||||
@@ -49,7 +56,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
@@ -151,6 +158,28 @@ export async function POST(req: Request) {
|
||||
break
|
||||
}
|
||||
|
||||
case "aihubmix": {
|
||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
||||
|
||||
if (
|
||||
isAihubmixStandardBaseURL(baseUrl) ||
|
||||
baseUrl === defaultBaseURL
|
||||
) {
|
||||
const aihubmix = createAihubmix({
|
||||
apiKey,
|
||||
appCode: AIHUBMIX_APP_CODE,
|
||||
})
|
||||
model = aihubmix(modelId)
|
||||
} else {
|
||||
const aihubmixCompatible = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
})
|
||||
model = aihubmixCompatible.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
if (baseUrl || apiKey) {
|
||||
const ds = createDeepSeek({
|
||||
@@ -174,10 +203,21 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
const ollama = createOllama({
|
||||
baseURL: baseUrl || "http://localhost:11434",
|
||||
// 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}` },
|
||||
}),
|
||||
})
|
||||
model = ollama(modelId)
|
||||
model = ollamaProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -308,6 +348,61 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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, MiMo, Atlas Cloud - OpenAI compatible
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
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
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -141,9 +141,6 @@ 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}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BookmarkPlus,
|
||||
Download,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Link,
|
||||
Loader2,
|
||||
Send,
|
||||
Square,
|
||||
} from "lucide-react"
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} 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"
|
||||
@@ -237,6 +238,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
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")
|
||||
@@ -440,6 +442,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
return (
|
||||
<form
|
||||
id="chat-form"
|
||||
onSubmit={onSubmit}
|
||||
className={`w-full transition-all duration-200 ${
|
||||
isDragging
|
||||
@@ -539,6 +542,18 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
</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}
|
||||
@@ -611,6 +626,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
isExtracting={isExtractingUrl}
|
||||
/>
|
||||
)}
|
||||
<TemplateCreateDialog
|
||||
open={showSaveAsTemplate}
|
||||
onOpenChange={setShowSaveAsTemplate}
|
||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||
initialPrompt={input.trim()}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
import {
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -26,6 +27,7 @@ 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"
|
||||
@@ -159,6 +161,10 @@ interface ChatMessageDisplayProps {
|
||||
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({
|
||||
@@ -178,6 +184,8 @@ export function ChatMessageDisplay({
|
||||
loadedMessageIdsRef,
|
||||
validationStates = {},
|
||||
onImproveWithSuggestions,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
}: ChatMessageDisplayProps) {
|
||||
const dict = useDictionary()
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
@@ -237,6 +245,10 @@ 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,
|
||||
@@ -405,6 +417,7 @@ 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) {
|
||||
@@ -418,8 +431,17 @@ export function ChatMessageDisplay({
|
||||
return
|
||||
}
|
||||
|
||||
// Single message added - smooth scroll
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
// Throttle scroll during streaming to avoid layout thrashing
|
||||
// Leading + trailing: scroll immediately, then once more after cooldown
|
||||
if (!scrollThrottleRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
scrollThrottleRef.current = setTimeout(() => {
|
||||
scrollThrottleRef.current = null
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
})
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
@@ -650,6 +672,8 @@ export function ChatMessageDisplay({
|
||||
onDeleteSession={onDeleteSession}
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
dict={dict}
|
||||
/>
|
||||
) : messages.length === 0 ? null : (
|
||||
@@ -747,8 +771,42 @@ 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" &&
|
||||
|
||||
@@ -32,6 +32,7 @@ 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"
|
||||
@@ -68,8 +69,8 @@ interface ChatMessage {
|
||||
interface ChatPanelProps {
|
||||
isVisible: boolean
|
||||
onToggleVisibility: () => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
drawioUi: DrawioTheme
|
||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
isMobile?: boolean
|
||||
@@ -110,7 +111,7 @@ export default function ChatPanel({
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
onDrawioUiChange,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
isMobile = false,
|
||||
@@ -138,26 +139,22 @@ export default function ChatPanel({
|
||||
const onFetchChart = (saveToHistory = true) => {
|
||||
return Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
if (resolverRef && "current" in resolverRef) {
|
||||
resolverRef.current = resolve
|
||||
}
|
||||
resolverRef.current = resolve
|
||||
if (saveToHistory) {
|
||||
onExport()
|
||||
} else {
|
||||
handleExportWithoutHistory()
|
||||
}
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
"Chart export timed out after 10 seconds",
|
||||
),
|
||||
),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
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)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -180,6 +177,8 @@ export default function ChatPanel({
|
||||
const [tpmLimit, setTpmLimit] = useState(0)
|
||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
||||
const [customSystemMessage, setCustomSystemMessage] = useState("")
|
||||
const [maxOutputTokens, setMaxOutputTokens] = useState("")
|
||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
||||
|
||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||
@@ -198,6 +197,22 @@ export default function ChatPanel({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load custom system message from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.customSystemMessage)
|
||||
if (stored !== null) {
|
||||
setCustomSystemMessage(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load output token budget from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.maxOutputTokens)
|
||||
if (stored !== null) {
|
||||
setMaxOutputTokens(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check config on mount
|
||||
useEffect(() => {
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
@@ -308,6 +323,19 @@ export default function ChatPanel({
|
||||
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)
|
||||
}, [])
|
||||
|
||||
// Handler for output token budget change (empty string = use server default)
|
||||
const handleMaxOutputTokensChange = useCallback((value: string) => {
|
||||
const digitsOnly = value.replace(/\D/g, "")
|
||||
setMaxOutputTokens(digitsOnly)
|
||||
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
|
||||
}, [])
|
||||
|
||||
// Ref to store the sendMessage function for use in callbacks
|
||||
const sendMessageRef = useRef<typeof sendMessage | null>(null)
|
||||
|
||||
@@ -590,7 +618,7 @@ export default function ChatPanel({
|
||||
|
||||
try {
|
||||
const currentSession = sessionManager.currentSession
|
||||
if (currentSession && currentSession.messages.length > 0) {
|
||||
if (currentSession) {
|
||||
// Restore from session manager (IndexedDB)
|
||||
justLoadedSessionRef.current = true
|
||||
syncUIWithSession(currentSession)
|
||||
@@ -627,7 +655,7 @@ export default function ChatPanel({
|
||||
lastSyncedSessionIdRef.current = newSessionId
|
||||
|
||||
// Sync UI with new session
|
||||
if (newSession && newSession.messages.length > 0) {
|
||||
if (newSession) {
|
||||
justLoadedSessionRef.current = true
|
||||
syncUIWithSession(newSession)
|
||||
} else if (!newSession) {
|
||||
@@ -682,7 +710,7 @@ export default function ChatPanel({
|
||||
// Debounce: save after 1 second of no changes
|
||||
localStorageDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
if (messages.length > 0) {
|
||||
if (messages.length > 0 || hasDiagramNow) {
|
||||
const sessionData = await buildSessionData({
|
||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||
@@ -704,6 +732,7 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
chartXML,
|
||||
messages,
|
||||
status,
|
||||
sessionIsAvailable,
|
||||
@@ -734,7 +763,8 @@ export default function ChatPanel({
|
||||
const handleVisibilityChange = async () => {
|
||||
if (
|
||||
document.visibilityState === "hidden" &&
|
||||
messagesRef.current.length > 0
|
||||
(messagesRef.current.length > 0 ||
|
||||
isRealDiagram(chartXMLRef.current))
|
||||
) {
|
||||
try {
|
||||
// Attempt to save session - browser may not wait for completion
|
||||
@@ -816,10 +846,6 @@ export default function ChatPanel({
|
||||
let chartXml = await onFetchChart()
|
||||
chartXml = formatXML(chartXml)
|
||||
|
||||
// Update ref directly to avoid race condition with React's async state update
|
||||
// This ensures edit_diagram has the correct XML before AI responds
|
||||
chartXMLRef.current = chartXml
|
||||
|
||||
// Build user text by concatenating input with pre-extracted text
|
||||
// (Backend only reads first text part, so we must combine them)
|
||||
const parts: any[] = []
|
||||
@@ -960,6 +986,25 @@ export default function ChatPanel({
|
||||
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>,
|
||||
) => {
|
||||
@@ -1037,7 +1082,7 @@ export default function ChatPanel({
|
||||
sendMessage(
|
||||
{ parts },
|
||||
{
|
||||
body: { xml, previousXml, sessionId },
|
||||
body: { xml, previousXml, sessionId, customSystemMessage },
|
||||
headers: {
|
||||
"x-access-code": config.accessCode,
|
||||
...(config.aiProvider && {
|
||||
@@ -1075,6 +1120,9 @@ export default function ChatPanel({
|
||||
...(minimalStyle && {
|
||||
"x-minimal-style": "true",
|
||||
}),
|
||||
...(maxOutputTokens && {
|
||||
"x-max-output-tokens": maxOutputTokens,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1363,6 +1411,8 @@ export default function ChatPanel({
|
||||
loadedMessageIdsRef={loadedMessageIdsRef}
|
||||
validationStates={validationStates}
|
||||
onImproveWithSuggestions={handleImproveWithSuggestions}
|
||||
onSendTemplate={handleSendTemplate}
|
||||
currentInput={input}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -1408,13 +1458,17 @@ export default function ChatPanel({
|
||||
open={showSettingsDialog}
|
||||
onOpenChange={setShowSettingsDialog}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={onToggleDrawioUi}
|
||||
onDrawioUiChange={onDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={onToggleDarkMode}
|
||||
minimalStyle={minimalStyle}
|
||||
onMinimalStyleChange={setMinimalStyle}
|
||||
vlmValidationEnabled={vlmValidationEnabled}
|
||||
onVlmValidationChange={handleVlmValidationChange}
|
||||
customSystemMessage={customSystemMessage}
|
||||
onCustomSystemMessageChange={handleCustomSystemMessageChange}
|
||||
maxOutputTokens={maxOutputTokens}
|
||||
onMaxOutputTokensChange={handleMaxOutputTokensChange}
|
||||
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import ExamplePanel from "@/components/chat-example-panel"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import {
|
||||
@@ -21,6 +22,8 @@ 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
|
||||
@@ -35,6 +38,8 @@ interface ChatLobbyProps {
|
||||
onDeleteSession?: (id: string) => void
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
dict: {
|
||||
sessionHistory?: {
|
||||
recentChats?: string
|
||||
@@ -44,6 +49,10 @@ interface ChatLobbyProps {
|
||||
deleteTitle?: string
|
||||
deleteDescription?: string
|
||||
}
|
||||
templates?: {
|
||||
title?: string
|
||||
myTemplates?: string
|
||||
}
|
||||
examples?: {
|
||||
quickExamples?: string
|
||||
}
|
||||
@@ -75,164 +84,239 @@ 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) {
|
||||
// Track whether examples section is expanded (collapsed by default when there's history)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||
// Delete confirmation dialog state
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||
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) {
|
||||
// Show full examples when no history
|
||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
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 history + collapsible examples when there are sessions
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
{/* Recent Chats Section */}
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||
</p>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
dict.sessionHistory?.searchPlaceholder ||
|
||||
"Search chats..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions
|
||||
.filter((session) =>
|
||||
session.title
|
||||
{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"}
|
||||
</p>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
dict.sessionHistory?.searchPlaceholder ||
|
||||
"Search chats..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions
|
||||
.filter((session) =>
|
||||
session.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map((session) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
onSelectSession(session.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.thumbnailDataUrl ? (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||
<Image
|
||||
src={session.thumbnailDataUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSessionDate(
|
||||
session.updatedAt,
|
||||
dict.sessionHistory,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSessionToDelete(session.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter((s) =>
|
||||
s.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map((session) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectSession(session.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.thumbnailDataUrl ? (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||
<Image
|
||||
src={session.thumbnailDataUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSessionDate(
|
||||
session.updatedAt,
|
||||
dict.sessionHistory,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSessionToDelete(session.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter((s) =>
|
||||
s.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
).length === 0 &&
|
||||
searchQuery && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{dict.sessionHistory?.noResults ||
|
||||
"No chats found"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Examples Section */}
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.examples?.quickExamples || "Quick Examples"}
|
||||
</span>
|
||||
{examplesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{examplesExpanded && (
|
||||
<div className="mt-2">
|
||||
<ExamplePanel
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
minimal
|
||||
/>
|
||||
).length === 0 &&
|
||||
searchQuery && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{dict.sessionHistory?.noResults ||
|
||||
"No chats found"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.examples?.quickExamples || "Quick Examples"}
|
||||
</span>
|
||||
{examplesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{examplesExpanded && (
|
||||
<div className="mt-2">
|
||||
<ExamplePanel
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
|
||||
203
components/chat/TemplateCreateDialog.tsx
Normal file
203
components/chat/TemplateCreateDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"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
components/chat/TemplateEditDialog.tsx
Normal file
215
components/chat/TemplateEditDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"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
components/chat/TemplatePanel.tsx
Normal file
622
components/chat/TemplatePanel.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +195,22 @@ export function ToolCallCard({
|
||||
{input && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||
{typeof input === "object" && input.xml ? (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
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) ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,10 @@ import {
|
||||
} from "@/components/ai-elements/model-selector"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
PROVIDER_LOGO_MAP,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
@@ -39,24 +42,6 @@ interface ModelSelectorProps {
|
||||
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",
|
||||
vertexai: "google",
|
||||
doubao: "bytedance",
|
||||
modelscope: "modelscope",
|
||||
}
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
function groupModelsByProvider(
|
||||
models: FlattenedModel[],
|
||||
@@ -173,7 +158,7 @@ export function ModelSelector({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="inline-block">
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
@@ -182,7 +167,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",
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
@@ -191,7 +176,7 @@ export function ModelSelector({
|
||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="text-xs truncate">
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
@@ -214,7 +199,7 @@ export function ModelSelector({
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 &&
|
||||
models.length > 0
|
||||
|
||||
264
components/provider-credentials-fields.tsx
Normal file
264
components/provider-credentials-fields.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"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>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
components/provider-logo.tsx
Normal file
36
components/provider-logo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -23,9 +23,22 @@ 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()
|
||||
@@ -71,19 +84,24 @@ export function QuotaLimitToast({
|
||||
</div>
|
||||
{/* Message */}
|
||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||
<p>
|
||||
{isTokenLimit
|
||||
? dict.quota.messageToken
|
||||
: dict.quota.messageApi}
|
||||
</p>
|
||||
<p>{quotaMessage}</p>
|
||||
{!isSelfHosted && (
|
||||
<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: formatMessage(dict.quota.doubaoSponsorship, {
|
||||
link: "https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
|
||||
}),
|
||||
__html: tipHtml,
|
||||
}}
|
||||
/>
|
||||
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
|
||||
<p>{dict.quota.reset}</p>
|
||||
</div>{" "}
|
||||
{/* Action buttons */}
|
||||
@@ -101,24 +119,28 @@ export function QuotaLimitToast({
|
||||
{dict.quota.configModel}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
{dict.quota.selfHost}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Coffee className="w-3.5 h-3.5" />
|
||||
{dict.quota.sponsor}
|
||||
</a>
|
||||
{!isSelfHosted && (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
{dict.quota.selfHost}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Coffee className="w-3.5 h-3.5" />
|
||||
{dict.quota.sponsor}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg"
|
||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||
|
||||
interface SaveDialogProps {
|
||||
open: boolean
|
||||
@@ -74,6 +74,11 @@ 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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -22,8 +22,10 @@ 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"
|
||||
|
||||
@@ -62,8 +64,8 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
drawioUi: DrawioTheme
|
||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
minimalStyle?: boolean
|
||||
@@ -71,6 +73,10 @@ interface SettingsDialogProps {
|
||||
vlmValidationEnabled?: boolean
|
||||
onVlmValidationChange?: (value: boolean) => void
|
||||
onOpenModelConfig?: () => void
|
||||
customSystemMessage?: string
|
||||
onCustomSystemMessageChange?: (value: string) => void
|
||||
maxOutputTokens?: string
|
||||
onMaxOutputTokensChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
@@ -87,7 +93,7 @@ function SettingsContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
onDrawioUiChange,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
minimalStyle = false,
|
||||
@@ -95,6 +101,10 @@ function SettingsContent({
|
||||
vlmValidationEnabled = false,
|
||||
onVlmValidationChange = () => {},
|
||||
onOpenModelConfig,
|
||||
customSystemMessage = "",
|
||||
onCustomSystemMessageChange = () => {},
|
||||
maxOutputTokens = "",
|
||||
onMaxOutputTokensChange = () => {},
|
||||
}: SettingsDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
@@ -109,14 +119,31 @@ function SettingsContent({
|
||||
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(() => {
|
||||
// Only fetch if not cached in localStorage
|
||||
if (getStoredAccessCodeRequired() !== null) return
|
||||
// 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
|
||||
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => {
|
||||
@@ -132,10 +159,9 @@ function SettingsContent({
|
||||
setAccessCodeRequired(required)
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't cache on error - allow retry on next mount
|
||||
setAccessCodeRequired(false)
|
||||
// Keep existing cached value on error
|
||||
})
|
||||
}, [])
|
||||
}, [open])
|
||||
|
||||
// Detect current language from pathname
|
||||
useEffect(() => {
|
||||
@@ -159,6 +185,17 @@ function SettingsContent({
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
setError("")
|
||||
|
||||
// Load proxy settings (Electron only)
|
||||
@@ -274,7 +311,7 @@ function SettingsContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
@@ -284,7 +321,7 @@ function SettingsContent({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{/* API Keys & Models */}
|
||||
{onOpenModelConfig && (
|
||||
@@ -400,23 +437,40 @@ function SettingsContent({
|
||||
{/* Draw.io Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.drawioStyle}
|
||||
description={`${dict.settings.drawioStyleDescription} ${
|
||||
drawioUi === "min"
|
||||
? dict.settings.minimal
|
||||
: dict.settings.sketch
|
||||
}`}
|
||||
description={dict.settings.drawioStyleDescription}
|
||||
>
|
||||
<Button
|
||||
id="drawio-ui"
|
||||
variant="outline"
|
||||
onClick={onToggleDrawioUi}
|
||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
||||
<Select
|
||||
value={drawioUi}
|
||||
onValueChange={(v) =>
|
||||
onDrawioUiChange(v as DrawioTheme)
|
||||
}
|
||||
>
|
||||
{dict.settings.switchTo}{" "}
|
||||
{drawioUi === "min"
|
||||
? dict.settings.sketch
|
||||
: dict.settings.minimal}
|
||||
</Button>
|
||||
<SelectTrigger
|
||||
id="drawio-ui-select"
|
||||
aria-label={dict.settings.drawioStyle}
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<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>
|
||||
</SettingItem>
|
||||
|
||||
{/* Diagram Style */}
|
||||
@@ -438,6 +492,63 @@ function SettingsContent({
|
||||
</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}
|
||||
@@ -457,6 +568,51 @@ function SettingsContent({
|
||||
</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>
|
||||
|
||||
{/* Max Output Tokens */}
|
||||
<SettingItem
|
||||
label={dict.settings.maxOutputTokens}
|
||||
description={dict.settings.maxOutputTokensDescription}
|
||||
>
|
||||
<Input
|
||||
id="max-output-tokens"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxOutputTokens}
|
||||
onChange={(e) =>
|
||||
onMaxOutputTokensChange(e.target.value)
|
||||
}
|
||||
placeholder="64000"
|
||||
className="h-9 w-28 text-sm"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Send Shortcut */}
|
||||
<SettingItem
|
||||
label={dict.settings.sendShortcut}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import { toast } from "sonner"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
@@ -20,9 +20,10 @@ interface DiagramContextType {
|
||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.Ref<((value: string) => void) | null>
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: EventExport) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
filename: string,
|
||||
@@ -56,8 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
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>("")
|
||||
|
||||
@@ -66,6 +65,10 @@ 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 = () => {
|
||||
@@ -78,27 +81,9 @@ 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
|
||||
resolver: ((data: string, fullDiagramXML?: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
|
||||
@@ -219,7 +204,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: any) => {
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Handle PNG export for VLM validation
|
||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||
pngResolverRef.current(data.data)
|
||||
@@ -230,17 +215,21 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current.resolver(data.data, data.xml)
|
||||
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
|
||||
if (format === "png" || format === "svg") {
|
||||
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setChartXML(extractedXML)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
@@ -267,6 +256,16 @@ 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)
|
||||
@@ -287,18 +286,21 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Map format to draw.io export format
|
||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||
const drawioFormat =
|
||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string) => {
|
||||
resolver: (exportData: string, fullDiagramXML?: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Extract XML from SVG for .drawio format
|
||||
const xml = extractDiagramXML(exportData)
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
@@ -311,8 +313,13 @@ 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
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
@@ -391,6 +398,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
resolverRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
getThumbnailSvg,
|
||||
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
一个集成了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/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
|
||||
> 注:感谢 <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>
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
@@ -28,7 +30,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- [目录](#目录)
|
||||
- [示例](#示例)
|
||||
- [功能特性](#功能特性)
|
||||
- [MCP服务器(预览)](#mcp服务器预览)
|
||||
- [MCP服务器](#mcp服务器)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [快速开始](#快速开始)
|
||||
- [在线试用](#在线试用)
|
||||
@@ -54,31 +56,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>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCP架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
||||
<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" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWS架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
||||
<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" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azure架构图</strong><br />
|
||||
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
||||
<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" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫咪素描</strong><br />
|
||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -96,9 +98,7 @@ 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。
|
||||
|
||||
@@ -195,7 +195,7 @@ npm run dev
|
||||
|
||||
## 多提供商支持
|
||||
|
||||
- [字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- [字节跳动豆包](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)
|
||||
- AWS Bedrock(默认)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -204,6 +204,7 @@ npm run dev
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
@@ -216,12 +217,18 @@ npm run dev
|
||||
|
||||
### 服务端多模型配置
|
||||
|
||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
|
||||
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
|
||||
|
||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(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)** — 启用方法、优先级规则和注意事项。
|
||||
|
||||
|
||||
## 工作原理
|
||||
|
||||
@@ -236,7 +243,7 @@ npm run dev
|
||||
|
||||
## 支持与联系
|
||||
|
||||
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
||||
**特别感谢[字节跳动豆包](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://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||
|
||||
|
||||
24
docs/cn/admin-panel.md
Normal file
24
docs/cn/admin-panel.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 管理面板
|
||||
|
||||
无需手动编辑 `.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_*` 变量在构建时固化,无法在面板中修改。
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
### 豆包 (字节跳动火山引擎)
|
||||
|
||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
|
||||
> **免费 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!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
可选的自定义端点:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -53,6 +68,13 @@ 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
|
||||
@@ -207,6 +229,98 @@ 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
|
||||
```
|
||||
|
||||
### MiMo (小米)
|
||||
|
||||
```bash
|
||||
MIMO_API_KEY=your_api_key
|
||||
AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
|
||||
|
||||
```bash
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
## 自动检测
|
||||
|
||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||
@@ -214,7 +328,7 @@ AI_MODEL=openai/gpt-4o
|
||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
```
|
||||
|
||||
## 服务端多模型配置
|
||||
@@ -235,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
||||
|
||||
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
|
||||
|
||||
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
|
||||
|
||||
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=doubao
|
||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
```
|
||||
|
||||
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
|
||||
|
||||
### 配置示例
|
||||
|
||||
```json
|
||||
|
||||
24
docs/en/admin-panel.md
Normal file
24
docs/en/admin-panel.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
@@ -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/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
|
||||
> **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!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
@@ -61,6 +61,21 @@ Optional custom endpoint (for OpenAI-compatible services):
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
Optional custom endpoint:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -68,6 +83,13 @@ 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
|
||||
@@ -222,6 +244,98 @@ 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
|
||||
```
|
||||
|
||||
### MiMo (Xiaomi)
|
||||
|
||||
```bash
|
||||
MIMO_API_KEY=your_api_key
|
||||
AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
||||
|
||||
```bash
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
## 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`.
|
||||
@@ -229,7 +343,7 @@ If you only configure **one** provider's API key, the system will automatically
|
||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
|
||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
```
|
||||
|
||||
## Server-Side Multi-Model Configuration
|
||||
@@ -250,6 +364,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
||||
|
||||
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
|
||||
|
||||
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
|
||||
|
||||
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=doubao
|
||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
```
|
||||
|
||||
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
|
||||
@@ -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/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
|
||||
> 注:<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 モデルを導入しました!
|
||||
|
||||
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)
|
||||
- [はじめに](#はじめに)
|
||||
- [オンラインで試す](#オンラインで試す)
|
||||
@@ -54,31 +54,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>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCPアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||
<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" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWSアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||
<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" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azureアーキテクチャ図</strong><br />
|
||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||
<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" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>猫のスケッチ</strong><br />
|
||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -96,9 +96,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
|
||||
## MCPサーバー(プレビュー)
|
||||
|
||||
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
||||
## MCPサーバー
|
||||
|
||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||
|
||||
@@ -196,7 +194,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
||||
|
||||
## マルチプロバイダーサポート
|
||||
|
||||
- [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- [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)
|
||||
- AWS Bedrock(デフォルト)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -205,6 +203,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
@@ -217,12 +216,18 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
### サーバーサイドマルチモデル設定
|
||||
|
||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
|
||||
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
|
||||
|
||||
**モデル要件**:このタスクは厳密なフォーマット制約(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)** — 有効化の方法、優先順位ルール、注意事項。
|
||||
|
||||
|
||||
## 仕組み
|
||||
|
||||
@@ -237,7 +242,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
## サポート&お問い合わせ
|
||||
|
||||
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
||||
**デモサイトの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万トークンが無料でもらえます!
|
||||
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||
|
||||
|
||||
24
docs/ja/admin-panel.md
Normal file
24
docs/ja/admin-panel.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 管理パネル
|
||||
|
||||
`.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_*` 変数はビルド時に固定され、パネルでは変更できません。
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
### Doubao (ByteDance Volcengine)
|
||||
|
||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
||||
> **無料トークン**: [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万トークンが無料で入手できます!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
||||
|
||||
```bash
|
||||
AIHUBMIX_API_KEY=your_api_key
|
||||
AI_MODEL=claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
任意のカスタムエンドポイント:
|
||||
|
||||
```bash
|
||||
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```bash
|
||||
@@ -53,6 +68,13 @@ 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
|
||||
@@ -207,6 +229,98 @@ 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
|
||||
```
|
||||
|
||||
### MiMo (Xiaomi)
|
||||
|
||||
```bash
|
||||
MIMO_API_KEY=your_api_key
|
||||
AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
|
||||
|
||||
```bash
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
## 自動検出
|
||||
|
||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||
@@ -214,7 +328,7 @@ AI_MODEL=openai/gpt-4o
|
||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
```
|
||||
|
||||
## サーバーサイドマルチモデル設定
|
||||
@@ -235,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
|
||||
|
||||
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
|
||||
|
||||
**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
|
||||
|
||||
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=doubao
|
||||
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
```
|
||||
|
||||
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
|
||||
|
||||
### 設定例
|
||||
|
||||
```json
|
||||
|
||||
367
docs/shape-libraries/material_design.md
Normal file
367
docs/shape-libraries/material_design.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 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)
|
||||
@@ -10,8 +10,13 @@ directories:
|
||||
afterPack: ./scripts/afterPack.cjs
|
||||
|
||||
files:
|
||||
- dist-electron/**/*
|
||||
- "!node_modules"
|
||||
- from: dist-electron
|
||||
to: dist-electron
|
||||
filter:
|
||||
- "**/*"
|
||||
- from: .
|
||||
filter:
|
||||
- package.json
|
||||
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
@@ -90,6 +95,10 @@ linux:
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: rpm
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
|
||||
# Publish configuration (optional)
|
||||
publish:
|
||||
|
||||
2
electron/electron.d.ts
vendored
2
electron/electron.d.ts
vendored
@@ -101,8 +101,8 @@ declare global {
|
||||
}
|
||||
|
||||
export type {
|
||||
ConfigPreset,
|
||||
ApplyPresetResult,
|
||||
ConfigPreset,
|
||||
ProxyConfig,
|
||||
SetProxyResult,
|
||||
SetUserLocaleResult,
|
||||
|
||||
@@ -359,9 +359,9 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
||||
baseUrl: "MODELSCOPE_BASE_URL",
|
||||
},
|
||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||
// bedrock and ollama don't use API keys in the same way
|
||||
// bedrock doesn't use API keys in the same way
|
||||
bedrock: { apiKey: "", baseUrl: "" },
|
||||
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
||||
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,7 +94,8 @@ if (!gotTheLock) {
|
||||
if (
|
||||
url.includes("diagrams.net") ||
|
||||
url.includes("draw.io") ||
|
||||
url.startsWith("http://localhost")
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("http://127.0.0.1")
|
||||
) {
|
||||
return { action: "allow" }
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
|
||||
const env: Record<string, string> = {
|
||||
NODE_ENV: "production",
|
||||
PORT: String(port),
|
||||
HOSTNAME: "localhost",
|
||||
HOSTNAME: "127.0.0.1",
|
||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import { app } from "electron"
|
||||
const PORT_CONFIG = {
|
||||
// Development mode uses fixed port for hot reload compatibility
|
||||
development: 6002,
|
||||
// Production mode uses fixed port (61337) to preserve localStorage
|
||||
// Falls back to sequential ports if unavailable
|
||||
production: 61337,
|
||||
// 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,
|
||||
// Maximum attempts to find an available port (fallback)
|
||||
maxAttempts: 100,
|
||||
}
|
||||
@@ -27,7 +29,10 @@ let allocatedPort: number | null = null
|
||||
export function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", () => resolve(false))
|
||||
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(`Port ${port} unavailable: ${err.code}`)
|
||||
resolve(false)
|
||||
})
|
||||
server.once("listening", () => {
|
||||
server.close()
|
||||
resolve(true)
|
||||
@@ -39,12 +44,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses fixed port (61337) to preserve localStorage
|
||||
* - In production: uses fixed port (13370) 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
|
||||
@@ -64,7 +69,16 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// Try preferred port first
|
||||
// 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
|
||||
if (await isPortAvailable(preferredPort)) {
|
||||
allocatedPort = preferredPort
|
||||
return preferredPort
|
||||
@@ -84,9 +98,23 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||
// 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).",
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,5 +141,5 @@ export function getServerUrl(): string {
|
||||
"No port allocated yet. Call findAvailablePort() first.",
|
||||
)
|
||||
}
|
||||
return `http://localhost:${allocatedPort}`
|
||||
return `http://127.0.0.1:${allocatedPort}`
|
||||
}
|
||||
|
||||
@@ -60,13 +60,24 @@ 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")) {
|
||||
if (
|
||||
title &&
|
||||
!title.includes("localhost") &&
|
||||
!title.includes("127.0.0.1")
|
||||
) {
|
||||
mainWindow?.setTitle(title)
|
||||
} else {
|
||||
event.preventDefault()
|
||||
|
||||
78
env.example
78
env.example
@@ -1,12 +1,21 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
|
||||
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
||||
# The first one becomes the default and the rest appear in the model picker.
|
||||
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
|
||||
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
|
||||
# so a thinking model can spend it all before the tool call. Users can override it in Settings.
|
||||
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
|
||||
# MAX_OUTPUT_TOKENS=64000
|
||||
|
||||
# AWS Bedrock Configuration
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=your-access-key-id
|
||||
@@ -25,7 +34,8 @@ 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-...
|
||||
# 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_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
|
||||
@@ -59,14 +69,19 @@ 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 (Local) Configuration
|
||||
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
|
||||
# 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_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||
|
||||
# OpenRouter Configuration
|
||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
||||
|
||||
# AIHubMix Configuration
|
||||
# AIHUBMIX_API_KEY=your-aihubmix-api-key
|
||||
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
|
||||
|
||||
# DeepSeek Configuration
|
||||
# DEEPSEEK_API_KEY=sk-...
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||
@@ -114,6 +129,14 @@ 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
|
||||
@@ -134,3 +157,50 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# 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
|
||||
|
||||
# MiMo (Xiaomi) Configuration (Optional)
|
||||
# Get your API key from: https://platform.xiaomimimo.com/
|
||||
# MIMO_API_KEY=your_mimo_api_key
|
||||
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
|
||||
|
||||
# Atlas Cloud Configuration (Optional)
|
||||
# Get your API key from: https://www.atlascloud.ai/console/api-keys
|
||||
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
|
||||
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
|
||||
export function register() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip telemetry if Langfuse env vars are not configured
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
console.warn(
|
||||
|
||||
37
lib/admin/auth.ts
Normal file
37
lib/admin/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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
lib/admin/providers.ts
Normal file
303
lib/admin/providers.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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
lib/admin/settings-registry.ts
Normal file
229
lib/admin/settings-registry.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// 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
lib/admin/settings.ts
Normal file
134
lib/admin/settings.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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]
|
||||
}
|
||||
@@ -6,18 +6,71 @@ 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 { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export type { ProviderName }
|
||||
|
||||
export const AIHUBMIX_APP_CODE = "MSBS9675"
|
||||
|
||||
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",
|
||||
"mimo",
|
||||
])
|
||||
|
||||
/**
|
||||
* 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 function isAihubmixStandardBaseURL(
|
||||
rawUrl: string | null | undefined,
|
||||
): boolean {
|
||||
if (!rawUrl) return true
|
||||
|
||||
const baseURL = rawUrl.replace(/\/+$/, "")
|
||||
return (
|
||||
baseURL === "https://aihubmix.com" ||
|
||||
baseURL === "https://aihubmix.com/v1"
|
||||
)
|
||||
}
|
||||
|
||||
export interface ClientOverrides {
|
||||
@@ -49,6 +102,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"azure",
|
||||
"bedrock",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
"deepseek",
|
||||
"siliconflow",
|
||||
"sglang",
|
||||
@@ -57,6 +111,14 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"ollama",
|
||||
"doubao",
|
||||
"modelscope",
|
||||
"glm",
|
||||
"qwen",
|
||||
"qiniu",
|
||||
"kimi",
|
||||
"minimax",
|
||||
"novita",
|
||||
"mimo",
|
||||
"atlascloud",
|
||||
]
|
||||
|
||||
// Bedrock provider options for Anthropic beta features
|
||||
@@ -470,11 +532,20 @@ function buildProviderOptions(
|
||||
|
||||
case "deepseek":
|
||||
case "openrouter":
|
||||
case "aihubmix":
|
||||
case "siliconflow":
|
||||
case "sglang":
|
||||
case "gateway":
|
||||
case "modelscope":
|
||||
case "doubao": {
|
||||
case "doubao":
|
||||
case "minimax":
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
// These providers don't have reasoning configs in AI SDK yet
|
||||
// Gateway passes through to underlying providers which handle their own configs
|
||||
break
|
||||
@@ -488,7 +559,7 @@ function buildProviderOptions(
|
||||
}
|
||||
|
||||
// Map of provider to required environment variable
|
||||
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
export 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",
|
||||
@@ -497,6 +568,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
azure: "AZURE_API_KEY",
|
||||
ollama: null, // No credentials needed for local Ollama
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
aihubmix: "AIHUBMIX_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
siliconflow: "SILICONFLOW_API_KEY",
|
||||
sglang: "SGLANG_API_KEY",
|
||||
@@ -504,6 +576,14 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
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",
|
||||
mimo: "MIMO_API_KEY",
|
||||
atlascloud: "ATLASCLOUD_API_KEY",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,7 +598,15 @@ function detectProvider(): ProviderName | null {
|
||||
// Skip ollama - it doesn't require credentials
|
||||
continue
|
||||
}
|
||||
if (process.env[envVar]) {
|
||||
// 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) {
|
||||
// Azure requires additional config (baseURL or resourceName)
|
||||
if (provider === "azure") {
|
||||
const hasBaseUrl = !!process.env.AZURE_BASE_URL
|
||||
@@ -560,13 +648,26 @@ function validateProviderCredentials(
|
||||
return
|
||||
}
|
||||
|
||||
// Use custom env var name if provided, otherwise use default
|
||||
const requiredVar = customApiKeyEnv || 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.`,
|
||||
// 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]
|
||||
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
|
||||
@@ -586,7 +687,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, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope)
|
||||
* - AI_MODEL: The model ID/name for the selected provider
|
||||
*
|
||||
* Provider-specific env vars:
|
||||
@@ -596,8 +697,9 @@ 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 http://localhost:11434)
|
||||
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
|
||||
* - OPENROUTER_API_KEY: OpenRouter API key
|
||||
* - AIHUBMIX_API_KEY: AIHubMix API key
|
||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||
@@ -611,13 +713,15 @@ 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 and Ollama providers don't require API keys
|
||||
// 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.
|
||||
if (
|
||||
overrides?.baseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
||||
overrides?.provider !== "edgeone" &&
|
||||
overrides?.provider !== "ollama"
|
||||
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
|
||||
) {
|
||||
throw new Error(
|
||||
`API key is required when using a custom base URL. ` +
|
||||
@@ -632,8 +736,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
(overrides?.provider === "vertexai" && overrides?.vertexApiKey))
|
||||
)
|
||||
|
||||
// Use client override if provided, otherwise fall back to env vars
|
||||
const modelId = overrides?.modelId || process.env.AI_MODEL
|
||||
// Use client override if provided, otherwise fall back to env vars.
|
||||
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
|
||||
const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
|
||||
const modelId = overrides?.modelId || envModel
|
||||
|
||||
if (!modelId) {
|
||||
if (isClientOverride) {
|
||||
@@ -683,6 +789,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||
`- AIHUBMIX_API_KEY for AIHubMix\n` +
|
||||
`- AZURE_API_KEY for Azure\n` +
|
||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||
`- SGLANG_API_KEY for SGLang\n` +
|
||||
@@ -788,8 +895,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
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 customProvider = createAnthropic({
|
||||
apiKey,
|
||||
...(authToken ? { authToken } : { apiKey }),
|
||||
baseURL,
|
||||
headers: ANTHROPIC_BETA_HEADERS,
|
||||
})
|
||||
@@ -878,8 +993,19 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "ollama": {
|
||||
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
|
||||
if (baseURL) {
|
||||
const customOllama = createOllama({ baseURL })
|
||||
// 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) {
|
||||
const customOllama = createOllama({
|
||||
...(baseURL && { baseURL }),
|
||||
...(apiKey && {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}),
|
||||
})
|
||||
model = customOllama(modelId)
|
||||
} else {
|
||||
model = ollama(modelId)
|
||||
@@ -906,6 +1032,42 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
break
|
||||
}
|
||||
|
||||
case "aihubmix": {
|
||||
const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY")
|
||||
const serverBaseUrl = resolveBaseUrlEnv(
|
||||
overrides,
|
||||
"AIHUBMIX_BASE_URL",
|
||||
)
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
serverBaseUrl,
|
||||
PROVIDER_INFO.aihubmix.defaultBaseUrl,
|
||||
)
|
||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
||||
|
||||
if (
|
||||
isAihubmixStandardBaseURL(baseURL) ||
|
||||
baseURL === defaultBaseURL
|
||||
) {
|
||||
const aihubmixProvider =
|
||||
overrides?.apiKey || apiKey
|
||||
? createAihubmix({
|
||||
apiKey,
|
||||
appCode: AIHUBMIX_APP_CODE,
|
||||
})
|
||||
: aihubmix
|
||||
model = aihubmixProvider(modelId)
|
||||
} else {
|
||||
const aihubmixCompatibleProvider = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
})
|
||||
model = aihubmixCompatibleProvider.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
||||
const serverBaseUrl = resolveBaseUrlEnv(
|
||||
@@ -1159,9 +1321,104 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
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 "mimo": {
|
||||
const apiKey = resolveApiKey(overrides, "MIMO_API_KEY")
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"),
|
||||
PROVIDER_INFO.mimo?.defaultBaseUrl,
|
||||
)
|
||||
// Use createDeepSeek to properly handle reasoning_content for MiMo
|
||||
// thinking models (e.g., mimo-v2.5-pro). MiMo's API requires
|
||||
// reasoning_content to be passed back during multi-turn tool calls
|
||||
// (returns 400 otherwise), same convention as DeepSeek and Kimi.
|
||||
const mimoProvider = createDeepSeek({ apiKey, baseURL })
|
||||
model = mimoProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "qiniu":
|
||||
case "novita":
|
||||
case "atlascloud": {
|
||||
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`,
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1170,7 +1427,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
providerOptions = customProviderOptions
|
||||
}
|
||||
|
||||
return { model, providerOptions, headers, modelId }
|
||||
return { model, providerOptions, headers, modelId, provider }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1187,51 +1444,19 @@ export function supportsPromptCaching(modelId: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports image/vision input.
|
||||
* Some models silently drop image parts without error (AI SDK warning only).
|
||||
*/
|
||||
export function supportsImageInput(modelId: string): boolean {
|
||||
const lowerModelId = modelId.toLowerCase()
|
||||
|
||||
// Helper to check if model has vision capability indicator
|
||||
const hasVisionIndicator =
|
||||
lowerModelId.includes("vision") || lowerModelId.includes("vl")
|
||||
|
||||
// Models that DON'T support image/vision input (unless vision variant)
|
||||
// Kimi K2 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
|
||||
}
|
||||
|
||||
// DeepSeek text models (not vision variants)
|
||||
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Qwen text models (not vision variants like qwen-vl)
|
||||
if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Default: assume model supports images
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AI model for diagram validation.
|
||||
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
||||
* Throws if the model doesn't support image input.
|
||||
*
|
||||
* Note: we no longer guess whether the model supports image input from its
|
||||
* name — that heuristic misfired on newer models (see issue #874). If a
|
||||
* configured validation model can't handle images, the API call simply errors
|
||||
* and the validate-diagram route falls back to "valid".
|
||||
*/
|
||||
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
||||
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
|
||||
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
|
||||
const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
|
||||
const modelId = process.env.VALIDATION_MODEL || envFallback
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error(
|
||||
@@ -1239,12 +1464,6 @@ export function getValidationModel(): ReturnType<typeof getAIModel>["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
|
||||
}
|
||||
|
||||
79
lib/aihubmix-models.ts
Normal file
79
lib/aihubmix-models.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
|
||||
|
||||
const NON_CHAT_MODEL_TYPES = new Set([
|
||||
"embedding",
|
||||
"image_generation",
|
||||
"rerank",
|
||||
"transcription",
|
||||
"tts",
|
||||
"video",
|
||||
])
|
||||
|
||||
type AihubmixModelListPayload = {
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
type AihubmixModelRecord = {
|
||||
model_id?: unknown
|
||||
types?: unknown
|
||||
}
|
||||
|
||||
function getModelTypes(types: unknown): Set<string> {
|
||||
if (typeof types !== "string") {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
return new Set(
|
||||
types
|
||||
.split(",")
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
|
||||
function isChatModel(record: AihubmixModelRecord): record is {
|
||||
model_id: string
|
||||
types: string
|
||||
} {
|
||||
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const types = getModelTypes(record.types)
|
||||
if (!types.has("llm")) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
|
||||
}
|
||||
|
||||
export function extractAihubmixModelIds(payload: unknown): string[] {
|
||||
const data = (payload as AihubmixModelListPayload)?.data
|
||||
if (!Array.isArray(data)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const modelIds: string[] = []
|
||||
|
||||
for (const item of data) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = item as AihubmixModelRecord
|
||||
if (!isChatModel(record)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const modelId = record.model_id.trim()
|
||||
if (seen.has(modelId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(modelId)
|
||||
modelIds.push(modelId)
|
||||
}
|
||||
|
||||
return modelIds
|
||||
}
|
||||
17
lib/drawio-themes.ts
Normal file
17
lib/drawio-themes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,13 @@
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"siliconflow": "SiliconFlow",
|
||||
"modelscope": "ModelScope"
|
||||
"modelscope": "ModelScope",
|
||||
"minimax": "MiniMax",
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (Xiaomi)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "Describe your diagram or upload a file...",
|
||||
@@ -71,8 +77,7 @@
|
||||
"creativeDescription": "Draw something fun and creative",
|
||||
"cachedNote": "Examples are cached for instant response",
|
||||
"mcpServer": "MCP Server",
|
||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
|
||||
"preview": "PREVIEW"
|
||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -98,10 +103,12 @@
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
|
||||
"drawioStyle": "DrawIO Style",
|
||||
"drawioStyleDescription": "Canvas style:",
|
||||
"switchTo": "Switch to",
|
||||
"minimal": "Minimal",
|
||||
"sketch": "Sketch",
|
||||
"drawioStyleDescription": "Canvas style",
|
||||
"themeDefault": "Default",
|
||||
"themeDark": "Dark",
|
||||
"themeMinimal": "Minimal",
|
||||
"themeSketch": "Sketch",
|
||||
"themeSimple": "Simple",
|
||||
"diagramStyle": "Diagram Style",
|
||||
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
|
||||
"sendShortcut": "Send Shortcut",
|
||||
@@ -121,7 +128,17 @@
|
||||
"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"
|
||||
"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...",
|
||||
"maxOutputTokens": "Max Output Tokens",
|
||||
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
|
||||
"panelVisibility": "Lobby Panels",
|
||||
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
|
||||
"showRecentChats": "Recent Chats",
|
||||
"showMyTemplates": "My Templates",
|
||||
"showQuickExamples": "Quick Examples"
|
||||
},
|
||||
"save": {
|
||||
"title": "Save Diagram",
|
||||
@@ -132,7 +149,8 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG Image",
|
||||
"svg": "SVG Image"
|
||||
"svg": "SVG Image",
|
||||
"xmlsvg": "Editable SVG"
|
||||
},
|
||||
"savedSuccessfully": "Saved successfully!"
|
||||
},
|
||||
@@ -179,8 +197,11 @@
|
||||
"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",
|
||||
@@ -254,6 +275,52 @@
|
||||
"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",
|
||||
@@ -306,6 +373,8 @@
|
||||
"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",
|
||||
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "Models",
|
||||
"customModelId": "Custom model ID...",
|
||||
"allAdded": "All added",
|
||||
@@ -337,6 +406,152 @@
|
||||
"showUnvalidatedModels": "Show unvalidated models",
|
||||
"allModelsShown": "All models are shown (including unvalidated)",
|
||||
"unvalidatedModelWarning": "This model has not been validated",
|
||||
"serverDefaultModel": "Server default model"
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,13 @@
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"siliconflow": "SiliconFlow",
|
||||
"modelscope": "ModelScope"
|
||||
"modelscope": "ModelScope",
|
||||
"minimax": "MiniMax",
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (Xiaomi)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||
@@ -71,8 +77,7 @@
|
||||
"creativeDescription": "楽しくてクリエイティブなものを描く",
|
||||
"cachedNote": "例はキャッシュされ、即座に応答します",
|
||||
"mcpServer": "MCP サーバー",
|
||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
|
||||
"preview": "プレビュー"
|
||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
@@ -98,10 +103,12 @@
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||
"drawioStyle": "DrawIO スタイル",
|
||||
"drawioStyleDescription": "キャンバススタイル:",
|
||||
"switchTo": "切り替え",
|
||||
"minimal": "ミニマル",
|
||||
"sketch": "スケッチ",
|
||||
"drawioStyleDescription": "キャンバススタイル",
|
||||
"themeDefault": "デフォルト",
|
||||
"themeDark": "ダーク",
|
||||
"themeMinimal": "ミニマル",
|
||||
"themeSketch": "スケッチ",
|
||||
"themeSimple": "シンプル",
|
||||
"diagramStyle": "ダイアグラムスタイル",
|
||||
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
|
||||
"sendShortcut": "送信ショートカット",
|
||||
@@ -121,7 +128,17 @@
|
||||
"diagramValidation": "ダイアグラム検証(実験的)",
|
||||
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
|
||||
"enabled": "有効",
|
||||
"disabled": "無効"
|
||||
"disabled": "無効",
|
||||
"customSystemMessage": "カスタムシステムメッセージ",
|
||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||
"maxOutputTokens": "最大出力トークン数",
|
||||
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||
"panelVisibility": "ロビーパネル",
|
||||
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||
"showRecentChats": "最近のチャット",
|
||||
"showMyTemplates": "マイテンプレート",
|
||||
"showQuickExamples": "クイック例"
|
||||
},
|
||||
"save": {
|
||||
"title": "ダイアグラムを保存",
|
||||
@@ -132,7 +149,8 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG 画像",
|
||||
"svg": "SVG 画像"
|
||||
"svg": "SVG 画像",
|
||||
"xmlsvg": "編集可能 SVG"
|
||||
},
|
||||
"savedSuccessfully": "保存完了!"
|
||||
},
|
||||
@@ -179,8 +197,11 @@
|
||||
"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キーを使用",
|
||||
@@ -306,6 +327,8 @@
|
||||
"optional": "(オプション)",
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
|
||||
"models": "モデル",
|
||||
"customModelId": "カスタムモデル ID...",
|
||||
"allAdded": "すべて追加済み",
|
||||
@@ -337,6 +360,198 @@
|
||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||
"serverDefaultModel": "サーバーデフォルトモデル"
|
||||
"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 日のリセット境界に使用するタイムゾーン。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,13 @@
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"siliconflow": "SiliconFlow",
|
||||
"modelscope": "ModelScope"
|
||||
"modelscope": "ModelScope",
|
||||
"minimax": "MiniMax",
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (小米)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的圖表或上傳檔案...",
|
||||
@@ -71,8 +77,7 @@
|
||||
"creativeDescription": "繪製有趣且富有創意的內容",
|
||||
"cachedNote": "範例已快取,可即時回應",
|
||||
"mcpServer": "MCP 伺服器",
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
||||
"preview": "預覽"
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
@@ -98,10 +103,12 @@
|
||||
"theme": "主題",
|
||||
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
|
||||
"drawioStyle": "DrawIO 樣式",
|
||||
"drawioStyleDescription": "畫布樣式:",
|
||||
"switchTo": "切換到",
|
||||
"minimal": "簡約",
|
||||
"sketch": "草圖",
|
||||
"drawioStyleDescription": "畫布樣式",
|
||||
"themeDefault": "預設",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "簡約",
|
||||
"themeSketch": "草圖",
|
||||
"themeSimple": "簡單",
|
||||
"diagramStyle": "圖表樣式",
|
||||
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
|
||||
"sendShortcut": "傳送快捷鍵",
|
||||
@@ -121,7 +128,17 @@
|
||||
"diagramValidation": "圖表驗證(實驗性)",
|
||||
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||
"enabled": "已啟用",
|
||||
"disabled": "已停用"
|
||||
"disabled": "已停用",
|
||||
"customSystemMessage": "自訂系統訊息",
|
||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||
"maxOutputTokens": "最大輸出 token 數",
|
||||
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||
"panelVisibility": "大廳面板",
|
||||
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
"showMyTemplates": "我的範本",
|
||||
"showQuickExamples": "快速範例"
|
||||
},
|
||||
"save": {
|
||||
"title": "儲存圖表",
|
||||
@@ -132,7 +149,8 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG 圖片",
|
||||
"svg": "SVG 圖片"
|
||||
"svg": "SVG 圖片",
|
||||
"xmlsvg": "可編輯 SVG"
|
||||
},
|
||||
"savedSuccessfully": "儲存成功!"
|
||||
},
|
||||
@@ -179,8 +197,11 @@
|
||||
"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": "使用您的金鑰",
|
||||
@@ -254,6 +275,52 @@
|
||||
"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": "截圖中",
|
||||
@@ -306,6 +373,8 @@
|
||||
"optional": "(可選)",
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自訂模型 ID...",
|
||||
"allAdded": "已全部新增",
|
||||
@@ -337,6 +406,152 @@
|
||||
"showUnvalidatedModels": "顯示未驗證的模型",
|
||||
"allModelsShown": "顯示所有模型(包括未驗證的)",
|
||||
"unvalidatedModelWarning": "此模型尚未驗證",
|
||||
"serverDefaultModel": "伺服器預設模型"
|
||||
"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": "每日重置邊界所用的時區。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,13 @@
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"siliconflow": "SiliconFlow",
|
||||
"modelscope": "ModelScope"
|
||||
"modelscope": "ModelScope",
|
||||
"minimax": "MiniMax",
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (小米)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的图表或上传文件...",
|
||||
@@ -71,8 +77,7 @@
|
||||
"creativeDescription": "绘制有趣且富有创意的内容",
|
||||
"cachedNote": "示例已缓存,可即时响应",
|
||||
"mcpServer": "MCP 服务器",
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
||||
"preview": "预览"
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
@@ -98,10 +103,12 @@
|
||||
"theme": "主题",
|
||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||
"drawioStyle": "DrawIO 样式",
|
||||
"drawioStyleDescription": "画布样式:",
|
||||
"switchTo": "切换到",
|
||||
"minimal": "简约",
|
||||
"sketch": "草图",
|
||||
"drawioStyleDescription": "画布样式",
|
||||
"themeDefault": "默认",
|
||||
"themeDark": "深色",
|
||||
"themeMinimal": "简约",
|
||||
"themeSketch": "草图",
|
||||
"themeSimple": "简单",
|
||||
"diagramStyle": "图表样式",
|
||||
"diagramStyleDescription": "切换简约与精致图表输出模式。",
|
||||
"sendShortcut": "发送快捷键",
|
||||
@@ -121,7 +128,17 @@
|
||||
"diagramValidation": "图表验证(实验性)",
|
||||
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用"
|
||||
"disabled": "已禁用",
|
||||
"customSystemMessage": "自定义系统消息",
|
||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||
"maxOutputTokens": "最大输出 token 数",
|
||||
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||
"panelVisibility": "大厅面板",
|
||||
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
"showMyTemplates": "我的模板",
|
||||
"showQuickExamples": "快速示例"
|
||||
},
|
||||
"save": {
|
||||
"title": "保存图表",
|
||||
@@ -132,7 +149,8 @@
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG 图片",
|
||||
"svg": "SVG 图片"
|
||||
"svg": "SVG 图片",
|
||||
"xmlsvg": "可编辑 SVG"
|
||||
},
|
||||
"savedSuccessfully": "保存成功!"
|
||||
},
|
||||
@@ -179,8 +197,11 @@
|
||||
"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": "使用您的密钥",
|
||||
@@ -254,6 +275,52 @@
|
||||
"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": "截图中",
|
||||
@@ -306,6 +373,8 @@
|
||||
"optional": "(可选)",
|
||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自定义模型 ID...",
|
||||
"allAdded": "已全部添加",
|
||||
@@ -337,6 +406,152 @@
|
||||
"showUnvalidatedModels": "显示未验证的模型",
|
||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||
"unvalidatedModelWarning": "此模型尚未验证",
|
||||
"serverDefaultModel": "服务器默认模型"
|
||||
"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": "每日重置边界所用的时区。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
145
lib/output-token-limit.ts
Normal file
145
lib/output-token-limit.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { wrapLanguageModel } from "ai"
|
||||
|
||||
type WrappedModel = ReturnType<typeof wrapLanguageModel>
|
||||
|
||||
/**
|
||||
* Default output budget for a chat turn.
|
||||
*
|
||||
* This has to cover thinking + prose + the tool call, because reasoning models
|
||||
* spend it in that order. Measured on deepseek-v4-flash: refining an existing
|
||||
* diagram burned 16000 tokens on thinking alone and the request ended with
|
||||
* finishReason "length" before display_diagram was ever called (issue #924).
|
||||
* 64000 leaves room for the plan and the XML in one turn.
|
||||
*/
|
||||
export const DEFAULT_MAX_OUTPUT_TOKENS = 64000
|
||||
|
||||
/** Ceiling for the user-supplied override, to catch typos like an extra zero. */
|
||||
export const MAX_OUTPUT_TOKENS_LIMIT = 200000
|
||||
|
||||
/**
|
||||
* Below this a diagram cannot come out whole, so a retry would just produce
|
||||
* truncated XML instead of the provider's error. Better to surface the error.
|
||||
*/
|
||||
const MIN_USABLE_OUTPUT_TOKENS = 1024
|
||||
|
||||
/** Status codes that can carry a complaint about the requested budget. */
|
||||
const BUDGET_REJECTION_STATUSES = new Set([400, 422])
|
||||
|
||||
function usableLimit(value: number): number | null {
|
||||
return value >= MIN_USABLE_OUTPUT_TOKENS ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A budget this large exceeds what some models accept. Providers reject it with a
|
||||
* 400 that names the real limit, so we parse the number out and retry once
|
||||
* instead of failing the turn.
|
||||
*
|
||||
* Formats seen in the wild:
|
||||
* - Bedrock: "The maximum tokens you requested exceeds the model limit of 4096."
|
||||
* - OpenRouter: "This endpoint's maximum context length is 64000 tokens. However,
|
||||
* you requested about 64025 tokens (25 of text input, 64000 in the output)."
|
||||
* Note this one is an input+output ceiling, so the input has to be subtracted.
|
||||
* - Anthropic: "max_tokens: 200000 > 64000, which is the maximum allowed..."
|
||||
* - OpenAI: "This model supports at most 16384 completion tokens"
|
||||
*
|
||||
* Every pattern names tokens explicitly. A generic one (an earlier draft matched
|
||||
* "lower than N") would reinterpret unrelated failures, and retrying on a bogus
|
||||
* number turns a readable error into an empty diagram.
|
||||
*/
|
||||
export function parseOutputTokenLimit(error: unknown): number | null {
|
||||
const err = error as {
|
||||
message?: unknown
|
||||
responseBody?: unknown
|
||||
statusCode?: unknown
|
||||
}
|
||||
|
||||
// An auth or rate-limit failure is not about the budget, so leave it alone.
|
||||
if (
|
||||
typeof err?.statusCode === "number" &&
|
||||
!BUDGET_REJECTION_STATUSES.has(err.statusCode)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = [
|
||||
typeof err?.message === "string" ? err.message : "",
|
||||
typeof err?.responseBody === "string" ? err.responseBody : "",
|
||||
].join(" ")
|
||||
|
||||
if (!text) return null
|
||||
|
||||
// Combined input+output ceiling: subtract the input the provider counted,
|
||||
// plus a small margin because its estimate is approximate.
|
||||
const context = text.match(/maximum context length is (\d+)/i)
|
||||
if (context) {
|
||||
const input = text.match(/(\d+) of text input/i)
|
||||
return usableLimit(
|
||||
Number(context[1]) - (input ? Number(input[1]) : 0) - 1024,
|
||||
)
|
||||
}
|
||||
|
||||
const output =
|
||||
text.match(/model limit of (\d+)/i) ||
|
||||
text.match(/> (\d+), which is the maximum/i) ||
|
||||
text.match(/at most (\d+) completion tokens/i)
|
||||
|
||||
return output ? usableLimit(Number(output[1])) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry the stream once with a smaller budget when the provider rejects the
|
||||
* requested one. Without this, raising the default breaks every model whose
|
||||
* ceiling is below it (measured: bedrock claude-3-haiku 4096, nova-lite 10000,
|
||||
* openrouter deepseek-r1 64000 shared with the input).
|
||||
*/
|
||||
export function withOutputTokenLimitFallback(
|
||||
model: WrappedModel,
|
||||
): WrappedModel {
|
||||
return wrapLanguageModel({
|
||||
model,
|
||||
middleware: {
|
||||
specificationVersion: "v3",
|
||||
async wrapStream({ doStream, params, model: inner }) {
|
||||
try {
|
||||
return await doStream()
|
||||
} catch (error) {
|
||||
const limit = parseOutputTokenLimit(error)
|
||||
const requested = params.maxOutputTokens
|
||||
|
||||
if (!limit || !requested || limit >= requested) throw error
|
||||
|
||||
console.warn(
|
||||
`[maxOutputTokens] ${requested} rejected, retrying with ${limit}`,
|
||||
)
|
||||
return await inner.doStream({
|
||||
...params,
|
||||
maxOutputTokens: limit,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function validBudget(value: string | null | undefined): number | null {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) &&
|
||||
parsed > 0 &&
|
||||
parsed <= MAX_OUTPUT_TOKENS_LIMIT
|
||||
? parsed
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the output budget: user setting (sent as a header so it works in the
|
||||
* desktop app too), then server env, then the default. Both sources go through
|
||||
* the same validation, so a typo in either falls back instead of reaching the
|
||||
* provider.
|
||||
*/
|
||||
export function resolveMaxOutputTokens(headerValue: string | null): number {
|
||||
return (
|
||||
validBudget(headerValue) ??
|
||||
validBudget(process.env.MAX_OUTPUT_TOKENS) ??
|
||||
DEFAULT_MAX_OUTPUT_TOKENS
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,54 @@ function getConfigPath(): string {
|
||||
return path.join(process.cwd(), "ai-models.json")
|
||||
}
|
||||
|
||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||
/**
|
||||
* Synthesize a config from a comma-separated AI_MODEL value (Priority 3 fallback).
|
||||
* Lets users expose multiple models without authoring AI_MODELS_CONFIG / ai-models.json.
|
||||
* Triggers only when AI_MODEL contains a comma AND AI_PROVIDER is set to a known provider.
|
||||
*/
|
||||
function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
|
||||
const aiModel = process.env.AI_MODEL
|
||||
if (!aiModel || !aiModel.includes(",")) return null
|
||||
|
||||
const aiProvider = process.env.AI_PROVIDER
|
||||
if (!aiProvider) {
|
||||
console.warn(
|
||||
"[server-model-config] AI_MODEL contains commas but AI_PROVIDER is not set; " +
|
||||
"skipping multi-model fallback. Set AI_PROVIDER, or use AI_MODELS_CONFIG / ai-models.json.",
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (!(aiProvider in PROVIDER_INFO)) {
|
||||
console.warn(
|
||||
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const models = Array.from(
|
||||
new Set(
|
||||
aiModel
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0),
|
||||
),
|
||||
)
|
||||
if (models.length === 0) return null
|
||||
|
||||
const providerName = aiProvider as ProviderName
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
name: PROVIDER_INFO[providerName]?.label || providerName,
|
||||
provider: providerName,
|
||||
models,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
|
||||
const envConfig = process.env.AI_MODELS_CONFIG
|
||||
if (envConfig && envConfig.trim().length > 0) {
|
||||
@@ -85,15 +132,51 @@ export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig |
|
||||
const json = JSON.parse(jsonStr)
|
||||
return ServerModelsConfigSchema.parse(json)
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") {
|
||||
if (err?.code !== "ENOENT") {
|
||||
console.error(
|
||||
"[server-model-config] Failed to load ai-models.json:",
|
||||
err,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: AI_MODEL with comma-separated values + AI_PROVIDER
|
||||
return configFromCommaSeparatedAiModel()
|
||||
}
|
||||
|
||||
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
|
||||
// Merge in providers managed via the admin panel (settings.json).
|
||||
// Dynamic import to avoid a module-init cycle with lib/admin/providers.
|
||||
let adminConfig: ServerModelsConfig | null = null
|
||||
try {
|
||||
const { adminProvidersToConfig, loadAdminProviders } = await import(
|
||||
"./admin/providers"
|
||||
)
|
||||
const adminProviders = loadAdminProviders()
|
||||
if (adminProviders.length > 0) {
|
||||
adminConfig = adminProvidersToConfig(adminProviders)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[server-model-config] Failed to load ai-models.json:",
|
||||
"[server-model-config] Failed to load admin providers:",
|
||||
err,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!adminConfig || adminConfig.providers.length === 0) return envConfig
|
||||
if (!envConfig) return adminConfig
|
||||
|
||||
// A panel default overrides an env default
|
||||
const adminHasDefault = adminConfig.providers.some((p) => p.default)
|
||||
const envProviders = adminHasDefault
|
||||
? envConfig.providers.map((p) =>
|
||||
p.default ? { ...p, default: undefined } : p,
|
||||
)
|
||||
: envConfig.providers
|
||||
return { providers: [...envProviders, ...adminConfig.providers] }
|
||||
}
|
||||
|
||||
export async function loadFlattenedServerModels(): Promise<
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||
import { nanoid } from "nanoid"
|
||||
import type { Template } from "./template-storage"
|
||||
|
||||
// Constants
|
||||
const DB_NAME = "next-ai-drawio"
|
||||
const DB_VERSION = 1
|
||||
const DB_VERSION = 2
|
||||
const STORE_NAME = "sessions"
|
||||
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
||||
const MAX_SESSIONS = 50
|
||||
@@ -43,6 +44,16 @@ interface ChatSessionDB extends DBSchema {
|
||||
value: ChatSession
|
||||
indexes: { "by-updated": number }
|
||||
}
|
||||
templates: {
|
||||
key: string
|
||||
value: Template
|
||||
indexes: {
|
||||
"by-updated": number
|
||||
"by-pinned": number
|
||||
"by-run-count": number
|
||||
"by-last-used": number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Database singleton
|
||||
@@ -58,7 +69,24 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||
})
|
||||
store.createIndex("by-updated", "updatedAt")
|
||||
}
|
||||
// Future migrations: if (oldVersion < 2) { ... }
|
||||
// Version 2: templates store (added by template-storage.ts)
|
||||
// Note: We also need to include this here to ensure the upgrade
|
||||
// callback properly handles all migrations when opening from this file
|
||||
if (oldVersion < 2) {
|
||||
// Check if templates store already exists (created by template-storage.ts)
|
||||
if (!db.objectStoreNames.contains("templates")) {
|
||||
const templateStore = db.createObjectStore(
|
||||
"templates",
|
||||
{
|
||||
keyPath: "id",
|
||||
},
|
||||
)
|
||||
templateStore.createIndex("by-updated", "updatedAt")
|
||||
templateStore.createIndex("by-pinned", "pinned")
|
||||
templateStore.createIndex("by-run-count", "runCount")
|
||||
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,62 +2,116 @@
|
||||
* SSRF (Server-Side Request Forgery) protection utilities
|
||||
*/
|
||||
|
||||
import { lookup } from "node:dns/promises"
|
||||
|
||||
/**
|
||||
* Check if URL points to private/internal network
|
||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
|
||||
* Works for both user-supplied literal IPs and DNS-resolved addresses.
|
||||
*/
|
||||
export function isPrivateUrl(urlString: string): boolean {
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
|
||||
|
||||
// IPv6
|
||||
if (addr.includes(":")) {
|
||||
if (addr === "::1" || addr === "::") return true
|
||||
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
|
||||
if (
|
||||
addr.startsWith("fc") ||
|
||||
addr.startsWith("fd") ||
|
||||
addr.startsWith("::ffff:")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// link-local (fe80::/10)
|
||||
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
|
||||
if (linkLocal) {
|
||||
const high = parseInt(linkLocal[1], 16)
|
||||
if (high >= 0x80 && high <= 0xbf) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IPv4
|
||||
const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
if (a === 10) return true // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||
if (a === 0) return true // 0.0.0.0/8
|
||||
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 (CGNAT, used by some cloud internal networks)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* String-only check against well-known private hostnames and literal IPs.
|
||||
* Fast path that avoids a DNS lookup for obvious cases.
|
||||
*/
|
||||
function isPrivateHostname(hostname: string): boolean {
|
||||
const host = hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.replace(/\.$/, "")
|
||||
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "::1" ||
|
||||
host === "::"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (host === "169.254.169.254" || host === "metadata.google.internal") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
host.endsWith(".local") ||
|
||||
host.endsWith(".internal") ||
|
||||
host.endsWith(".localhost")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Literal IP supplied directly in the URL
|
||||
return isPrivateIp(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL points to private/internal network.
|
||||
* Blocks: localhost, private IPs, link-local, AWS metadata service.
|
||||
*
|
||||
* Resolves the hostname via DNS and validates every returned address, so
|
||||
* public-looking names that map to internal IPs (e.g. "127-0-0-1.sslip.io")
|
||||
* are caught even though they pass the string-only check.
|
||||
*/
|
||||
export async function isPrivateUrl(urlString: string): Promise<boolean> {
|
||||
try {
|
||||
const url = new URL(urlString)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const hostname = url.hostname
|
||||
|
||||
// Block localhost
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Fast path: obvious string matches and literal IPs.
|
||||
if (isPrivateHostname(hostname)) return true
|
||||
|
||||
// Block AWS/cloud metadata endpoints
|
||||
if (
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for private IPv4 ranges
|
||||
const ipv4Match = hostname.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||
)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
if (a === 10) return true // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".localhost")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
// Resolve DNS and reject if any address is private.
|
||||
const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "")
|
||||
const addresses = await lookup(stripped, { all: true })
|
||||
return addresses.some(({ address }) => isPrivateIp(address))
|
||||
} catch {
|
||||
return true // Invalid URL - block it
|
||||
return true // Invalid URL or DNS failure - block it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether private URLs are allowed (defaults to true)
|
||||
* Set ALLOW_PRIVATE_URLS=false to block private URLs
|
||||
* Read per call so admin-panel changes apply without restart
|
||||
*/
|
||||
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||
export function allowPrivateUrls(): boolean {
|
||||
return process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||
}
|
||||
|
||||
@@ -27,4 +27,15 @@ export const STORAGE_KEYS = {
|
||||
|
||||
// Diagram validation
|
||||
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
|
||||
|
||||
// Custom system message
|
||||
customSystemMessage: "next-ai-draw-io-custom-system-message",
|
||||
|
||||
// Output token budget per turn (empty = server default)
|
||||
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
|
||||
|
||||
// Panel visibility
|
||||
showRecentChats: "next-ai-draw-io-show-recent-chats",
|
||||
showMyTemplates: "next-ai-draw-io-show-my-templates",
|
||||
showQuickExamples: "next-ai-draw-io-show-quick-examples",
|
||||
} as const
|
||||
|
||||
@@ -51,9 +51,9 @@ parameters: {
|
||||
}
|
||||
---Tool4---
|
||||
tool name: get_shape_library
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, etc.) before creating diagrams with cloud/tech icons.
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||
parameters: {
|
||||
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc.
|
||||
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||
}
|
||||
---End of tools---
|
||||
|
||||
@@ -61,7 +61,7 @@ IMPORTANT: Choose the right tool:
|
||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||
- Use get_shape_library for: Discovering available icons/shapes when creating cloud architecture or technical diagrams (call BEFORE display_diagram)
|
||||
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
|
||||
|
||||
Core capabilities:
|
||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||
@@ -92,7 +92,7 @@ Note that:
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||
- Return XML only via tool calls, never in text responses.
|
||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s), call get_shape_library first to discover available icon shapes and their syntax.
|
||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
|
||||
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||
|
||||
When using edit_diagram tool:
|
||||
|
||||
385
lib/template-storage.ts
Normal file
385
lib/template-storage.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
// Constants
|
||||
const DB_NAME = "next-ai-drawio-templates"
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = "templates"
|
||||
|
||||
// Types
|
||||
export interface Template {
|
||||
id: string
|
||||
title: string
|
||||
prompt: string
|
||||
description?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
clickCount: number
|
||||
runCount: number
|
||||
lastUsedAt: number
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export type TemplateCreateInput = Pick<Template, "prompt"> &
|
||||
Partial<
|
||||
Omit<
|
||||
Template,
|
||||
| "id"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "clickCount"
|
||||
| "runCount"
|
||||
| "lastUsedAt"
|
||||
>
|
||||
>
|
||||
|
||||
interface TemplateDB extends DBSchema {
|
||||
templates: {
|
||||
key: string
|
||||
value: Template
|
||||
indexes: {
|
||||
"by-updated": number
|
||||
"by-pinned": number
|
||||
"by-run-count": number
|
||||
"by-last-used": number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default title: first 20 chars of trimmed prompt, with ellipsis if truncated
|
||||
const DEFAULT_TITLE_MAX_LENGTH = 20
|
||||
|
||||
export function generateDefaultTitle(prompt: string): string {
|
||||
const trimmed = prompt.trim()
|
||||
if (trimmed.length <= DEFAULT_TITLE_MAX_LENGTH) return trimmed
|
||||
return trimmed.slice(0, DEFAULT_TITLE_MAX_LENGTH).trim() + "..."
|
||||
}
|
||||
|
||||
// Database singleton
|
||||
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<TemplateDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const templateStore = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: "id",
|
||||
})
|
||||
templateStore.createIndex("by-updated", "updatedAt")
|
||||
templateStore.createIndex("by-pinned", "pinned")
|
||||
templateStore.createIndex("by-run-count", "runCount")
|
||||
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
// Check if IndexedDB is available
|
||||
export function isIndexedDBAvailable(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
return "indexedDB" in window && window.indexedDB !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
export async function getAllTemplates(): Promise<Template[]> {
|
||||
if (!isIndexedDBAvailable()) return []
|
||||
try {
|
||||
const db = await getDB()
|
||||
const templates = await db.getAll(STORE_NAME)
|
||||
return sortTemplates(templates)
|
||||
} catch (error) {
|
||||
console.error("Failed to get templates:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplate(id: string): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
const db = await getDB()
|
||||
return (await db.get(STORE_NAME, id)) || null
|
||||
} catch (error) {
|
||||
console.error("Failed to get template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTemplate(
|
||||
input: TemplateCreateInput,
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
|
||||
const prompt = input.prompt.trim()
|
||||
if (!prompt) return null
|
||||
|
||||
const now = Date.now()
|
||||
const template: Template = {
|
||||
id: nanoid(),
|
||||
title: input.title?.trim() || generateDefaultTitle(prompt),
|
||||
prompt,
|
||||
description: input.description?.trim() || undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: input.pinned ?? false,
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, template)
|
||||
return template
|
||||
} catch (error) {
|
||||
console.error("Failed to create template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTemplate(
|
||||
id: string,
|
||||
updates: Partial<Omit<Template, "id" | "createdAt">>,
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
const db = await getDB()
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
|
||||
const updated: Template = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await db.put(STORE_NAME, updated)
|
||||
return updated
|
||||
} catch (error) {
|
||||
console.error("Failed to update template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<boolean> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.delete(STORE_NAME, id)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to delete template:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateTemplate(
|
||||
id: string,
|
||||
copySuffix = "(copy)",
|
||||
): Promise<Template | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
const db = await getDB()
|
||||
const existing = await db.get(STORE_NAME, id)
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const duplicate: Template = {
|
||||
...existing,
|
||||
id: nanoid(),
|
||||
title: `${existing.title} ${copySuffix}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
clickCount: 0,
|
||||
runCount: 0,
|
||||
lastUsedAt: 0,
|
||||
pinned: false,
|
||||
}
|
||||
await db.put(STORE_NAME, duplicate)
|
||||
return duplicate
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate template:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Usage tracking
|
||||
|
||||
export async function incrementClickCount(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
const db = await getDB()
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
template.clickCount += 1
|
||||
template.updatedAt = Date.now()
|
||||
await db.put(STORE_NAME, template)
|
||||
} catch (error) {
|
||||
console.error("Failed to increment click count:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function incrementRunCount(id: string): Promise<void> {
|
||||
if (!isIndexedDBAvailable()) return
|
||||
try {
|
||||
const db = await getDB()
|
||||
const template = await db.get(STORE_NAME, id)
|
||||
if (!template) return
|
||||
const now = Date.now()
|
||||
template.runCount += 1
|
||||
template.lastUsedAt = now
|
||||
template.updatedAt = now
|
||||
await db.put(STORE_NAME, template)
|
||||
} catch (error) {
|
||||
console.error("Failed to increment run count:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Search
|
||||
|
||||
export function searchTemplates(
|
||||
templates: Template[],
|
||||
query: string,
|
||||
): Template[] {
|
||||
if (!query.trim()) return templates
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return templates.filter((t) => {
|
||||
const titleMatch = t.title.toLowerCase().includes(lowerQuery)
|
||||
const descMatch =
|
||||
t.description?.toLowerCase().includes(lowerQuery) ?? false
|
||||
return titleMatch || descMatch
|
||||
})
|
||||
}
|
||||
|
||||
// Sorting
|
||||
|
||||
export function sortTemplates(templates: Template[]): Template[] {
|
||||
return [...templates].sort((a, b) => {
|
||||
// pinned desc
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
// runCount desc
|
||||
if (a.runCount !== b.runCount) return b.runCount - a.runCount
|
||||
// lastUsedAt desc
|
||||
if (a.lastUsedAt !== b.lastUsedAt) return b.lastUsedAt - a.lastUsedAt
|
||||
// updatedAt desc
|
||||
return b.updatedAt - a.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
// Import / Export
|
||||
|
||||
export const TEMPLATE_EXPORT_SCHEMA_VERSION = 1
|
||||
|
||||
export interface TemplateExportData {
|
||||
schemaVersion: number
|
||||
exportedAt: number
|
||||
templates: Template[]
|
||||
}
|
||||
|
||||
export function exportTemplates(templates: Template[]): TemplateExportData {
|
||||
return {
|
||||
schemaVersion: TEMPLATE_EXPORT_SCHEMA_VERSION,
|
||||
exportedAt: Date.now(),
|
||||
templates,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateImportData(data: unknown): {
|
||||
valid: boolean
|
||||
error?: string
|
||||
} {
|
||||
if (!data || typeof data !== "object") {
|
||||
return { valid: false, error: "Invalid data: expected an object" }
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>
|
||||
|
||||
if (typeof obj.schemaVersion !== "number") {
|
||||
return { valid: false, error: "Missing or invalid schemaVersion" }
|
||||
}
|
||||
|
||||
if (!Array.isArray(obj.templates)) {
|
||||
return { valid: false, error: "Missing or invalid templates array" }
|
||||
}
|
||||
|
||||
for (let i = 0; i < obj.templates.length; i++) {
|
||||
const t = obj.templates[i]
|
||||
if (!t || typeof t !== "object") {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Template at index ${i} is not an object`,
|
||||
}
|
||||
}
|
||||
const template = t as Record<string, unknown>
|
||||
if (typeof template.prompt !== "string" || !template.prompt.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Template at index ${i} has missing or empty prompt`,
|
||||
}
|
||||
}
|
||||
if (typeof template.title !== "string" || !template.title.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Template at index ${i} has missing or empty title`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export async function importTemplates(
|
||||
templates: Template[],
|
||||
existingTemplates: Template[],
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
|
||||
const existingKeys = new Set(
|
||||
existingTemplates.map((t) => `${t.title}|||${t.prompt}`),
|
||||
)
|
||||
|
||||
for (const t of templates) {
|
||||
const key = `${t.title}|||${t.prompt}`
|
||||
if (existingKeys.has(key)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const newTemplate: Template = {
|
||||
id: nanoid(),
|
||||
title:
|
||||
String(t.title || "").trim() ||
|
||||
generateDefaultTitle(String(t.prompt || "")),
|
||||
prompt: String(t.prompt || "").trim(),
|
||||
description: t.description ? String(t.description) : undefined,
|
||||
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
|
||||
updatedAt: now,
|
||||
clickCount: typeof t.clickCount === "number" ? t.clickCount : 0,
|
||||
runCount: typeof t.runCount === "number" ? t.runCount : 0,
|
||||
lastUsedAt: typeof t.lastUsedAt === "number" ? t.lastUsedAt : 0,
|
||||
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
|
||||
}
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, newTemplate)
|
||||
existingKeys.add(key)
|
||||
imported++
|
||||
} catch (error) {
|
||||
console.error("Failed to import template:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type ProviderName =
|
||||
| "bedrock"
|
||||
| "ollama"
|
||||
| "openrouter"
|
||||
| "aihubmix"
|
||||
| "deepseek"
|
||||
| "siliconflow"
|
||||
| "sglang"
|
||||
@@ -16,6 +17,14 @@ export type ProviderName =
|
||||
| "edgeone"
|
||||
| "doubao"
|
||||
| "modelscope"
|
||||
| "glm"
|
||||
| "qwen"
|
||||
| "qiniu"
|
||||
| "kimi"
|
||||
| "minimax"
|
||||
| "novita"
|
||||
| "mimo"
|
||||
| "atlascloud"
|
||||
|
||||
// Individual model configuration
|
||||
export interface ModelConfig {
|
||||
@@ -79,6 +88,38 @@ export interface FlattenedModel {
|
||||
baseUrlEnv?: string
|
||||
}
|
||||
|
||||
// Providers whose server credentials live in fixed env vars
|
||||
// (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no
|
||||
// apiKeyEnv redirection support — their credentials are global
|
||||
export const FIXED_CRED_PROVIDERS: ProviderName[] = [
|
||||
"bedrock",
|
||||
"vertexai",
|
||||
"ollama",
|
||||
]
|
||||
|
||||
// Map provider names to models.dev logo names
|
||||
export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
google: "google",
|
||||
azure: "azure",
|
||||
bedrock: "amazon-bedrock",
|
||||
openrouter: "openrouter",
|
||||
aihubmix: "aihubmix",
|
||||
deepseek: "deepseek",
|
||||
siliconflow: "siliconflow",
|
||||
sglang: "openai", // SGLang is OpenAI-compatible
|
||||
gateway: "vercel",
|
||||
edgeone: "tencent-cloud",
|
||||
vertexai: "google",
|
||||
doubao: "bytedance",
|
||||
modelscope: "modelscope",
|
||||
minimax: "minimax",
|
||||
novita: "novita",
|
||||
mimo: "xiaomi",
|
||||
atlascloud: "openai",
|
||||
}
|
||||
|
||||
// Provider metadata
|
||||
export const PROVIDER_INFO: Record<
|
||||
ProviderName,
|
||||
@@ -104,12 +145,16 @@ export const PROVIDER_INFO: Record<
|
||||
bedrock: { label: "Amazon Bedrock" },
|
||||
ollama: {
|
||||
label: "Ollama",
|
||||
defaultBaseUrl: "http://localhost:11434",
|
||||
defaultBaseUrl: "https://ollama.com/api",
|
||||
},
|
||||
openrouter: {
|
||||
label: "OpenRouter",
|
||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
aihubmix: {
|
||||
label: "AIHubMix",
|
||||
defaultBaseUrl: "https://aihubmix.com/v1",
|
||||
},
|
||||
deepseek: {
|
||||
label: "DeepSeek",
|
||||
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||
@@ -135,168 +180,277 @@ export const PROVIDER_INFO: Record<
|
||||
label: "ModelScope",
|
||||
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
||||
},
|
||||
glm: {
|
||||
label: "GLM (Zhipu)",
|
||||
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
},
|
||||
qwen: {
|
||||
label: "Qwen (Alibaba)",
|
||||
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
qiniu: {
|
||||
label: "Qiniu",
|
||||
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
||||
},
|
||||
kimi: {
|
||||
label: "Kimi (Moonshot)",
|
||||
defaultBaseUrl: "https://api.moonshot.cn/v1",
|
||||
},
|
||||
minimax: {
|
||||
label: "MiniMax",
|
||||
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
|
||||
},
|
||||
novita: {
|
||||
label: "Novita AI",
|
||||
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||
},
|
||||
mimo: {
|
||||
label: "MiMo (Xiaomi)",
|
||||
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
||||
},
|
||||
atlascloud: {
|
||||
label: "Atlas Cloud",
|
||||
defaultBaseUrl: "https://api.atlascloud.ai/v1",
|
||||
},
|
||||
}
|
||||
|
||||
// Suggested models per provider for quick add
|
||||
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
openai: [
|
||||
"gpt-5.2-pro",
|
||||
"gpt-5.2-chat-latest",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-chat-latest",
|
||||
"gpt-5.1",
|
||||
"gpt-5-pro",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-codex",
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5-codex-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
anthropic: [
|
||||
// Claude 4.5 series (latest)
|
||||
"claude-opus-4-5-20250514",
|
||||
"claude-sonnet-4-5-20250514",
|
||||
// Claude 4 series
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-20250514",
|
||||
// Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
// Claude 4.5 series
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
// Claude 3.7 series
|
||||
"claude-3-7-sonnet-20250219",
|
||||
// Claude 3.5 series
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
// Claude 3 series
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
],
|
||||
google: [
|
||||
// Gemini 3 series
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
// Gemini 2.5 series
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
// Gemini 2.0 series
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-flash-lite",
|
||||
// Gemini 1.5 series
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
// Legacy
|
||||
"gemini-pro",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
vertexai: [
|
||||
// Gemini 3 series
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite",
|
||||
// Gemini 2.5 series
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
// Gemini 2.0 series
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
// Gemini 1.5 series
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
azure: [
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.1",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
],
|
||||
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||
bedrock: [
|
||||
// Anthropic Claude
|
||||
"anthropic.claude-opus-4-5-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-4-7",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-opus-4-6-v1",
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"anthropic.claude-3-opus-20240229-v1:0",
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||
// Amazon Nova
|
||||
"amazon.nova-2-lite-v1:0",
|
||||
"amazon.nova-premier-v1:0",
|
||||
"amazon.nova-pro-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"amazon.nova-micro-v1:0",
|
||||
// Meta Llama
|
||||
"meta.llama4-maverick-17b-instruct-v1:0",
|
||||
"meta.llama4-scout-17b-instruct-v1:0",
|
||||
"meta.llama3-3-70b-instruct-v1:0",
|
||||
"meta.llama3-1-405b-instruct-v1:0",
|
||||
"meta.llama3-1-70b-instruct-v1:0",
|
||||
// Mistral
|
||||
"mistral.mistral-large-2411-v1:0",
|
||||
"mistral.mistral-small-2503-v1:0",
|
||||
"mistral.mistral-large-3-675b-instruct",
|
||||
"mistral.pixtral-large-2502-v1:0",
|
||||
],
|
||||
openrouter: [
|
||||
// Anthropic
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-opus-4",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3.5-haiku",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
// OpenAI
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.4-mini",
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/o1",
|
||||
"openai/o3-mini",
|
||||
// Google
|
||||
"google/gemini-2.5-pro",
|
||||
"google/gemini-2.5-flash",
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-2.5-flash-lite",
|
||||
// xAI
|
||||
"x-ai/grok-4.3",
|
||||
// Meta Llama
|
||||
"meta-llama/llama-4-maverick",
|
||||
"meta-llama/llama-4-scout",
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"meta-llama/llama-3.1-405b-instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
// DeepSeek
|
||||
"deepseek/deepseek-chat",
|
||||
"deepseek/deepseek-r1",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v3.2",
|
||||
// Qwen
|
||||
"qwen/qwen-2.5-72b-instruct",
|
||||
"qwen/qwen3.7-max",
|
||||
"qwen/qwen3-coder",
|
||||
// MiniMax
|
||||
"minimax/minimax-m3",
|
||||
],
|
||||
aihubmix: [
|
||||
// Fallback list. The settings UI loads the live model list from AIHubMix when available.
|
||||
// Anthropic Claude
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
// OpenAI
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.4",
|
||||
// Google Gemini
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
// DeepSeek
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
// Qwen
|
||||
"qwen3.7-max",
|
||||
"qwen3-coder-next",
|
||||
// Z.ai
|
||||
"glm-5.1",
|
||||
// Moonshot AI
|
||||
"kimi-k2.6",
|
||||
// MiniMax
|
||||
"minimax-m3",
|
||||
// xAI
|
||||
"grok-4.3",
|
||||
// Baidu
|
||||
"ernie-5.1",
|
||||
// Mistral
|
||||
"mistral-large-3",
|
||||
// Meta
|
||||
"llama-4-maverick",
|
||||
],
|
||||
deepseek: [
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
],
|
||||
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
|
||||
siliconflow: [
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-V3",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
"deepseek-ai/DeepSeek-V2.5",
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"deepseek-ai/DeepSeek-V4-Flash",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
// MiniMax
|
||||
"MiniMaxAI/MiniMax-M3",
|
||||
// Moonshot
|
||||
"moonshotai/Kimi-K2.6",
|
||||
// Z.ai
|
||||
"zai-org/GLM-5",
|
||||
// Qwen
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
"Qwen/Qwen2.5-32B-Instruct",
|
||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||
"Qwen/Qwen2.5-7B-Instruct",
|
||||
"Qwen/Qwen2-VL-72B-Instruct",
|
||||
"Qwen/Qwen3.6-35B-A3B",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
"Qwen/Qwen3-30B-A3B-Instruct-2507",
|
||||
"Qwen/Qwen3-VL-32B-Instruct",
|
||||
// OpenAI open-weights
|
||||
"openai/gpt-oss-120b",
|
||||
],
|
||||
sglang: [
|
||||
// SGLang is OpenAI-compatible, models depend on deployment
|
||||
"default",
|
||||
],
|
||||
gateway: [
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-4o-mini",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-3-5-sonnet",
|
||||
"google/gemini-2.0-flash",
|
||||
"openai/gpt-5.5",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"xai/grok-4.3",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5.4-mini",
|
||||
],
|
||||
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
|
||||
doubao: [
|
||||
// ByteDance Doubao models
|
||||
"doubao-1.5-thinking-pro-250415",
|
||||
"doubao-1.5-thinking-pro-m-250428",
|
||||
"doubao-1.5-pro-32k-250115",
|
||||
"doubao-1.5-pro-256k-250115",
|
||||
"doubao-pro-32k-241215",
|
||||
"doubao-pro-256k-241215",
|
||||
// ByteDance Doubao models (Volcengine Ark IDs use dash form)
|
||||
"doubao-seed-2-0-pro-260215",
|
||||
"doubao-seed-2-0-lite-260428",
|
||||
"doubao-seed-2-0-mini-260428",
|
||||
"doubao-seed-1-8-251228",
|
||||
"doubao-seed-1-6-251015",
|
||||
"doubao-seed-1-6-flash-250828",
|
||||
"doubao-seed-1-6-vision-250815",
|
||||
"doubao-1-5-pro-32k-250115",
|
||||
"doubao-1-5-lite-32k-250115",
|
||||
],
|
||||
modelscope: [
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
// Qwen
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
"Qwen/Qwen2.5-32B-Instruct",
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
||||
"Qwen/Qwen3-32B",
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
],
|
||||
minimax: [
|
||||
// MiniMax models (Anthropic-compatible API)
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
],
|
||||
novita: [
|
||||
// Novita AI models (OpenAI-compatible API)
|
||||
"minimax/minimax-m3",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"zai-org/glm-5.1",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
],
|
||||
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
|
||||
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
|
||||
}
|
||||
|
||||
// Helper to generate UUID
|
||||
|
||||
14106
package-lock.json
generated
14106
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-ai-draw-io",
|
||||
"version": "0.4.12",
|
||||
"version": "0.4.16",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
@@ -40,18 +40,19 @@
|
||||
"@ai-sdk/google-vertex": "^4.0.16",
|
||||
"@ai-sdk/openai": "^3.0.0",
|
||||
"@ai-sdk/react": "^3.0.1",
|
||||
"@aihubmix/ai-sdk-provider": "^2.1.0",
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@extractus/article-extractor": "^8.0.18",
|
||||
"@formatjs/intl-localematcher": "^0.7.2",
|
||||
"@formatjs/intl-localematcher": "^0.8.0",
|
||||
"@langfuse/client": "^4.4.9",
|
||||
"@langfuse/otel": "^4.4.4",
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@opennextjs/cloudflare": "1.16.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -72,12 +73,12 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"jsonrepair": "^3.13.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.23.25",
|
||||
"nanoid": "^5.0.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.0.7",
|
||||
"ollama-ai-provider-v2": "^2.0.0",
|
||||
"ollama-ai-provider-v2": "^3.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.1.2",
|
||||
@@ -108,7 +109,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@biomejs/biome": "^2.3.10",
|
||||
"@biomejs/biome": "2.5.7",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -127,8 +128,8 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "9.39.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
|
||||
@@ -116,9 +116,14 @@ Use the standard MCP configuration with:
|
||||
|------|-------------|
|
||||
| `start_session` | Opens browser with real-time diagram preview |
|
||||
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
||||
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
||||
| `get_diagram` | Get the current diagram XML |
|
||||
| `export_diagram` | Save diagram to a `.drawio` file |
|
||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
|
||||
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
||||
| `add_page` | Append a new page without touching existing ones |
|
||||
| `rename_page` | Rename a page |
|
||||
| `delete_page` | Delete a page (refuses to delete the last one) |
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
1722
packages/mcp-server/package-lock.json
generated
1722
packages/mcp-server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.1.16",
|
||||
"version": "0.2.3",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -11,6 +11,8 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -44,7 +46,8 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* ID-based diagram operations
|
||||
* Copied from lib/utils.ts to avoid cross-package imports
|
||||
*
|
||||
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
|
||||
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
|
||||
* optional pageSelector identifies which page to edit; when omitted, the
|
||||
* first page is targeted (the "active page by convention" — see pages.ts).
|
||||
*/
|
||||
|
||||
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
|
||||
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
@@ -22,15 +28,18 @@ export interface ApplyOperationsResult {
|
||||
|
||||
/**
|
||||
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
||||
* This replaces the text-matching approach with direct DOM manipulation.
|
||||
*
|
||||
* @param xmlContent - The full mxfile XML content
|
||||
* @param operations - Array of operations to apply
|
||||
* @returns Object with result XML and any errors
|
||||
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
|
||||
* a full <mxfile> with one or more <diagram> children.
|
||||
* @param operations - Array of operations to apply.
|
||||
* @param pageSelector - Optional page selector for multi-page docs. Defaults
|
||||
* to the first page.
|
||||
* @returns Object with result XML (same shape as input) and any per-op errors.
|
||||
*/
|
||||
export function applyDiagramOperations(
|
||||
xmlContent: string,
|
||||
operations: DiagramOperation[],
|
||||
pageSelector?: PageSelector,
|
||||
): ApplyOperationsResult {
|
||||
const errors: OperationError[] = []
|
||||
|
||||
@@ -53,22 +62,75 @@ export function applyDiagramOperations(
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root element (inside mxGraphModel)
|
||||
const root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
// Locate the <root> element to operate on.
|
||||
//
|
||||
// - For <mxfile> input: resolve the page via pageSelector, then dive into
|
||||
// its <root>. This scopes querySelectorAll calls below to one page so
|
||||
// cells on other pages aren't accidentally matched.
|
||||
// - For bare <mxGraphModel> input: use the document's only <root>.
|
||||
let root: Element | null
|
||||
if (doc.documentElement?.tagName === "mxfile") {
|
||||
const found = findPageElement(doc as unknown as Document, pageSelector)
|
||||
if (!found) {
|
||||
const selDesc = hasPageSelector(pageSelector)
|
||||
? ` matching selector ${JSON.stringify(pageSelector)}`
|
||||
: ""
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `Page${selDesc} not found in <mxfile>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
root = found.element.querySelector("root")
|
||||
if (!root) {
|
||||
const pageId =
|
||||
found.element.getAttribute("id") || `(index ${found.index})`
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `Page "${pageId}" has no <root> element`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (hasPageSelector(pageSelector)) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message:
|
||||
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of cell IDs to elements
|
||||
// Build a map of cell IDs to elements (scoped to the resolved page).
|
||||
const cellMap = new Map<string, Element>()
|
||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
@@ -208,7 +270,9 @@ export function applyDiagramOperations(
|
||||
cellsToDelete.add(cellId)
|
||||
|
||||
// Find children (cells where parent === cellId)
|
||||
const children = root.querySelectorAll(
|
||||
// Scoped to `root` so other pages' cells with the same parent id
|
||||
// (notably "1") are never touched.
|
||||
const children = root!.querySelectorAll(
|
||||
`mxCell[parent="${cellId}"]`,
|
||||
)
|
||||
children.forEach((child) => {
|
||||
|
||||
102
packages/mcp-server/src/edit-gate.ts
Normal file
102
packages/mcp-server/src/edit-gate.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Workflow gate for edit_diagram.
|
||||
*
|
||||
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
|
||||
* clients, see #885), we compare content: `lastSeenXml` is the state-store
|
||||
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
|
||||
* edit_diagram / page CRUD). The store only changes on server writes or
|
||||
* browser pushes (user autosave, sync exports), so if the live store still
|
||||
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
|
||||
* edit is safe no matter how much time passed.
|
||||
*
|
||||
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
|
||||
* document when it pushes state back (different attribute order, pretty-
|
||||
* printed whitespace, regenerated diagram ids, viewport attributes like
|
||||
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
|
||||
* is a user edit, so the fingerprint keeps only what a user can actually
|
||||
* change: the set of pages, each page's name, and each page's cell tree
|
||||
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
|
||||
*/
|
||||
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
|
||||
|
||||
export type EditGateResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "no-context" | "stale" }
|
||||
|
||||
/**
|
||||
* Canonical serialisation of an element subtree: tag + attributes sorted by
|
||||
* name + child elements in order + non-whitespace text. Whitespace-only text
|
||||
* nodes (pretty-printing) are dropped.
|
||||
*/
|
||||
function canonicalizeElement(el: Element): string {
|
||||
const attrs = Array.from(el.attributes)
|
||||
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
|
||||
.sort()
|
||||
.join(" ")
|
||||
let children = ""
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
if (child.nodeType === 1) {
|
||||
children += canonicalizeElement(child as Element)
|
||||
} else if (child.nodeType === 3 || child.nodeType === 4) {
|
||||
const text = (child.textContent ?? "").trim()
|
||||
if (text) children += JSON.stringify(text)
|
||||
}
|
||||
}
|
||||
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural fingerprint of a diagram document: page names + each page's
|
||||
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
|
||||
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
|
||||
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
|
||||
* Unparseable input falls back to the trimmed raw string, degrading to the
|
||||
* plain string comparison.
|
||||
*
|
||||
* `includeNames=false` drops page names from the fingerprint — used when the
|
||||
* other side of a comparison is a bare <mxGraphModel>, which carries no page
|
||||
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
|
||||
* any real page name).
|
||||
*/
|
||||
export function contentFingerprint(xml: string, includeNames = true): string {
|
||||
const normalized = normalizeToMxfile(xml)
|
||||
const doc = normalized ? parseMxfile(normalized) : null
|
||||
if (!doc) return xml.trim()
|
||||
const pages: string[] = []
|
||||
doc.querySelectorAll("diagram").forEach((d) => {
|
||||
const name = includeNames ? d.getAttribute("name") || "" : ""
|
||||
const root = d.querySelector("root")
|
||||
// No <root> means the page content is not plain XML (e.g. draw.io's
|
||||
// compressed format) — fingerprint the raw text instead.
|
||||
const body = root
|
||||
? canonicalizeElement(root)
|
||||
: (d.textContent || "").trim()
|
||||
pages.push(`${name}=${body}`)
|
||||
})
|
||||
return pages.join("\n")
|
||||
}
|
||||
|
||||
export function checkEditGate(
|
||||
lastSeenXml: string,
|
||||
liveXml: string,
|
||||
): EditGateResult {
|
||||
// Model never fetched or produced any diagram state in this session.
|
||||
if (!lastSeenXml) return { ok: false, reason: "no-context" }
|
||||
// Browser state moved since the model last looked (e.g. manual user
|
||||
// edits): force a re-fetch so update/delete operations don't build on
|
||||
// stale cell contents. An empty liveXml means the store has no entry to
|
||||
// compare against, so there is nothing newer to have missed.
|
||||
if (liveXml && liveXml !== lastSeenXml) {
|
||||
// A bare <mxGraphModel> on either side carries no page name, so
|
||||
// comparing names would mismatch against anything not called
|
||||
// "Page-1". Compare cell trees only in that case.
|
||||
const includeNames =
|
||||
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
|
||||
if (
|
||||
contentFingerprint(liveXml, includeNames) !==
|
||||
contentFingerprint(lastSeenXml, includeNames)
|
||||
)
|
||||
return { ok: false, reason: "stale" }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -4,6 +4,29 @@
|
||||
*/
|
||||
|
||||
import http from "node:http"
|
||||
|
||||
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
||||
|
||||
function readBody(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
cb: (body: string) => void,
|
||||
): void {
|
||||
let body = ""
|
||||
let size = 0
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
res.writeHead(413, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "Payload too large" }))
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
body += chunk
|
||||
})
|
||||
req.on("end", () => cb(body))
|
||||
}
|
||||
|
||||
import {
|
||||
addHistory,
|
||||
clearHistory,
|
||||
@@ -70,6 +93,7 @@ interface SessionState {
|
||||
svg?: string // Cached SVG from last browser save
|
||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
||||
exportXml?: string // Single-page projection to load before a page-targeted export
|
||||
exportData?: string // Base64/SVG data returned by browser after export
|
||||
}
|
||||
|
||||
@@ -94,12 +118,37 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||
exportXml: existing?.exportXml, // Preserve pending projection
|
||||
exportData: existing?.exportData, // Preserve export result
|
||||
})
|
||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||
return newVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the browser bridge to export the current diagram as png/svg.
|
||||
*
|
||||
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
|
||||
* first, waits for draw.io's own load event, exports, then reloads the
|
||||
* session's real document — so a page-targeted export never mutates the
|
||||
* canonical session state and needs no fixed-delay guessing on the server.
|
||||
*
|
||||
* Returns false when the session is unknown. Callers should then poll
|
||||
* `getState(sessionId)?.exportData` for the result.
|
||||
*/
|
||||
export function requestExport(
|
||||
sessionId: string,
|
||||
format: "png" | "svg",
|
||||
projectionXml?: string,
|
||||
): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (!state) return false
|
||||
state.exportData = undefined
|
||||
state.exportXml = projectionXml
|
||||
state.exportFormat = format
|
||||
return true
|
||||
}
|
||||
|
||||
export function requestSync(sessionId: string): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) {
|
||||
@@ -155,7 +204,7 @@ export function startHttpServer(port = 6002): Promise<number> {
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, () => {
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
serverPort = port
|
||||
log.info(`HTTP server running on http://localhost:${port}`)
|
||||
resolve(port)
|
||||
@@ -198,9 +247,12 @@ function handleRequest(
|
||||
): void {
|
||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
const requestOrigin = req.headers.origin
|
||||
if (requestOrigin === `http://localhost:${serverPort}`) {
|
||||
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
@@ -260,14 +312,11 @@ function handleStateApi(
|
||||
version: state?.version || 0,
|
||||
syncRequested: !!state?.syncRequested,
|
||||
exportFormat: state?.exportFormat || null,
|
||||
exportXml: state?.exportXml || null,
|
||||
}),
|
||||
)
|
||||
} else if (req.method === "POST") {
|
||||
let body = ""
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on("end", () => {
|
||||
readBody(req, res, (body) => {
|
||||
try {
|
||||
const data = JSON.parse(body)
|
||||
const { sessionId } = data
|
||||
@@ -283,6 +332,7 @@ function handleStateApi(
|
||||
if (state) {
|
||||
state.exportData = data.exportData
|
||||
state.exportFormat = undefined
|
||||
state.exportXml = undefined
|
||||
log.debug(
|
||||
`Export data received for session=${sessionId}`,
|
||||
)
|
||||
@@ -344,11 +394,7 @@ function handleRestoreApi(
|
||||
return
|
||||
}
|
||||
|
||||
let body = ""
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on("end", () => {
|
||||
readBody(req, res, (body) => {
|
||||
try {
|
||||
const { sessionId, index } = JSON.parse(body)
|
||||
if (!sessionId || index === undefined) {
|
||||
@@ -390,11 +436,7 @@ function handleHistorySvgApi(
|
||||
return
|
||||
}
|
||||
|
||||
let body = ""
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on("end", () => {
|
||||
readBody(req, res, (body) => {
|
||||
try {
|
||||
const { sessionId, svg } = JSON.parse(body)
|
||||
if (!sessionId || !svg) {
|
||||
@@ -661,6 +703,8 @@ function getHtmlPage(sessionId: string): string {
|
||||
let pendingSvgExport = null;
|
||||
let pendingAiSvg = false;
|
||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
||||
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||
@@ -670,6 +714,10 @@ function getHtmlPage(sessionId: string): string {
|
||||
isReady = true;
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||
// Ignore autosave while a single-page projection is on screen
|
||||
// for a page-targeted export — otherwise we'd push the
|
||||
// transient projection back as the canonical session state.
|
||||
if (projectionExportActive) return;
|
||||
// Request SVG export, then push state with SVG
|
||||
pendingSvgExport = msg.xml;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
@@ -690,6 +738,9 @@ function getHtmlPage(sessionId: string): string {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, exportData: d })
|
||||
}).catch(() => {});
|
||||
// Page-targeted export: restore the user's real
|
||||
// multi-page document now that we have the image.
|
||||
restoreFromProjection();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -747,6 +798,22 @@ function getHtmlPage(sessionId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the user's real document after a page-targeted projection
|
||||
// export. If we never captured one (lastXml was null at projection
|
||||
// start), fall back to forcing a reload from the server on the next
|
||||
// poll by rewinding currentVersion — never leave the iframe stuck on
|
||||
// the transient projection.
|
||||
function restoreFromProjection() {
|
||||
if (!projectionExportActive) return;
|
||||
projectionExportActive = false;
|
||||
if (projectionRestoreXml) {
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
|
||||
projectionRestoreXml = null;
|
||||
} else {
|
||||
currentVersion = -1; // force the next poll to reload from server
|
||||
}
|
||||
}
|
||||
|
||||
async function pushState(xml, svg = '') {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
@@ -772,20 +839,54 @@ function getHtmlPage(sessionId: string): string {
|
||||
pendingSyncExport = true;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||
}
|
||||
// Load new diagram from server (before export, so we export latest)
|
||||
if (s.version > currentVersion && s.xml) {
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// While a page-targeted projection is on screen, skip the reload
|
||||
// so it doesn't fight the projection — and leave currentVersion
|
||||
// unadvanced so this bump is re-detected and applied once the
|
||||
// real document is restored.
|
||||
if (s.version > currentVersion && s.xml && !projectionExportActive) {
|
||||
currentVersion = s.version;
|
||||
loadDiagram(s.xml, true);
|
||||
}
|
||||
// Handle export request from MCP server (png/svg) - after version update
|
||||
// Handle export request from MCP server (png/svg).
|
||||
//
|
||||
// Plain export: capture whatever tab is currently displayed.
|
||||
//
|
||||
// Page-targeted export: the server sends a single-page <mxfile>
|
||||
// projection in s.exportXml. We load it into the iframe, let
|
||||
// draw.io render it, export, then reload the user's real
|
||||
// document — all browser-side. The canonical session state is
|
||||
// never mutated, so there is no server-side restore race and no
|
||||
// dependence on poll timing. autosave is suppressed while the
|
||||
// projection is showing (see projectionExportActive guard).
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const exportOpts = s.exportFormat === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
// Timeout: reset if draw.io never responds
|
||||
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
||||
const fireExport = () => {
|
||||
const exportOpts = pendingMcpExport === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
// Stash the real document so we can restore after export.
|
||||
projectionRestoreXml = lastXml;
|
||||
projectionExportActive = true;
|
||||
// Load the projection without touching lastXml/server state.
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
||||
// Let draw.io render the loaded page before exporting
|
||||
// (same proven settle delay as the AI-preview path).
|
||||
setTimeout(fireExport, 600);
|
||||
} else {
|
||||
fireExport();
|
||||
}
|
||||
// Timeout: reset if draw.io never responds, and restore the
|
||||
// real document if a projection was left showing.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport) {
|
||||
pendingMcpExport = null;
|
||||
restoreFromProjection();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -825,7 +926,11 @@ function getHtmlPage(sessionId: string): string {
|
||||
saveConfirmBtn.textContent = 'Exporting...';
|
||||
|
||||
if (format === 'drawio') {
|
||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
|
||||
// session.xml is canonically <mxfile> after the multi-page refactor,
|
||||
// so no wrapper injection is needed. The legacy fallback below
|
||||
// remains only for documents that somehow slipped past
|
||||
// normalisation (e.g. an older session loaded from external state).
|
||||
let xmlData = lastXml || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
101
packages/mcp-server/src/load-diagram.ts
Normal file
101
packages/mcp-server/src/load-diagram.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* File-loading helpers for the load_diagram tool.
|
||||
*
|
||||
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
|
||||
* <mxGraphModel> either as plain XML or — draw.io's default save format —
|
||||
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
|
||||
* diagram's text content. The rest of the server assumes plain XML inside
|
||||
* every <diagram>, so loading decompresses all pages up front.
|
||||
*/
|
||||
import { inflateRawSync } from "node:zlib"
|
||||
import { DOMParser } from "linkedom"
|
||||
import {
|
||||
isMxFile,
|
||||
isMxGraphModel,
|
||||
normalizeToMxfile,
|
||||
parseMxfile,
|
||||
serializeMxfile,
|
||||
} from "./pages.js"
|
||||
|
||||
export type LoadResult =
|
||||
| { ok: true; xml: string }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Decode one compressed page body (base64 → raw deflate → URI-decode).
|
||||
* Returns null if the text isn't in that format.
|
||||
*/
|
||||
export function decompressPageContent(compressed: string): string | null {
|
||||
try {
|
||||
const inflated = inflateRawSync(
|
||||
Buffer.from(compressed.trim(), "base64"),
|
||||
).toString("utf-8")
|
||||
try {
|
||||
return decodeURIComponent(inflated)
|
||||
} catch {
|
||||
// Not URI-encoded (older files) — the inflated text is the XML.
|
||||
return inflated
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the content of a .drawio file into the canonical session shape:
|
||||
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
|
||||
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
|
||||
* any compressed pages.
|
||||
*/
|
||||
export function parseDrawioFileContent(content: string): LoadResult {
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return { ok: false, error: "File is empty." }
|
||||
|
||||
if (isMxGraphModel(trimmed)) {
|
||||
const normalized = normalizeToMxfile(trimmed)
|
||||
return normalized
|
||||
? { ok: true, xml: normalized }
|
||||
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
|
||||
}
|
||||
if (!isMxFile(trimmed)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
|
||||
}
|
||||
}
|
||||
const doc = parseMxfile(trimmed)
|
||||
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
|
||||
|
||||
let decompressedAny = false
|
||||
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
|
||||
if (d.querySelector("mxGraphModel")) continue
|
||||
const text = (d.textContent || "").trim()
|
||||
if (!text) continue // an empty page is valid
|
||||
const pageLabel =
|
||||
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
|
||||
const xml = decompressPageContent(text)
|
||||
if (!xml || !isMxGraphModel(xml)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
|
||||
}
|
||||
}
|
||||
const inner = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (
|
||||
inner.querySelector("parsererror") ||
|
||||
inner.documentElement?.tagName !== "mxGraphModel"
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
|
||||
}
|
||||
}
|
||||
d.textContent = ""
|
||||
d.appendChild(
|
||||
doc.importNode(inner.documentElement as unknown as Node, true),
|
||||
)
|
||||
decompressedAny = true
|
||||
}
|
||||
// Nothing changed — keep the file's own serialisation.
|
||||
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
|
||||
}
|
||||
316
packages/mcp-server/src/pages.ts
Normal file
316
packages/mcp-server/src/pages.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Multi-page (mxfile) helpers for draw.io diagrams.
|
||||
*
|
||||
* The on-disk and embed-protocol shape of a draw.io document is:
|
||||
*
|
||||
* <mxfile host="...">
|
||||
* <diagram id="..." name="...">
|
||||
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
|
||||
* </diagram>
|
||||
* ...one or more <diagram> children...
|
||||
* </mxfile>
|
||||
*
|
||||
* This module centralises page CRUD so that index.ts, xml-validation.ts,
|
||||
* and diagram-operations.ts can all agree on:
|
||||
* - what "the canonical in-memory shape" is (always mxfile),
|
||||
* - how to find a page (id, name, or index),
|
||||
* - how to add/rename/delete pages without re-parsing ad-hoc.
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
|
||||
export interface PageInfo {
|
||||
id: string
|
||||
name: string
|
||||
index: number
|
||||
cellCount: number
|
||||
}
|
||||
|
||||
/** Selector used by all multi-page-aware tools. All fields optional. */
|
||||
export interface PageSelector {
|
||||
page_id?: string
|
||||
page_name?: string
|
||||
page_index?: number
|
||||
}
|
||||
|
||||
/** True if the selector targets a specific page (any field set). */
|
||||
export function hasPageSelector(s?: PageSelector | null): boolean {
|
||||
if (!s) return false
|
||||
return (
|
||||
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short page id similar in shape to drawio's auto-assigned ids.
|
||||
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
|
||||
* uses short ids; collisions are still astronomically unlikely for one session.
|
||||
*/
|
||||
export function generatePageId(): string {
|
||||
const a = Math.random().toString(36).substring(2, 10)
|
||||
const b = Math.random().toString(36).substring(2, 6)
|
||||
return `${a}-${b}`
|
||||
}
|
||||
|
||||
/** Cheap regex check — does the XML start with an <mxfile> root? */
|
||||
export function isMxFile(xml: string): boolean {
|
||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
|
||||
}
|
||||
|
||||
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
|
||||
export function isMxGraphModel(xml: string): boolean {
|
||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
|
||||
* only permits the declaration at the very start of a document, so embedding
|
||||
* a declaration inside another element produces invalid XML. Callers must
|
||||
* strip before splicing a fragment into a wrapper.
|
||||
*/
|
||||
function stripXmlDeclaration(xml: string): string {
|
||||
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
|
||||
* If the input is already an mxfile, returns it unchanged.
|
||||
* If the input is neither shape, returns null so the caller can surface a clear error.
|
||||
*
|
||||
* Strips any leading <?xml ?> declaration before embedding — a declaration is
|
||||
* only valid at the very start of a document, never inside a <diagram>.
|
||||
*/
|
||||
export function normalizeToMxfile(
|
||||
xml: string,
|
||||
opts: { pageId?: string; pageName?: string; host?: string } = {},
|
||||
): string | null {
|
||||
const trimmed = xml.trim()
|
||||
if (!trimmed) return null
|
||||
if (isMxFile(trimmed)) return trimmed
|
||||
if (!isMxGraphModel(trimmed)) return null
|
||||
|
||||
const pageId = opts.pageId || generatePageId()
|
||||
const pageName = opts.pageName || "Page-1"
|
||||
const host = opts.host || "app.diagrams.net"
|
||||
const inner = stripXmlDeclaration(trimmed)
|
||||
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an mxfile XML string. Returns null on parse error or if the root
|
||||
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
|
||||
*/
|
||||
export function parseMxfile(xml: string): Document | null {
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (doc.querySelector("parsererror")) return null
|
||||
if (doc.documentElement?.tagName !== "mxfile") return null
|
||||
return doc as unknown as Document
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
|
||||
export function serializeMxfile(doc: Document): string {
|
||||
const serializer = new XMLSerializer()
|
||||
return serializer.serializeToString(doc)
|
||||
}
|
||||
|
||||
export type PageProjection =
|
||||
| { ok: true; xml: string; index: number; name: string }
|
||||
| { ok: false; reason: "parse" | "notfound" }
|
||||
|
||||
/**
|
||||
* Project a single page out of an mxfile string into a standalone one-page
|
||||
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
|
||||
* share one parse → find → serialise path.
|
||||
*
|
||||
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
|
||||
* or { ok:false, reason:"notfound" } if the selector matches no page.
|
||||
*/
|
||||
export function projectPage(
|
||||
xml: string,
|
||||
selector: PageSelector,
|
||||
): PageProjection {
|
||||
const doc = parseMxfile(xml)
|
||||
if (!doc) return { ok: false, reason: "parse" }
|
||||
const found = findPageElement(doc, selector)
|
||||
if (!found) return { ok: false, reason: "notfound" }
|
||||
const serializer = new XMLSerializer()
|
||||
return {
|
||||
ok: true,
|
||||
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
|
||||
index: found.index,
|
||||
name: found.element.getAttribute("name") || "",
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk every <diagram> child of <mxfile> and return summary info. */
|
||||
export function listPagesFromDoc(doc: Document): PageInfo[] {
|
||||
const diagrams = doc.querySelectorAll("diagram")
|
||||
const result: PageInfo[] = []
|
||||
diagrams.forEach((d, idx) => {
|
||||
const root = d.querySelector("root")
|
||||
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
|
||||
result.push({
|
||||
id: d.getAttribute("id") || "",
|
||||
name: d.getAttribute("name") || `Page-${idx + 1}`,
|
||||
index: idx,
|
||||
cellCount,
|
||||
})
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page selector to its <diagram> element.
|
||||
* Resolution order: page_id → page_name → page_index → default (first page).
|
||||
*
|
||||
* When no selector field is set we return the first page — the "active page
|
||||
* by convention" mentioned in §3.4 of the design doc.
|
||||
*/
|
||||
export function findPageElement(
|
||||
doc: Document,
|
||||
selector?: PageSelector,
|
||||
): { element: Element; index: number } | null {
|
||||
const diagrams = Array.from(doc.querySelectorAll("diagram"))
|
||||
if (diagrams.length === 0) return null
|
||||
|
||||
if (!hasPageSelector(selector)) {
|
||||
return { element: diagrams[0], index: 0 }
|
||||
}
|
||||
|
||||
if (selector?.page_id) {
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
if (diagrams[i].getAttribute("id") === selector.page_id) {
|
||||
return { element: diagrams[i], index: i }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (selector?.page_name) {
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
if (diagrams[i].getAttribute("name") === selector.page_name) {
|
||||
return { element: diagrams[i], index: i }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (selector && selector.page_index !== undefined) {
|
||||
const idx = selector.page_index
|
||||
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
|
||||
return { element: diagrams[idx], index: idx }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
|
||||
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
|
||||
*
|
||||
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
|
||||
* end up nested inside <diagram>, which is malformed. We reject the mxfile
|
||||
* shape explicitly and strip any <?xml ?> declaration (only valid at
|
||||
* document start, never inside <diagram>).
|
||||
*
|
||||
* Returns the new PageInfo. Throws if the requested id collides or the xml
|
||||
* shape is wrong.
|
||||
*/
|
||||
export function addPageToDoc(
|
||||
doc: Document,
|
||||
opts: { id?: string; name?: string; xml?: string } = {},
|
||||
): PageInfo {
|
||||
const existing = listPagesFromDoc(doc)
|
||||
const id = opts.id || generatePageId()
|
||||
if (existing.some((p) => p.id === id)) {
|
||||
throw new Error(`Page id "${id}" already exists`)
|
||||
}
|
||||
const name = opts.name || `Page-${existing.length + 1}`
|
||||
|
||||
let inner: string
|
||||
if (opts.xml?.trim()) {
|
||||
const trimmed = stripXmlDeclaration(opts.xml.trim())
|
||||
if (isMxFile(trimmed)) {
|
||||
throw new Error(
|
||||
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
|
||||
)
|
||||
}
|
||||
if (!isMxGraphModel(trimmed)) {
|
||||
throw new Error(
|
||||
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
|
||||
)
|
||||
}
|
||||
inner = trimmed
|
||||
} else {
|
||||
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
||||
}
|
||||
|
||||
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
||||
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
|
||||
if (tempDoc.querySelector("parsererror")) {
|
||||
throw new Error(
|
||||
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
|
||||
)
|
||||
}
|
||||
const newDiagram = tempDoc.querySelector("diagram")
|
||||
if (!newDiagram) {
|
||||
throw new Error("Failed to construct <diagram> element for new page")
|
||||
}
|
||||
|
||||
const imported = doc.importNode(newDiagram, true) as Element
|
||||
doc.documentElement.appendChild(imported)
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
index: existing.length,
|
||||
cellCount: imported.querySelectorAll("mxCell").length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Rename the page matched by selector. Returns true on success. */
|
||||
export function renamePageInDoc(
|
||||
doc: Document,
|
||||
selector: PageSelector,
|
||||
newName: string,
|
||||
): boolean {
|
||||
const found = findPageElement(doc, selector)
|
||||
if (!found) return false
|
||||
found.element.setAttribute("name", newName)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a page. Refuses to delete the last remaining page — the embed needs
|
||||
* at least one diagram to render anything, and silently recreating one would
|
||||
* be surprising behaviour for an MCP caller.
|
||||
*/
|
||||
export function deletePageFromDoc(
|
||||
doc: Document,
|
||||
selector: PageSelector,
|
||||
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
|
||||
const pages = listPagesFromDoc(doc)
|
||||
if (pages.length <= 1) {
|
||||
return { ok: false, reason: "Cannot delete the only remaining page" }
|
||||
}
|
||||
const found = findPageElement(doc, selector)
|
||||
if (!found) {
|
||||
return { ok: false, reason: "Page not found" }
|
||||
}
|
||||
const id = found.element.getAttribute("id") || ""
|
||||
const index = found.index
|
||||
found.element.parentNode?.removeChild(found.element)
|
||||
return { ok: true, deletedId: id, deletedIndex: index }
|
||||
}
|
||||
@@ -119,8 +119,74 @@ function checkDuplicateAttributes(xml: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check for duplicate IDs in XML */
|
||||
/**
|
||||
* Check for duplicate IDs in XML.
|
||||
*
|
||||
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
|
||||
* IDs are unique **within a page**, not across the whole document — drawio
|
||||
* legitimately reuses "0" and "1" for the root cells of every page. So we
|
||||
* scope the cell-ID uniqueness check per <diagram>, and additionally check
|
||||
* that the <diagram> ids themselves are unique.
|
||||
*
|
||||
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
|
||||
* and for XML that won't DOM-parse.
|
||||
*/
|
||||
function checkDuplicateIds(xml: string): string | null {
|
||||
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
|
||||
// bare <mxGraphModel> inputs (the overwhelming majority of historic
|
||||
// traffic), the cheap regex fallback at the bottom is enough. A quick
|
||||
// string check avoids paying the DOMParser cost on every call.
|
||||
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
|
||||
|
||||
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
|
||||
if (mightBeMxFile)
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (!doc.querySelector("parsererror")) {
|
||||
const rootEl = doc.documentElement
|
||||
if (rootEl && rootEl.tagName === "mxfile") {
|
||||
const diagrams = doc.querySelectorAll("diagram")
|
||||
|
||||
// 1) <diagram> ids must be unique across the file.
|
||||
const diagramIds = new Map<string, number>()
|
||||
diagrams.forEach((d) => {
|
||||
const id = d.getAttribute("id")
|
||||
if (id)
|
||||
diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dupDiagrams = Array.from(diagramIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id]) => `'${id}'`)
|
||||
if (dupDiagrams.length > 0) {
|
||||
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
|
||||
}
|
||||
|
||||
// 2) Within each page, mxCell ids must be unique.
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
const diagram = diagrams[i]
|
||||
const pageId =
|
||||
diagram.getAttribute("id") || `(index ${i})`
|
||||
const cells = diagram.querySelectorAll("mxCell")
|
||||
const cellIds = new Map<string, number>()
|
||||
cells.forEach((c) => {
|
||||
const id = c.getAttribute("id")
|
||||
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dups = Array.from(cellIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id, count]) => `'${id}' (${count}x)`)
|
||||
if (dups.length > 0) {
|
||||
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to regex
|
||||
}
|
||||
|
||||
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
const ids = new Map<string, number>()
|
||||
let idMatch
|
||||
@@ -770,35 +836,46 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||
}
|
||||
|
||||
// 22. Fix duplicate IDs by appending suffix
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
// 22. Fix duplicate IDs by appending suffix.
|
||||
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
|
||||
// across pages legitimately (every page has its own <root> with id="0"/"1"
|
||||
// sentinel cells). Renaming them would break drawio's parent references.
|
||||
// For mxfile inputs, duplicate-id validation is page-scoped in
|
||||
// checkDuplicateIds() and a true duplicate produces a hard error rather
|
||||
// than a silent rename.
|
||||
if (!/<mxfile[\s>]/i.test(fixed)) {
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(
|
||||
/\bid\s*=\s*["']([^"']+)["']/gi,
|
||||
(match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
|
||||
if (count === 0) return match
|
||||
if (count === 0) return match
|
||||
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
})
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
},
|
||||
)
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
// 23. Fix empty id attributes
|
||||
|
||||
132
packages/mcp-server/tests/edit-gate.test.ts
Normal file
132
packages/mcp-server/tests/edit-gate.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Unit tests for the edit_diagram workflow gate (edit-gate.ts).
|
||||
*
|
||||
* The gate replaced the old 30-second wall-clock rule (#885): an edit is
|
||||
* allowed when the model has seen the current browser state, no matter how
|
||||
* long ago — and rejected when the browser state moved since. "Seen" is
|
||||
* judged structurally, so draw.io's re-serialisation of the same content
|
||||
* (attribute order, whitespace, viewport attributes, wrapper shape) never
|
||||
* reads as a user edit.
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
beforeAll(() => {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
})
|
||||
|
||||
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
|
||||
|
||||
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
// The same document as draw.io re-serialises it on autosave: different host,
|
||||
// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered
|
||||
// cell attributes, pretty-printed whitespace.
|
||||
const XML_A_RESERIALIZED = `<mxfile host="embed.diagrams.net">
|
||||
<diagram id="regenerated-id" name="Page-1">
|
||||
<mxGraphModel dx="1596" dy="743" grid="1" pageWidth="827" pageHeight="1169">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="box1" parent="1" style="rounded=0;" value="Hello" vertex="1">
|
||||
<mxGeometry height="60" width="120" x="40" y="40" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>`
|
||||
|
||||
// A real user edit: box1 moved to a different position.
|
||||
const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"')
|
||||
|
||||
// Bare mxGraphModel with identical page content to XML_A.
|
||||
const XML_A_BARE = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
|
||||
describe("checkEditGate", () => {
|
||||
it("rejects when no diagram context was ever established", () => {
|
||||
expect(checkEditGate("", XML_A)).toEqual({
|
||||
ok: false,
|
||||
reason: "no-context",
|
||||
})
|
||||
})
|
||||
|
||||
it("allows when the browser state is exactly what the model saw", () => {
|
||||
expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it("allows when the browser state is a re-serialisation of the same content", () => {
|
||||
expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it("rejects when a cell actually changed", () => {
|
||||
expect(checkEditGate(XML_A, XML_B)).toEqual({
|
||||
ok: false,
|
||||
reason: "stale",
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects a real edit even when wrapped in re-serialisation noise", () => {
|
||||
const movedAndReserialized = XML_A_RESERIALIZED.replace(
|
||||
'x="40" y="40"',
|
||||
'x="300" y="200"',
|
||||
)
|
||||
expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({
|
||||
ok: false,
|
||||
reason: "stale",
|
||||
})
|
||||
})
|
||||
|
||||
it("allows when the store has no live entry to compare against", () => {
|
||||
expect(checkEditGate(XML_A, "")).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
// A bare <mxGraphModel> push carries no page name, so the gate must not
|
||||
// compare the invented "Page-1" wrapper name against the real one.
|
||||
it("allows a bare mxGraphModel push when the page has a custom name", () => {
|
||||
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
||||
expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it("still rejects a bare mxGraphModel push whose cells changed", () => {
|
||||
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
||||
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
|
||||
expect(checkEditGate(seenRenamed, bareMoved)).toEqual({
|
||||
ok: false,
|
||||
reason: "stale",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("contentFingerprint", () => {
|
||||
it("is invariant under draw.io re-serialisation", () => {
|
||||
expect(contentFingerprint(XML_A)).toBe(
|
||||
contentFingerprint(XML_A_RESERIALIZED),
|
||||
)
|
||||
})
|
||||
|
||||
it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => {
|
||||
expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A))
|
||||
})
|
||||
|
||||
it("changes when a cell attribute changes", () => {
|
||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B))
|
||||
})
|
||||
|
||||
it("changes when a page is renamed", () => {
|
||||
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
|
||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed))
|
||||
})
|
||||
|
||||
it("changes when a page is added", () => {
|
||||
const twoPages = XML_A.replace(
|
||||
"</mxfile>",
|
||||
`<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
|
||||
)
|
||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages))
|
||||
})
|
||||
|
||||
it("falls back to the raw string for unparseable input", () => {
|
||||
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
|
||||
})
|
||||
})
|
||||
126
packages/mcp-server/tests/load-diagram.test.ts
Normal file
126
packages/mcp-server/tests/load-diagram.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Unit tests for load_diagram's file parsing (load-diagram.ts).
|
||||
*
|
||||
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
|
||||
* as draw.io's compressed default (encodeURIComponent → raw deflate →
|
||||
* base64 text content). The loader must produce the canonical session
|
||||
* shape: an <mxfile> whose every page is plain XML.
|
||||
*/
|
||||
|
||||
import { deflateRawSync } from "node:zlib"
|
||||
import { DOMParser } from "linkedom"
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
// Install the DOM polyfills exactly as index.ts does at runtime.
|
||||
beforeAll(() => {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: any): string {
|
||||
if (node.outerHTML !== undefined) return node.outerHTML
|
||||
if (node.documentElement) return node.documentElement.outerHTML
|
||||
return ""
|
||||
}
|
||||
}
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
})
|
||||
|
||||
import {
|
||||
decompressPageContent,
|
||||
parseDrawioFileContent,
|
||||
} from "../src/load-diagram.js"
|
||||
|
||||
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
|
||||
/** Compress a page body exactly the way draw.io does when saving. */
|
||||
function drawioCompress(xml: string): string {
|
||||
return deflateRawSync(
|
||||
Buffer.from(encodeURIComponent(xml), "utf-8"),
|
||||
).toString("base64")
|
||||
}
|
||||
|
||||
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
|
||||
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
||||
|
||||
describe("decompressPageContent", () => {
|
||||
it("round-trips draw.io's compressed format", () => {
|
||||
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
|
||||
})
|
||||
|
||||
it("handles non-URI-encoded legacy payloads", () => {
|
||||
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
|
||||
"base64",
|
||||
)
|
||||
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
|
||||
})
|
||||
|
||||
it("returns null for garbage", () => {
|
||||
expect(decompressPageContent("not base64 deflate")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseDrawioFileContent", () => {
|
||||
it("passes a plain-XML mxfile through unchanged", () => {
|
||||
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
||||
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
|
||||
})
|
||||
|
||||
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
|
||||
const r = parseDrawioFileContent(MODEL_XML)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.xml).toContain("<mxfile")
|
||||
expect(r.xml).toContain('value="Hello"')
|
||||
}
|
||||
})
|
||||
|
||||
it("decompresses a compressed mxfile into plain XML pages", () => {
|
||||
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.xml).toContain("<mxGraphModel")
|
||||
expect(r.xml).toContain('value="Hello"')
|
||||
// The compressed blob must be gone.
|
||||
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
|
||||
}
|
||||
})
|
||||
|
||||
it("decompresses only the compressed pages of a mixed file", () => {
|
||||
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
||||
const r = parseDrawioFileContent(mixed)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
|
||||
const diagrams = Array.from(
|
||||
doc.querySelectorAll("diagram"),
|
||||
) as Element[]
|
||||
expect(diagrams).toHaveLength(2)
|
||||
for (const d of diagrams) {
|
||||
expect(d.querySelector("mxGraphModel")).not.toBeNull()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps empty pages as-is", () => {
|
||||
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
|
||||
const r = parseDrawioFileContent(withEmpty)
|
||||
expect(r).toEqual({ ok: true, xml: withEmpty })
|
||||
})
|
||||
|
||||
it("rejects empty files", () => {
|
||||
const r = parseDrawioFileContent(" ")
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects non-drawio content", () => {
|
||||
const r = parseDrawioFileContent("<svg><rect/></svg>")
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
|
||||
})
|
||||
|
||||
it("rejects a page whose content is neither XML nor compressed", () => {
|
||||
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
|
||||
const r = parseDrawioFileContent(bad)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.error).toContain('"Broken"')
|
||||
})
|
||||
})
|
||||
545
packages/mcp-server/tests/multi-page.test.ts
Normal file
545
packages/mcp-server/tests/multi-page.test.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Unit tests for multi-page (mxfile) support.
|
||||
*
|
||||
* Pinned to the user-visible contract described in
|
||||
* multi-page-mcp-support-plan.md §5 (acceptance criteria):
|
||||
*
|
||||
* AC1. create_new_diagram accepts both bare <mxGraphModel> and full <mxfile>.
|
||||
* AC2. get_diagram returns the full <mxfile> regardless of page count.
|
||||
* AC3. edit_diagram accepts an optional page selector.
|
||||
* AC6. Two tool calls reproduce the Transformer/CNN scenario.
|
||||
* AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary.
|
||||
*
|
||||
* These tests pin the helpers (pages.ts), the validator update
|
||||
* (xml-validation.ts), and the page-targeted edit logic
|
||||
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
// Install the DOM polyfill exactly as index.ts does at runtime — the
|
||||
// helpers under test rely on it.
|
||||
beforeAll(() => {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: any): string {
|
||||
if (node.outerHTML !== undefined) return node.outerHTML
|
||||
if (node.documentElement) return node.documentElement.outerHTML
|
||||
return ""
|
||||
}
|
||||
}
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
})
|
||||
|
||||
import { applyDiagramOperations } from "../src/diagram-operations.js"
|
||||
import {
|
||||
addPageToDoc,
|
||||
deletePageFromDoc,
|
||||
findPageElement,
|
||||
generatePageId,
|
||||
hasPageSelector,
|
||||
isMxFile,
|
||||
isMxGraphModel,
|
||||
listPagesFromDoc,
|
||||
normalizeToMxfile,
|
||||
parseMxfile,
|
||||
projectPage,
|
||||
renamePageInDoc,
|
||||
serializeMxfile,
|
||||
} from "../src/pages.js"
|
||||
import { validateAndFixXml } from "../src/xml-validation.js"
|
||||
|
||||
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
|
||||
const TWO_PAGE_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="page-transformer" name="Transformer"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Encoder"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="page-cnn" name="CNN"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
describe("pages.ts — shape detection", () => {
|
||||
it("isMxFile detects a multi-page mxfile", () => {
|
||||
expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true)
|
||||
})
|
||||
|
||||
it("isMxFile rejects a bare mxGraphModel", () => {
|
||||
expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false)
|
||||
})
|
||||
|
||||
it("isMxGraphModel detects a bare model", () => {
|
||||
expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true)
|
||||
expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false)
|
||||
})
|
||||
|
||||
it("isMxFile tolerates an XML declaration prefix", () => {
|
||||
expect(
|
||||
isMxFile(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>${TWO_PAGE_MXFILE}`,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => {
|
||||
it("wraps a bare mxGraphModel into a single-page mxfile", () => {
|
||||
const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
||||
pageId: "p1",
|
||||
pageName: "Page-1",
|
||||
})
|
||||
expect(out).not.toBeNull()
|
||||
expect(out).toMatch(/^<mxfile/)
|
||||
expect(out).toContain(`<diagram id="p1" name="Page-1">`)
|
||||
expect(out).toContain("<mxGraphModel>")
|
||||
})
|
||||
|
||||
it("returns mxfile inputs unchanged", () => {
|
||||
const out = normalizeToMxfile(TWO_PAGE_MXFILE)
|
||||
expect(out).toBe(TWO_PAGE_MXFILE)
|
||||
})
|
||||
|
||||
it("returns null for neither shape", () => {
|
||||
expect(normalizeToMxfile("<random/>")).toBeNull()
|
||||
expect(normalizeToMxfile("")).toBeNull()
|
||||
})
|
||||
|
||||
it("generated page ids look reasonable", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const id = generatePageId()
|
||||
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
|
||||
}
|
||||
})
|
||||
|
||||
it("strips a leading <?xml ?> declaration when wrapping a bare model", () => {
|
||||
// Regression for the bug Copilot caught: isMxGraphModel tolerates a
|
||||
// declaration prefix, but the wrapper used to embed it inside
|
||||
// <diagram>, producing invalid XML (<?xml ?> is only valid at the
|
||||
// document start). The result must round-trip through parseMxfile
|
||||
// and the declaration must be gone from inside <diagram>.
|
||||
const withDecl = `<?xml version="1.0" encoding="UTF-8"?>${BARE_MODEL_ONE_CELL}`
|
||||
const out = normalizeToMxfile(withDecl, {
|
||||
pageId: "p1",
|
||||
pageName: "Page-1",
|
||||
})
|
||||
expect(out).not.toBeNull()
|
||||
expect(out).toMatch(/^<mxfile/)
|
||||
// No <?xml inside the body of the wrapped document.
|
||||
expect(out!.indexOf("<?xml")).toBe(-1)
|
||||
// And it must still parse cleanly.
|
||||
const doc = parseMxfile(out!)
|
||||
expect(doc).not.toBeNull()
|
||||
expect(listPagesFromDoc(doc!)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — addPageToDoc input validation", () => {
|
||||
it("rejects opts.xml shaped as a full <mxfile>", () => {
|
||||
// Regression for the Copilot-flagged bug: an mxfile passed as
|
||||
// starting page xml would end up nested inside <diagram>, corrupting
|
||||
// the document. Must throw with a clear message.
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(() =>
|
||||
addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }),
|
||||
).toThrowError(/bare <mxGraphModel>/i)
|
||||
})
|
||||
|
||||
it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(() =>
|
||||
addPageToDoc(doc, { name: "Junk", xml: "<root><x/></root>" }),
|
||||
).toThrowError(/bare <mxGraphModel>/i)
|
||||
})
|
||||
|
||||
it("strips a <?xml ?> declaration prefix on opts.xml", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const withDecl = `<?xml version="1.0"?>${BARE_MODEL_ONE_CELL}`
|
||||
const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl })
|
||||
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
||||
// Serialised document must not have <?xml ?> inside <diagram>.
|
||||
const out = serializeMxfile(doc)
|
||||
// The mxfile may have one <?xml ?> at the very start (the doc decl),
|
||||
// but no further occurrence inside <diagram>.
|
||||
const matches = out.match(/<\?xml/g) || []
|
||||
expect(matches.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — listPagesFromDoc / findPageElement", () => {
|
||||
it("lists both pages in a two-page mxfile", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages).toHaveLength(2)
|
||||
expect(pages[0]).toMatchObject({
|
||||
id: "page-transformer",
|
||||
name: "Transformer",
|
||||
index: 0,
|
||||
})
|
||||
expect(pages[1]).toMatchObject({
|
||||
id: "page-cnn",
|
||||
name: "CNN",
|
||||
index: 1,
|
||||
})
|
||||
// Cell count is per-page (3 cells per page including the two root sentinels).
|
||||
expect(pages[0].cellCount).toBe(3)
|
||||
expect(pages[1].cellCount).toBe(3)
|
||||
})
|
||||
|
||||
it("findPageElement defaults to the first page when selector is empty", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const found = findPageElement(doc)
|
||||
expect(found?.index).toBe(0)
|
||||
expect(found?.element.getAttribute("id")).toBe("page-transformer")
|
||||
})
|
||||
|
||||
it("findPageElement matches by id, name, and index — id wins when several are set", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1)
|
||||
expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1)
|
||||
expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1)
|
||||
// id beats name beats index
|
||||
const winner = findPageElement(doc, {
|
||||
page_id: "page-cnn",
|
||||
page_name: "Transformer",
|
||||
page_index: 0,
|
||||
})
|
||||
expect(winner?.index).toBe(1)
|
||||
})
|
||||
|
||||
it("findPageElement returns null for an unknown selector", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(findPageElement(doc, { page_id: "ghost" })).toBeNull()
|
||||
expect(findPageElement(doc, { page_name: "ghost" })).toBeNull()
|
||||
expect(findPageElement(doc, { page_index: 99 })).toBeNull()
|
||||
expect(findPageElement(doc, { page_index: -1 })).toBeNull()
|
||||
})
|
||||
|
||||
it("hasPageSelector correctly detects empty vs populated selectors", () => {
|
||||
expect(hasPageSelector()).toBe(false)
|
||||
expect(hasPageSelector({})).toBe(false)
|
||||
expect(hasPageSelector({ page_id: "x" })).toBe(true)
|
||||
expect(hasPageSelector({ page_index: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — addPageToDoc", () => {
|
||||
it("appends a third page and returns its info", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const info = addPageToDoc(doc, { name: "Sequence" })
|
||||
expect(info.name).toBe("Sequence")
|
||||
expect(info.index).toBe(2)
|
||||
expect(info.id).toMatch(/.+/)
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages).toHaveLength(3)
|
||||
expect(pages[2].name).toBe("Sequence")
|
||||
})
|
||||
|
||||
it("rejects a duplicate explicit id", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(() =>
|
||||
addPageToDoc(doc, { id: "page-transformer", name: "X" }),
|
||||
).toThrowError(/already exists/)
|
||||
})
|
||||
|
||||
it("uses a sensible default name when none is supplied", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const info = addPageToDoc(doc, {})
|
||||
expect(info.name).toBe("Page-3")
|
||||
})
|
||||
|
||||
it("accepts an inline starting mxGraphModel", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="A"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
const info = addPageToDoc(doc, { name: "Custom", xml: inner })
|
||||
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => {
|
||||
it("renames an existing page by name", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2")
|
||||
expect(ok).toBe(true)
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages[1].name).toBe("CNN-v2")
|
||||
})
|
||||
|
||||
it("rename returns false when target page is missing", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false)
|
||||
})
|
||||
|
||||
it("deletes a page and removes the <diagram> element from the doc", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" })
|
||||
expect(outcome.ok).toBe(true)
|
||||
expect(outcome.deletedId).toBe("page-cnn")
|
||||
expect(listPagesFromDoc(doc)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("refuses to delete the only remaining page", () => {
|
||||
// Build a single-page doc to test the guard.
|
||||
const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)!
|
||||
const doc = parseMxfile(single)!
|
||||
const outcome = deletePageFromDoc(doc, { page_index: 0 })
|
||||
expect(outcome.ok).toBe(false)
|
||||
expect(outcome.reason).toMatch(/only remaining page/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("xml-validation.ts — multi-page support", () => {
|
||||
it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => {
|
||||
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => {
|
||||
// This is the regression the planning doc explicitly called out:
|
||||
// before this work, the legacy regex-based duplicate-id check rejected
|
||||
// any multi-page document because cells "0" and "1" appear in every page.
|
||||
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects duplicate cell ids WITHIN a single page", () => {
|
||||
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="dup" vertex="1" parent="1"/><mxCell id="dup" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const result = validateAndFixXml(bad)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toMatch(/duplicate cell ID/i)
|
||||
})
|
||||
|
||||
it("rejects duplicate <diagram> ids across the file", () => {
|
||||
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const result = validateAndFixXml(bad)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toMatch(/duplicate <diagram> id/i)
|
||||
})
|
||||
|
||||
it("still validates a bare <mxGraphModel> (legacy callers)", () => {
|
||||
const result = validateAndFixXml(BARE_MODEL_ONE_CELL)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => {
|
||||
// Build a doc that triggers some other auto-fix (so autoFixXml runs)
|
||||
// but contains valid multi-page 0/1 cells that must NOT be renamed.
|
||||
const malformedButMultiPage = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Q & A"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="p2" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const result = validateAndFixXml(malformedButMultiPage)
|
||||
// The doc has an unescaped & — autoFix will repair that. After repair
|
||||
// it should be valid AND must not have renamed the 0/1 cells.
|
||||
const finalXml = result.fixed || malformedButMultiPage
|
||||
expect(finalXml).not.toMatch(/id="0_dup/)
|
||||
expect(finalXml).not.toMatch(/id="1_dup/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("diagram-operations.ts — page-targeted edits (AC3)", () => {
|
||||
it("adds a cell to the targeted page by id, leaving the other page untouched", () => {
|
||||
const { result, errors } = applyDiagramOperations(
|
||||
TWO_PAGE_MXFILE,
|
||||
[
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "conv-2",
|
||||
new_xml: `<mxCell id="conv-2" vertex="1" parent="1" value="Conv2"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`,
|
||||
},
|
||||
],
|
||||
{ page_id: "page-cnn" },
|
||||
)
|
||||
expect(errors).toHaveLength(0)
|
||||
const doc = parseMxfile(result)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
// Transformer untouched (still 3 cells), CNN gained one cell.
|
||||
expect(pages[0].cellCount).toBe(3)
|
||||
expect(pages[1].cellCount).toBe(4)
|
||||
expect(result).toContain(`id="conv-2"`)
|
||||
})
|
||||
|
||||
it("defaults to the first page when no selector is given", () => {
|
||||
const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "shape-x",
|
||||
new_xml: `<mxCell id="shape-x" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>`,
|
||||
},
|
||||
])
|
||||
expect(errors).toHaveLength(0)
|
||||
const doc = parseMxfile(result)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew
|
||||
expect(pages[1].cellCount).toBe(3) // CNN untouched
|
||||
})
|
||||
|
||||
it("errors clearly when the page is not found", () => {
|
||||
const { errors } = applyDiagramOperations(
|
||||
TWO_PAGE_MXFILE,
|
||||
[
|
||||
{
|
||||
operation: "delete",
|
||||
cell_id: "2",
|
||||
},
|
||||
],
|
||||
{ page_id: "does-not-exist" },
|
||||
)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].message).toMatch(/Page.*not found/i)
|
||||
// Page-level errors carry an empty cellId — edit_diagram relies on
|
||||
// this to distinguish "nothing applied" from per-cell warnings and
|
||||
// return a hard error instead of a false success.
|
||||
expect(errors[0].cellId).toBe("")
|
||||
})
|
||||
|
||||
it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => {
|
||||
// Both pages have a cell with id="2". A delete on CNN's "2" must not
|
||||
// remove Transformer's "2".
|
||||
const { result, errors } = applyDiagramOperations(
|
||||
TWO_PAGE_MXFILE,
|
||||
[{ operation: "delete", cell_id: "2" }],
|
||||
{ page_id: "page-cnn" },
|
||||
)
|
||||
expect(errors).toHaveLength(0)
|
||||
const doc = parseMxfile(result)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
// CNN lost its only non-sentinel cell, Transformer keeps its three.
|
||||
expect(pages[1].cellCount).toBe(2)
|
||||
expect(pages[0].cellCount).toBe(3)
|
||||
})
|
||||
|
||||
it("legacy bare-mxGraphModel input still works when no selector is given", () => {
|
||||
const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "new",
|
||||
new_xml: `<mxCell id="new" vertex="1" parent="1"><mxGeometry x="100" y="100" width="50" height="50" as="geometry"/></mxCell>`,
|
||||
},
|
||||
])
|
||||
expect(errors).toHaveLength(0)
|
||||
expect(result).toContain(`id="new"`)
|
||||
})
|
||||
|
||||
it("page selector on a bare mxGraphModel returns a clear error", () => {
|
||||
const { errors } = applyDiagramOperations(
|
||||
BARE_MODEL_ONE_CELL,
|
||||
[{ operation: "delete", cell_id: "2" }],
|
||||
{ page_id: "page-1" },
|
||||
)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].message).toMatch(/not multi-page/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe("export_diagram — single-page projection (regression for selectPage bug)", () => {
|
||||
// The previous implementation tried to drive drawio's iframe with an
|
||||
// `action: 'selectPage'` postMessage, which the embed protocol silently
|
||||
// ignores. The result was that PNG/SVG exports targeted the currently
|
||||
// active tab regardless of the page selector — two visually different
|
||||
// pages would yield byte-identical PNGs.
|
||||
//
|
||||
// The current implementation builds a single-page <mxfile> projection via
|
||||
// the shared pages.ts:projectPage helper and hands it to the browser
|
||||
// bridge to load BEFORE triggering export. These tests pin that helper so
|
||||
// a future refactor can't silently re-introduce the multi-page drift.
|
||||
function projectSinglePage(fullMxfile: string, sel: any): string {
|
||||
const result = projectPage(fullMxfile, sel)
|
||||
if (!result.ok) throw new Error(`projection failed: ${result.reason}`)
|
||||
return result.xml
|
||||
}
|
||||
|
||||
it("returns a parse error for a non-mxfile source", () => {
|
||||
const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" })
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe("parse")
|
||||
})
|
||||
|
||||
it("returns a notfound error for an unknown page", () => {
|
||||
const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" })
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe("notfound")
|
||||
})
|
||||
|
||||
it("projects only the requested page when targeted by id", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_id: "page-cnn",
|
||||
})
|
||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||
expect(pages).toHaveLength(1)
|
||||
expect(pages[0].id).toBe("page-cnn")
|
||||
expect(pages[0].name).toBe("CNN")
|
||||
// The projection must NOT contain the Transformer page anywhere.
|
||||
expect(projected).not.toContain('id="page-transformer"')
|
||||
expect(projected).not.toContain('name="Transformer"')
|
||||
})
|
||||
|
||||
it("projects only the requested page when targeted by name", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_name: "Transformer",
|
||||
})
|
||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||
expect(pages).toHaveLength(1)
|
||||
expect(pages[0].name).toBe("Transformer")
|
||||
expect(projected).not.toContain('id="page-cnn"')
|
||||
})
|
||||
|
||||
it("projects only the requested page when targeted by index", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_index: 1,
|
||||
})
|
||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||
expect(pages).toHaveLength(1)
|
||||
expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection
|
||||
expect(pages[0].id).toBe("page-cnn")
|
||||
})
|
||||
|
||||
it("two different page selectors produce visually distinct projections", () => {
|
||||
// The regression: under the old selectPage bug, two exports would
|
||||
// return the same active tab. With the projection approach, the
|
||||
// payload that drawio renders is provably different.
|
||||
const a = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_id: "page-transformer",
|
||||
})
|
||||
const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" })
|
||||
expect(a).not.toBe(b)
|
||||
expect(a).toContain('"Encoder"')
|
||||
expect(a).not.toContain('"Conv1"')
|
||||
expect(b).toContain('"Conv1"')
|
||||
expect(b).not.toContain('"Encoder"')
|
||||
})
|
||||
|
||||
it("the projection parses to a valid one-page mxfile", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_id: "page-cnn",
|
||||
})
|
||||
// Validator accepts it.
|
||||
expect(validateAndFixXml(projected).valid).toBe(true)
|
||||
// And it has a real <root> with the cells from the source page.
|
||||
const doc = parseMxfile(projected)!
|
||||
const root = doc.querySelector("root")
|
||||
expect(root).not.toBeNull()
|
||||
const conv1 = doc.querySelector('mxCell[value="Conv1"]')
|
||||
expect(conv1).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("end-to-end — Transformer + CNN scenario (AC6)", () => {
|
||||
it("two tool-equivalent steps reproduce the motivating user scenario", () => {
|
||||
// Step 1 — caller passes a single-page mxfile.
|
||||
const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
||||
pageId: "page-transformer",
|
||||
pageName: "Transformer",
|
||||
})
|
||||
expect(step1).not.toBeNull()
|
||||
let xml = step1 as string
|
||||
const validate1 = validateAndFixXml(xml)
|
||||
expect(validate1.valid).toBe(true)
|
||||
|
||||
// Step 2 — equivalent of add_page("CNN") with a starting model.
|
||||
const doc = parseMxfile(xml)!
|
||||
addPageToDoc(doc, {
|
||||
id: "page-cnn",
|
||||
name: "CNN",
|
||||
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||
})
|
||||
xml = serializeMxfile(doc)
|
||||
|
||||
// Now: two pages, both valid, with the right names.
|
||||
const pages = listPagesFromDoc(parseMxfile(xml)!)
|
||||
expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"])
|
||||
expect(validateAndFixXml(xml).valid).toBe(true)
|
||||
})
|
||||
})
|
||||
142
packages/mcp-server/tests/server-wiring.test.ts
Normal file
142
packages/mcp-server/tests/server-wiring.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
|
||||
* and drive it the way a real MCP client does — initialize handshake,
|
||||
* tools/list — to catch registration/schema regressions that the unit tests
|
||||
* (which import helpers directly) can't see.
|
||||
*
|
||||
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
|
||||
* dist/index.js and was therefore never run in CI (CI doesn't build this
|
||||
* package before testing). Running from source via tsx means it executes as
|
||||
* part of the normal `vitest run`.
|
||||
*
|
||||
* We deliberately do NOT call start_session — it would open a real browser
|
||||
* window via open(). The browser bridge is covered by the Playwright e2e suite.
|
||||
*/
|
||||
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const entry = path.resolve(__dirname, "..", "src", "index.ts")
|
||||
const tsxBin = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? "tsx.cmd" : "tsx",
|
||||
)
|
||||
|
||||
const EXPECTED_TOOLS = [
|
||||
"start_session",
|
||||
"create_new_diagram",
|
||||
"load_diagram",
|
||||
"edit_diagram",
|
||||
"get_diagram",
|
||||
"export_diagram",
|
||||
"list_pages",
|
||||
"add_page",
|
||||
"rename_page",
|
||||
"delete_page",
|
||||
]
|
||||
|
||||
let proc: ChildProcessWithoutNullStreams
|
||||
let stdoutBuf = ""
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
|
||||
>()
|
||||
let nextId = 1
|
||||
|
||||
function send(method: string, params: unknown, isNotification = false) {
|
||||
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
|
||||
if (!isNotification) msg.id = nextId++
|
||||
proc.stdin.write(`${JSON.stringify(msg)}\n`)
|
||||
if (isNotification) return Promise.resolve(undefined)
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const id = msg.id as number
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
reject(new Error(`Timed out waiting for response to ${method}`))
|
||||
}, 15000)
|
||||
pending.set(id, { resolve, reject, timeout })
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
proc = spawn(tsxBin, [entry], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBuf += chunk.toString()
|
||||
const lines = stdoutBuf.split("\n")
|
||||
stdoutBuf = lines.pop() || ""
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
let msg: any
|
||||
try {
|
||||
msg = JSON.parse(trimmed)
|
||||
} catch {
|
||||
// Non-JSON-RPC log line — ignore.
|
||||
continue
|
||||
}
|
||||
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
|
||||
if (p) {
|
||||
clearTimeout(p.timeout)
|
||||
pending.delete(msg.id)
|
||||
p.resolve(msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const initResp = await send("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "wiring-test", version: "0.0.0" },
|
||||
})
|
||||
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
|
||||
expect(initResp.result?.serverInfo?.name).toBeTruthy()
|
||||
await send("notifications/initialized", {}, true)
|
||||
}, 30000)
|
||||
|
||||
afterAll(() => {
|
||||
proc?.kill("SIGTERM")
|
||||
})
|
||||
|
||||
describe("MCP server wiring", () => {
|
||||
it("registers all nine multi-page tools", async () => {
|
||||
const resp = await send("tools/list", {})
|
||||
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
|
||||
const names: string[] = (resp.result?.tools ?? []).map(
|
||||
(t: { name: string }) => t.name,
|
||||
)
|
||||
for (const expected of EXPECTED_TOOLS) {
|
||||
expect(names, `missing tool: ${expected}`).toContain(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it("advertises page-selector params on edit_diagram", async () => {
|
||||
const resp = await send("tools/list", {})
|
||||
const edit = resp.result.tools.find(
|
||||
(t: { name: string }) => t.name === "edit_diagram",
|
||||
)
|
||||
const props = edit?.inputSchema?.properties ?? {}
|
||||
expect(props.page_id).toBeTruthy()
|
||||
expect(props.page_name).toBeTruthy()
|
||||
expect(props.page_index).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advertises name/id/xml on add_page", async () => {
|
||||
const resp = await send("tools/list", {})
|
||||
const addPage = resp.result.tools.find(
|
||||
(t: { name: string }) => t.name === "add_page",
|
||||
)
|
||||
const props = addPage?.inputSchema?.properties ?? {}
|
||||
expect(props.name).toBeTruthy()
|
||||
expect(props.id).toBeTruthy()
|
||||
expect(props.xml).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,8 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
11
packages/mcp-server/vitest.config.ts
Normal file
11
packages/mcp-server/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: "node",
|
||||
// The package source uses Node16 module resolution with explicit .js
|
||||
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
|
||||
// transparently, so no extra alias config is needed.
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user