Compare commits

...
Author SHA1 Message Date
Dayuan Jiang 02eaa0bd22 ci: give the SignPath approval one hour instead of ten minutes (#968) 2026-10-10 12:57:21 +09:00
Dayuan Jiang 4cba863fd9 chore: bump version to 0.5.0 (#967) 2026-10-10 12:02:12 +09:00
Dayuan Jiang 7682e479e3 chore: replace the cat example with a new drawing (#966)
Update the README cat image and the cached example response for
"Draw a cat for me" with a new cat diagram.
2026-10-10 10:33:23 +09:00
Dayuan Jiang a7ae114edf fix(chat): keep the request alive while its answer streams, so a stop still reaches the provider (#965)
A Request's signal follows the signal it was created with through a weak
link inside undici: once the Request object is garbage collected, the link is
gone and request.signal never aborts (nodejs/undici#3644). The chat route
passes req.signal to streamText and holds nothing else from the request, so a
GC pause during a stream could leave the model call running after the client
stopped or disconnected, with no onAbort accounting.

The route now keeps each request in a WeakMap keyed by its response, which
Next holds while the body is piped.

This is also the cause of the unit test chat-route-abort hanging on CI (three
times this week, 60 ms locally): the test's Request is dropped as soon as the
route returns, and a GC in that window lost the abort. With a forced GC the
test hangs on Node 20 and 24 without this change and passes with it.
2026-10-10 09:24:25 +09:00
Dayuan Jiang e117095c7b feat(admin): rewrite the admin panel copy in plain words (#963)
Labels are two to four words, descriptions one short line, and the
wording follows the reader rather than the implementation: "服务商"
instead of "provider", "本页设置" instead of "后台设置" for the source
chip (it shared its name with the page title), "需重启" as a badge, a
verb on every switch ("访问内网"), "Langfuse 密钥" instead of "私钥".
Descriptions keep the facts the old ones had (where to change an
env-managed provider, that users must enter an access code), and the
same style is applied to the English, Japanese and Traditional Chinese
dictionaries. Five user-facing strings in the same files are aligned
with the new terms (服务商, token).
2026-10-10 08:38:26 +09:00
Dayuan Jiang f0f0bc0dc0 feat(ui): tidy the start screen and the chat panel details (#962)
Start screen
- The five examples are one row of picture tiles (title under the picture); the old cards cut their descriptions off with an ellipsis
- "Open .drawio file" leaves the example grid and sits with "Start with a blank canvas" below it, both as outlined buttons
- The "Paper to diagram" example gets a thumbnail of its result (public/paper_demo.svg) like the other four
- The caption says what happens: examples use saved answers, so they appear at once and do not call the AI
- Shorter lede

Chat panel
- The version card's "</>" icon button becomes a "Show XML" text link in the summary line; the summary runs under the title row, so Undo is the card's only button
- Settings uses the gear icon; new diagram uses a file-plus icon instead of the pen that reads as "edit"
- The message box has the same side padding as the message column

Copy
- The model picker says "Server model" instead of "Default" when the server's model is in use
- The theme description no longer claims the diagram always stays on a white sheet (the canvas follows the theme)
- en, zh, ja and zh-Hant dictionaries updated together
2026-10-10 08:22:36 +09:00
Dayuan Jiang d5096b9628 docs: restructure the README around the new promo video (#961)
* docs: restructure the README around the new promo video

The three READMEs now follow the reader's path: what it is (video and
two paragraphs), highlights grouped as draw / edit / use, examples, how
to use it (demo, desktop app, MCP), how to self-host (local run, a
one-click deploy table, models and providers), and support.

Removed the table of contents, the sponsor blocks, the "How It Works"
section, the duplicated demo button and the model recommendation list.
The provider list now matches env.example (24 providers). The video is
the new 30-second promo. Bold markers inside the HTML example table are
now <strong> tags, so they render.

* docs: keep the TrendShift badge under the language switch

* chore: remove the sponsor material for Doubao and Atlas Cloud

The about pages lose the "Sponsored by ByteDance Doubao" card, the
promotional links in the provider list and the thanks paragraph; the
"bring your own key" note stays. The quota toast no longer shows the
Volcengine sign-up paragraph, and its messages in the four languages no
longer mention a sponsor. The provider guides lose the "free tokens"
promo line. The logo and invite images used only by that material are
deleted. Doubao and Atlas Cloud remain supported providers.
2026-10-10 08:22:16 +09:00
Dayuan Jiang 2418c47764 fix(edit): refuse cut-off cell XML in edit_diagram operations (#960)
While an edit streams, the preview applies the operations received so
far. Chrome's DOMParser keeps a cell whose XML is cut off, so a vertex
without its geometry reached the canvas. draw.io placed it at the origin,
shifted the page layout and compensated the scroll position, and the
browser's clamping of that compensation left the diagram out of view
once the edit finished.

Check each new_xml with getXmlSyntaxError, as the whole document
already is, so an incomplete operation is reported instead of applied.
2026-10-09 21:06:49 +09:00
Dayuan Jiang 04d34534fd ci: run the e2e tests in six shards (#959)
* ci: run the e2e tests in four shards

One job ran all 300+ tests with one worker, about 20 minutes. Four
jobs at once take a quarter each, still with one worker per job.

* ci: six e2e shards

With four, the shard of the long workspace tests took 8 minutes while
the others took 4 to 5.
2026-10-08 18:44:14 +09:00
Dayuan Jiang 17ea4811fb feat(settings): reorganize the settings dialog and the model settings (#958)
Tabs Models, General, Drawing and About, each with a fixed title row.

The models tab becomes three pages: the user's providers with one status
each and the server's models; a searchable picker of all 24 providers in
four groups; and a provider page in three steps (connection, models,
test). A new provider gets its first suggested model, and a passing test
offers to use the model in the chat. Bedrock asks for one way of signing
in, with an optional session token for temporary access keys.

The composer's model picker lists every model, marking untested and
failed ones, and can open the provider picker. A chat error that offers
model settings opens the provider the request went to.
2026-10-08 18:10:07 +09:00
61 changed files with 5068 additions and 3031 deletions
+3
View File
@@ -86,6 +86,9 @@ jobs:
artifact-configuration-slug: 'windows-exe' artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true wait-for-completion: true
# The release-signing policy needs a manual approval in SignPath;
# the default 600 s wait made the job fail before anyone could approve.
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: release-signed output-artifact-directory: release-signed
- name: Upload signed artifacts to release - name: Upload signed artifacts to release
+9 -3
View File
@@ -43,8 +43,14 @@ jobs:
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
e2e: e2e:
name: E2E Tests # Six jobs at once, each with a sixth of the tests and one worker: the
# same conditions per test, a fraction of the time
name: E2E Tests (${{ matrix.shard }}/6)
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -76,7 +82,7 @@ jobs:
run: npm run build run: npm run build
- name: Run E2E tests - name: Run E2E tests
run: npm run test:e2e run: npm run test:e2e -- --shard=${{ matrix.shard }}/6
env: env:
CI: true CI: true
@@ -84,6 +90,6 @@ jobs:
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
if: always() if: always()
with: with:
name: playwright-report name: playwright-report-${{ matrix.shard }}
path: playwright-report/ path: playwright-report/
retention-days: 7 retention-days: 7
+73 -200
View File
@@ -1,121 +1,98 @@
# Next AI Draw.io
<div align="center"> <div align="center">
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize** # Next AI Draw.io
**Draw and edit draw.io diagrams by chatting with an AI.**
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md) English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [**Live Demo**](https://next-ai-drawio.jiang.jp/) · [**Desktop App**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP Server**](#in-your-ai-agent-mcp)
</div> </div>
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. https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
> 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! Describe a diagram in a sentence and the AI draws it on a real draw.io canvas. Edit it by hand like any draw.io file, or select a few shapes and tell the AI what to change. Every AI change is a version you can compare, restore or undo, and the result exports as `.drawio`, `.png` or `.svg`.
<p align="center"> Available as a web app, a desktop app for Windows, macOS and Linux, and an MCP server for AI agents such as Claude Code, Cursor and VS Code.
<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). ## Highlights
**Draw**
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 - Architecture diagrams, flowcharts, sequence diagrams and more from one sentence, with built-in AWS, Azure, GCP and Kubernetes icon libraries
- Connectors can carry a flowing animation
- Upload a screenshot or a sketch and the AI redraws it; upload PDF, Markdown, code and other text files and get a diagram of their content
**Edit**
- Edit by chat: changes stream onto the canvas and the shapes the AI just changed are highlighted
- Ask about a selection: select shapes on the canvas and the AI changes only those
- Versions and undo: every AI change is a version card with a thumbnail; compare it with the canvas, restore it or undo it, and Ctrl+Z on the canvas also takes back an AI change in one step
- It is a normal draw.io diagram: double-click to rename, drag, restyle, use several pages, and export as `.drawio`, `.png`, `.svg` or `.drawio.svg` at any time
## Table of Contents **Use**
- [Next AI Draw.io](#next-ai-drawio)
- [Table of Contents](#table-of-contents) - 24 model providers; enter your own API key in the browser and it stays on your machine
- [Examples](#examples) - Models that reason show their thinking
- [Features](#features) - Dark mode; the interface is available in English, Simplified Chinese, Traditional Chinese and Japanese
- [MCP Server](#mcp-server)
- [Claude Code CLI](#claude-code-cli)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
- [Desktop Application](#desktop-application)
- [Run with Docker](#run-with-docker)
- [Installation](#installation)
- [Deployment](#deployment)
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
- [Deploy on Vercel](#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)
- [Star History](#star-history)
## Examples ## Examples
Here are some example prompts and their generated diagrams:
<div align="center"> <div align="center">
<table width="100%"> <table width="100%">
<tr> <tr>
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>Animated transformer connectors</strong><br /> <strong>Transformer architecture with animated connectors</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p> <p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
<img src="./public/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width="480" /> <img src="./public/animated_connectors.svg" alt="Transformer architecture with animated connectors" width="440" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>RAG Technique Diagram</strong><br /> <strong>RAG architecture</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" /> <img src="./public/rag_prod.svg" alt="RAG architecture diagram" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Authentication using React and AWS</strong><br /> <strong>Authentication with React and AWS</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p> <p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" /> <img src="./public/auth.svg" alt="Authentication architecture diagram" width="400" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Open Innovation</strong><br /> <strong>Open Innovation model</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p> <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" /> <img src="./public/inno.svg" alt="Open Innovation diagram" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Cat sketch</strong><br /> <strong>Cat sketch</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" /> <img src="./public/cat_demo.svg" alt="Cat drawing" width="200" />
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
## Features ## Use it
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands ### Online demo
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
- **Versions and Undo**: Every AI change shows up in the chat as a version with a thumbnail. Compare it with the canvas, restore it, or undo it; Ctrl+Z on the canvas also takes back an AI change in one step. Shapes the AI just changed are highlighted.
- **Ask About a Selection**: Select shapes on the canvas and ask the AI to change just those.
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
- **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 Open [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/), nothing to install. The demo has a usage limit; click the settings icon in the chat panel and enter your own provider and API key to lift it. The key stays in your browser and is never sent to the server for storage.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol). ### Desktop app
Download the Windows, macOS or Linux installer from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases).
### In your AI agent (MCP)
Through MCP (Model Context Protocol, the protocol AI agents use to call outside tools), Claude Desktop, Cursor, VS Code and others can draw draw.io diagrams directly. Add this to your client's MCP configuration:
```json ```json
{ {
@@ -128,165 +105,61 @@ Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via
} }
``` ```
### Claude Code CLI For Claude Code, one command does it:
```bash ```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
``` ```
Then ask Claude to create diagrams: Then ask the AI for "a flowchart of user authentication with login, MFA and session management" and the diagram appears in your browser as it is drawn. The MCP server has most of the web app's drawing features:
> "Create a flowchart showing user authentication with login, MFA, and session management"
The diagram appears in your browser in real-time! - The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
- A screenshot tool, so the AI can look at the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart
The MCP server includes most of the web app's drawing features: See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more) ## Self-host
- A screenshot tool, so the AI can check the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations. ### Run locally
## Getting Started
### Try it Online
No installation needed! Try the app directly on our demo site:
[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
### Desktop Application
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
Supported platforms: Windows, macOS, Linux.
### Run with Docker
[Go to Docker Guide](./docs/en/docker.md)
### Installation
1. Clone the repository:
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
npm install npm install
cp env.example .env.local cp env.example .env.local # add your provider and API key, see "Models and providers" below
```
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
2. Run the development server:
```bash
npm run dev npm run dev
``` ```
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application. Open [http://localhost:6002](http://localhost:6002).
## Deployment ### One-click deploy
### Deploy to EdgeOne Pages | Platform | How |
| --- | --- |
| Tencent EdgeOne Pages | [![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) Deploying there also gives you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai) |
| Vercel | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) Set the same environment variables in the Vercel dashboard as in your `.env.local` |
| Cloudflare Workers | [Cloudflare deploy guide](./docs/en/cloudflare-deploy.md) |
| Docker | [Docker guide](./docs/en/docker.md) |
| Offline or intranet | [Offline deployment](./docs/en/offline-deployment.md) |
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/). ### Models and providers
Deploy by this button: 24 providers are supported: AWS Bedrock (default), OpenAI, Anthropic, Google AI, Google Vertex AI, Azure OpenAI, Ollama, OpenRouter, AIHubMix, DeepSeek, SiliconFlow, SGLang, Vercel AI Gateway, Tencent EdgeOne, ByteDance Doubao, ModelScope, Zhipu GLM, Qwen, Qiniu, Kimi, MiniMax, Novita, Xiaomi MiMo and Atlas Cloud. The environment variables and notes for each are in the [provider configuration guide](./docs/en/ai-providers.md).
[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) **Which model**: the task is to produce long text in a strict format (draw.io XML), so pick a capable model; small models tend to produce broken diagrams.
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details. **Several models and the admin panel**: list several model IDs in `AI_MODEL` separated by commas, or configure models from several providers with the `AI_MODELS_CONFIG` environment variable or an `ai-models.json` file, and every user can use them without a key of their own. Set `ADMIN_PASSWORD` and open `/admin` to manage models, access codes, feature switches, observability and quotas from a web page; see the [admin panel guide](./docs/en/admin-panel.md).
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai). ## Support
### Deploy on Vercel - Questions and ideas: open a [GitHub issue](https://github.com/DayuanJiang/next-ai-draw-io/issues) or email me[at]jiang.jp
- Common problems: [FAQ](./docs/en/FAQ.md)
- If the project is useful to you, consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help keep the demo site running
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) <div align="center">
The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Deploy on Cloudflare Workers
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
## Multi-Provider Support
- [ByteDance Doubao](https://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
- Google AI
- Google Vertex AI
- 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.
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. 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.
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
## How It Works
The application uses the following technologies:
- **Next.js**: For the frontend framework and routing
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
- **draw.io**: The editor runs from a copy bundled into `public/drawio` (downloaded by `npm run dev` / `npm run build`), so the app can drive it directly
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
## Support & Contact
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
**Special thanks to [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!
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
- Email: me[at]jiang.jp
## FAQ
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
--- </div>
+1 -70
View File
@@ -66,54 +66,6 @@ export default function AboutCN() {
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg"> <div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" /> <div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6"> <div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
由字节跳动豆包提供支持
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
好消息!感谢{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
字节跳动豆包
</a>
的慷慨赞助,演示站点现已接入强大的{" "}
<span className="font-semibold text-amber-700">
glm-4.7
</span>{" "}
模型,图表生成效果更佳!点击链接注册即可领取{" "}
<span className="font-semibold text-amber-700">
50万免费Token
</span>
,适用于所有模型!
</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 */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -309,16 +261,7 @@ export default function AboutCN() {
多提供商支持 多提供商支持
</h2> </h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li> <li>字节跳动豆包</li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
字节跳动豆包
</a>
</li>
<li>AWS Bedrock(默认)</li> <li>AWS Bedrock(默认)</li>
<li> <li>
OpenAI / OpenAI兼容API(通过{" "} OpenAI / OpenAI兼容API(通过{" "}
@@ -343,18 +286,6 @@ export default function AboutCN() {
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
支持与联系 支持与联系
</h2> </h2>
<p className="text-gray-700 mb-4 font-semibold">
特别感谢{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
字节跳动豆包
</a>{" "}
为本站提供 API Token 支持!
</p>
<p className="text-gray-700"> <p className="text-gray-700">
如果您觉得这个项目有用,请考虑{" "} 如果您觉得这个项目有用,请考虑{" "}
<a <a
+1 -53
View File
@@ -74,37 +74,6 @@ export default function AboutJA() {
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg"> <div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" /> <div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6"> <div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
ByteDance Doubao提供
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
朗報です!
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
様のご支援により、デモサイトでは強力な{" "}
<span className="font-semibold text-amber-700">
glm-4.7
</span>{" "}
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
<span className="font-semibold text-amber-700">
50万トークン
</span>
が無料でもらえます!
</p>
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -305,16 +274,7 @@ export default function AboutJA() {
マルチプロバイダーサポート マルチプロバイダーサポート
</h2> </h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li> <li>ByteDance Doubao</li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
</li>
<li>AWS Bedrock(デフォルト)</li> <li>AWS Bedrock(デフォルト)</li>
<li> <li>
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code> OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
@@ -339,18 +299,6 @@ export default function AboutJA() {
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
サポート&お問い合わせ サポート&お問い合わせ
</h2> </h2>
<p className="text-gray-700 mb-4 font-semibold">
デモサイトのAPIトークン使用を支援してくださった{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>{" "}
様に、心より感謝申し上げます。
</p>
<p className="text-gray-700"> <p className="text-gray-700">
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "} このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
<a <a
+1 -55
View File
@@ -74,39 +74,6 @@ export default function About() {
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg"> <div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" /> <div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6"> <div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
Sponsored by ByteDance Doubao
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
Great news! Thanks to the generous
sponsorship from{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
, the demo site now uses the powerful{" "}
<span className="font-semibold text-amber-700">
glm-4.7
</span>{" "}
model for better diagram generation! Sign up
via the link to get{" "}
<span className="font-semibold text-amber-700">
500K free tokens
</span>{" "}
for all models!
</p>
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
@@ -319,16 +286,7 @@ export default function About() {
Multi-Provider Support Multi-Provider Support
</h2> </h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li> <li>ByteDance Doubao</li>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
</li>
<li>AWS Bedrock (default)</li> <li>AWS Bedrock (default)</li>
<li> <li>
OpenAI / OpenAI-compatible APIs (via{" "} OpenAI / OpenAI-compatible APIs (via{" "}
@@ -355,18 +313,6 @@ export default function About() {
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Support &amp; Contact Support &amp; Contact
</h2> </h2>
<p className="text-gray-700 mb-4 font-semibold">
Special thanks to{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>{" "}
for sponsoring the API token usage of the demo site!
</p>
<p className="text-gray-700"> <p className="text-gray-700">
If you find this project useful, please consider{" "} If you find this project useful, please consider{" "}
<a <a
+6
View File
@@ -98,6 +98,11 @@ function createCachedStreamResponse(xml: string): Response {
// Responses streamed from the model, whose trace streamText's callbacks end // Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>() const modelStreamResponses = new WeakSet<Response>()
// A Request's signal follows the client's disconnect only while the Request
// object itself is alive: once it is garbage collected, the abort is lost
// (nodejs/undici#3644) and a stopped chat would run on at the provider.
// Each request is kept as long as its answer streams.
const requestOfResponse = new WeakMap<Response, Request>()
// Inner handler function // Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true" const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
@@ -787,6 +792,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
}, },
}) })
modelStreamResponses.add(response) modelStreamResponses.add(response)
requestOfResponse.set(response, req)
return response return response
} }
+14 -3
View File
@@ -413,6 +413,8 @@ export function ChatEngineProvider({
// screen): an answer started meanwhile would land in the next chat // screen): an answer started meanwhile would land in the next chat
const leavingRef = useRef(false) const leavingRef = useRef(false)
const [isLeaving, setIsLeaving] = useState(false) const [isLeaving, setIsLeaving] = useState(false)
// The user's provider the current turn was sent to, for its errors
const requestProviderIdRef = useRef<string | undefined>(undefined)
// A message is being sent again (retry, regenerate, edit), until the // A message is being sent again (retry, regenerate, edit), until the
// SDK has put it back // SDK has put it back
const [isResending, setIsResending] = useState(false) const [isResending, setIsResending] = useState(false)
@@ -607,6 +609,11 @@ export function ChatEngineProvider({
// Not in another chat that came on screen meanwhile // Not in another chat that came on screen meanwhile
if (getChatGeneration() !== turnChatRef.current) return if (getChatGeneration() !== turnChatRef.current) return
// The provider the turn was sent to (none for a server model);
// the model selected may have changed since, e.g. in another tab
const providerId = requestProviderIdRef.current
// A system message, so it can be cleared with the conversation // A system message, so it can be cleared with the conversation
setMessages((currentMessages) => [ setMessages((currentMessages) => [
...currentMessages, ...currentMessages,
@@ -614,14 +621,15 @@ export function ChatEngineProvider({
id: `error-${Date.now()}`, id: `error-${Date.now()}`,
role: "system" as const, role: "system" as const,
parts: [{ type: "text" as const, text }], parts: [{ type: "text" as const, text }],
// The message shows a button that opens model settings // The message shows a button that opens model settings,
// on the page of the provider that refused
...(openModelConfig && { ...(openModelConfig && {
metadata: { openModelConfig: true }, metadata: { openModelConfig: true, providerId },
}), }),
}, },
]) ])
if (isAccessCodeError) openSettings("advanced") if (isAccessCodeError) openSettings("general")
}, },
// Re-render streamed messages at most every 150 ms. The streaming // Re-render streamed messages at most every 150 ms. The streaming
// diagram preview draws on each update, so this also limits redraws // diagram preview draws on each update, so this also limits redraws
@@ -1200,6 +1208,9 @@ export function ChatEngineProvider({
const config = getSelectedAIConfig() const config = getSelectedAIConfig()
const { customSystemMessage, minimalStyle, maxOutputTokens } = const { customSystemMessage, minimalStyle, maxOutputTokens } =
useSettingsStore.getState() useSettingsStore.getState()
// For this turn's errors: the provider these headers name (read with
// them, from storage: another tab may have changed it meanwhile)
requestProviderIdRef.current = config.providerId || undefined
const selected = selectedCells.map(({ id, label }) => ({ id, label })) const selected = selectedCells.map(({ id, label }) => ({ id, label }))
const options = { const options = {
+5 -5
View File
@@ -1,10 +1,10 @@
import { import {
ChevronDown, ChevronDown,
Download, Download,
FilePlus,
MessageSquareDashed, MessageSquareDashed,
PanelRightClose, PanelRightClose,
Settings2, Settings,
SquarePen,
} from "lucide-react" } from "lucide-react"
import { useRef } from "react" import { useRef } from "react"
import { DevXmlSimulator } from "@/components/dev-xml-simulator" import { DevXmlSimulator } from "@/components/dev-xml-simulator"
@@ -139,14 +139,14 @@ export function ChatPanel({
disabled={engine.isBusy} disabled={engine.isBusy}
data-testid="new-chat-button" data-testid="new-chat-button"
> >
<SquarePen /> <FilePlus />
</IconButton> </IconButton>
<IconButton <IconButton
label={dict.nav.settings} label={dict.nav.settings}
onClick={() => openSettings()} onClick={() => openSettings()}
data-testid="settings-button" data-testid="settings-button"
> >
<Settings2 /> <Settings />
</IconButton> </IconButton>
{!mobile && ( {!mobile && (
<IconButton <IconButton
@@ -173,7 +173,7 @@ export function ChatPanel({
/> />
)} )}
<div className="shrink-0 p-2.5 pt-1"> <div className="shrink-0 px-4 pt-1 pb-3">
<Composer <Composer
placeholder={ placeholder={
engine.messages.length > 0 && engine.messages.length > 0 &&
+1 -3
View File
@@ -466,10 +466,8 @@ export function Composer({
selectedModelId={modelConfig.selectedModelId} selectedModelId={modelConfig.selectedModelId}
onSelect={modelConfig.setSelectedModelId} onSelect={modelConfig.setSelectedModelId}
onConfigure={() => openSettings("models")} onConfigure={() => openSettings("models")}
onAddProvider={() => openSettings("models", "picker")}
disabled={isDisabled} disabled={isDisabled}
showUnvalidatedModels={
modelConfig.showUnvalidatedModels
}
/> />
{!isBusy && input.trim() && ( {!isBusy && input.trim() && (
<kbd <kbd
+32 -42
View File
@@ -18,14 +18,18 @@ interface Example {
file?: { path: string; name: string; type: string } file?: { path: string; name: string; type: string }
} }
/** Secondary start buttons: outlined, so they read as clickable */
const startButtonClass =
"inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-card px-3 text-[13px] text-foreground transition-colors hover:bg-accent [&_svg]:text-muted-foreground"
async function loadExampleFile(file: NonNullable<Example["file"]>) { async function loadExampleFile(file: NonNullable<Example["file"]>) {
const response = await fetch(getAssetUrl(file.path)) const response = await fetch(getAssetUrl(file.path))
const blob = await response.blob() const blob = await response.blob()
return new File([blob], file.name, { type: file.type }) return new File([blob], file.name, { type: file.type })
} }
/** One start-screen choice: a small picture, a title and one line */ /** One start-screen example: a small picture over its title */
function ExampleCard({ function ExampleTile({
onClick, onClick,
title, title,
description, description,
@@ -35,6 +39,7 @@ function ExampleCard({
}: { }: {
onClick: () => void onClick: () => void
title: string title: string
/** Shown as the tooltip: the title alone names the example */
description: string description: string
picture: React.ReactNode picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */ /** An icon on a tinted tile instead of a diagram on paper */
@@ -45,24 +50,20 @@ function ExampleCard({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="group/example flex min-w-0 items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2.5 text-left transition-colors hover:border-foreground/25" title={description}
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
data-testid={testId} data-testid={testId}
> >
<span <span
className={cn( className={cn(
"flex h-12 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg", "flex h-16 w-full items-center justify-center overflow-hidden rounded-lg",
plain ? "bg-muted" : "sheet-light bg-white", plain ? "bg-muted" : "sheet-light bg-white",
)} )}
> >
{picture} {picture}
</span> </span>
<span className="min-w-0 flex-1"> <span className="px-1 text-[13px] leading-snug font-medium text-foreground">
<span className="line-clamp-2 text-[13px] leading-snug font-medium text-foreground"> {title}
{title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{description}
</span>
</span> </span>
</button> </button>
) )
@@ -92,6 +93,7 @@ export function LobbyHero({
key: "paper", key: "paper",
title: dict.examples.paperToDiagram, title: dict.examples.paperToDiagram,
description: dict.examples.paperDescription, description: dict.examples.paperDescription,
image: "/paper_demo.svg",
prompt: "Summarize this paper as a diagram", prompt: "Summarize this paper as a diagram",
file: { file: {
path: "/chain-of-thought.txt", path: "/chain-of-thought.txt",
@@ -185,10 +187,12 @@ export function LobbyHero({
placeholder={t.placeholder} placeholder={t.placeholder}
/> />
{/* Examples: one row of tiles; the ways to bring your own
diagram follow as a separate group */}
<div <div
className={cn( className={cn(
"mt-5 grid gap-2", "mt-5 grid gap-2",
compact ? "grid-cols-1" : "grid-cols-3", compact ? "grid-cols-1" : "grid-cols-5",
)} )}
> >
{examples.map((example) => {examples.map((example) =>
@@ -209,7 +213,7 @@ export function LobbyHero({
</span> </span>
</button> </button>
) : ( ) : (
<ExampleCard <ExampleTile
key={example.key} key={example.key}
onClick={() => pickExample(example)} onClick={() => pickExample(example)}
title={example.title} title={example.title}
@@ -221,7 +225,7 @@ export function LobbyHero({
<img <img
src={getAssetUrl(example.image)} src={getAssetUrl(example.image)}
alt="" alt=""
className="max-h-10 max-w-[56px] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]" className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
/> />
) : ( ) : (
<FileText className="size-5 text-muted-foreground" /> <FileText className="size-5 text-muted-foreground" />
@@ -231,48 +235,34 @@ export function LobbyHero({
/> />
), ),
)} )}
{!compact && (
<ExampleCard
onClick={onOpenFile}
title={dict.workspace.openFile}
description={t.openFileHint}
picture={
<FileUp className="size-5 text-muted-foreground" />
}
plain
/>
)}
</div> </div>
<p className="mt-3 text-center text-xs text-faint"> <p className="mt-2.5 text-center text-xs text-faint">
{dict.examples.cachedNote} {dict.examples.cachedNote}
</p> </p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[13px] text-muted-foreground"> {/* Ways to start without an example, as plain buttons */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{engine.sessions.length > 0 && ( {engine.sessions.length > 0 && (
<SessionMenu align="center" onOpenFile={onOpenFile}> <SessionMenu align="center" onOpenFile={onOpenFile}>
<button <button type="button" className={startButtonClass}>
type="button"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<History className="size-4" /> <History className="size-4" />
{t.recent} {t.recent}
</button> </button>
</SessionMenu> </SessionMenu>
)} )}
{compact && ( <button
<button type="button"
type="button" onClick={onOpenFile}
onClick={onOpenFile} className={startButtonClass}
className="inline-flex items-center gap-1.5 hover:text-foreground" data-testid="open-file"
> >
<FileUp className="size-4" /> <FileUp className="size-4" />
{dict.workspace.openFile} {dict.workspace.openFile}
</button> </button>
)}
<button <button
type="button" type="button"
onClick={onDrawYourself} onClick={onDrawYourself}
className="inline-flex items-center gap-1.5 hover:text-foreground" className={startButtonClass}
data-testid="draw-yourself" data-testid="draw-yourself"
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
+10 -1
View File
@@ -615,7 +615,16 @@ export function MessageList() {
| undefined | undefined
)?.openModelConfig )?.openModelConfig
} }
onOpenModelConfig={() => openSettings("models")} onOpenModelConfig={() => {
const { providerId } =
(message.metadata as
| { providerId?: string }
| undefined) ?? {}
openSettings(
"models",
providerId ? { providerId } : "list",
)
}}
onRetry={ onRetry={
index === messages.length - 1 && index === messages.length - 1 &&
!engine.isBusy && !engine.isBusy &&
+26 -26
View File
@@ -2,7 +2,6 @@ import {
AlertCircle, AlertCircle,
Check, Check,
ChevronDown, ChevronDown,
Code2,
Copy, Copy,
Library, Library,
PenTool, PenTool,
@@ -180,17 +179,23 @@ function VersionCard({
</button> </button>
) )
// What the AI wrote, on every card (main showed it on every tool call) // What the AI wrote: a text link in the summary line, so the card's
const codeButton = ( // only button is the action (undo or restore)
<button const summaryLine = (
type="button" <div className="text-xs text-muted-foreground">
onClick={() => setShowCode((v) => !v)} {summary}
aria-label={showCode ? t.hideCode : t.showCode} {/* The link wraps as one unit with its separator */}
title={showCode ? t.hideCode : t.showCode} <span className="whitespace-nowrap">
className="inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground" <span aria-hidden> · </span>
> <button
<Code2 className="size-4" /> type="button"
</button> onClick={() => setShowCode((v) => !v)}
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
>
{showCode ? t.hideCode : t.showCode}
</button>
</span>
</div>
) )
const codePanel = showCode && ( const codePanel = showCode && (
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5"> <div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
@@ -231,15 +236,12 @@ function VersionCard({
/> />
</button> </button>
{badge} {badge}
<div className="min-w-0 flex-1" title={summary}> <div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-foreground"> <div className="truncate text-[13px] text-foreground">
{title} {title}
</div> </div>
<div className="truncate text-xs text-muted-foreground"> {summaryLine}
{summary}
</div>
</div> </div>
{codeButton}
{restoreButton} {restoreButton}
</div> </div>
{codePanel} {codePanel}
@@ -271,17 +273,12 @@ function VersionCard({
{t.compare} {t.compare}
</span> </span>
</button> </button>
<div className="flex items-center gap-2.5 px-3 py-2.5"> {/* Title row with the action; the summary runs under both */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
{badge} {badge}
<div className="min-w-0 flex-1" title={summary}> <div className="truncate text-[13px] font-medium text-foreground">
<div className="truncate text-[13px] font-medium text-foreground"> {title}
{title}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{summary}
</div>
</div> </div>
{codeButton}
{canUndo ? ( {canUndo ? (
<button <button
type="button" type="button"
@@ -300,6 +297,9 @@ function VersionCard({
) : ( ) : (
restoreButton restoreButton
)} )}
<div className="col-span-2 col-start-2 mt-0.5">
{summaryLine}
</div>
</div> </div>
{codePanel} {codePanel}
</div> </div>
File diff suppressed because it is too large Load Diff
+90 -50
View File
@@ -1,11 +1,11 @@
"use client" "use client"
import { import {
AlertTriangle,
Bot, Bot,
Check, Check,
ChevronDown, ChevronDown,
Monitor, Monitor,
Plus,
Server, Server,
Settings2, Settings2,
User, User,
@@ -39,8 +39,9 @@ interface ModelSelectorProps {
selectedModelId: string | undefined selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void onSelect: (modelId: string | undefined) => void
onConfigure?: () => void onConfigure?: () => void
/** Opens the settings on the list of providers to add */
onAddProvider?: () => void
disabled?: boolean disabled?: boolean
showUnvalidatedModels?: boolean
} }
// Group models by providerLabel (handles duplicate providers) // Group models by providerLabel (handles duplicate providers)
@@ -92,27 +93,21 @@ export function ModelSelector({
selectedModelId, selectedModelId,
onSelect, onSelect,
onConfigure, onConfigure,
onAddProvider,
disabled = false, disabled = false,
showUnvalidatedModels = false,
}: ModelSelectorProps) { }: ModelSelectorProps) {
const dict = useDictionary() const dict = useDictionary()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Filter models based on showUnvalidatedModels setting
const displayModels = useMemo(() => {
if (showUnvalidatedModels) {
return models
}
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
// Separate server and user models // Separate server and user models. Every user model is listed; one not
// tested yet, or that failed its test, says so.
const serverModels = useMemo( const serverModels = useMemo(
() => displayModels.filter((m) => m.source === "server"), () => models.filter((m) => m.source === "server"),
[displayModels], [models],
) )
const userModels = useMemo( const userModels = useMemo(
() => displayModels.filter((m) => m.source !== "server"), () => models.filter((m) => m.source !== "server"),
[displayModels], [models],
) )
// Group each category separately // Group each category separately
@@ -131,6 +126,17 @@ export function ModelSelector({
[models, selectedModelId], [models, selectedModelId],
) )
// Leaving for the settings: closing, the picker must not give the focus
// back to its button, behind the settings dialog
const toSettingsRef = useRef(false)
const goToSettings = (openSettings: () => void) => {
toSettingsRef.current = true
setOpen(false)
openSettings()
}
const footerButton =
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
const handleSelect = (value: string) => { const handleSelect = (value: string) => {
if (value === "__server_default__") { if (value === "__server_default__") {
onSelect(undefined) onSelect(undefined)
@@ -180,7 +186,13 @@ export function ModelSelector({
return ( return (
<div ref={wrapperRef} className="min-w-0 max-w-44"> <div ref={wrapperRef} className="min-w-0 max-w-44">
<ModelSelectorRoot open={open} onOpenChange={setOpen}> <ModelSelectorRoot
open={open}
onOpenChange={(next) => {
if (next) toSettingsRef.current = false
setOpen(next)
}}
>
<ModelSelectorTrigger asChild> <ModelSelectorTrigger asChild>
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent={tooltipContent} tooltipContent={tooltipContent}
@@ -208,21 +220,28 @@ export function ModelSelector({
<span className="min-w-0 truncate text-xs"> <span className="min-w-0 truncate text-xs">
{selectedModel {selectedModel
? shortModelName(selectedModel.modelId) ? shortModelName(selectedModel.modelId)
: dict.modelConfig.default} : dict.modelConfig.serverDefault}
</span> </span>
) : ( ) : (
// Keep an sr-only label for screen readers when hidden // Keep an sr-only label for screen readers when hidden
<span className="sr-only"> <span className="sr-only">
{selectedModel {selectedModel
? selectedModel.modelId ? selectedModel.modelId
: dict.modelConfig.default} : dict.modelConfig.serverDefault}
</span> </span>
)} )}
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> <ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip> </ButtonWithTooltip>
</ModelSelectorTrigger> </ModelSelectorTrigger>
<ModelSelectorContent title={dict.modelConfig.selectModel}> <ModelSelectorContent
title={dict.modelConfig.selectModel}
onCloseAutoFocus={(e) => {
if (!toSettingsRef.current) return
toSettingsRef.current = false
e.preventDefault()
}}
>
<ModelSelectorInput <ModelSelectorInput
placeholder={dict.modelConfig.searchModels} placeholder={dict.modelConfig.searchModels}
/> />
@@ -230,10 +249,7 @@ export function ModelSelector({
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="overflow-y-auto scrollbar-thin"> <ModelSelectorList className="overflow-y-auto scrollbar-thin">
<ModelSelectorEmpty> <ModelSelectorEmpty>
{displayModels.length === 0 && {dict.modelConfig.noModelsFound}
models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty> </ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */} {/* Server Default Option - only show when no server models are configured */}
@@ -422,13 +438,22 @@ export function ModelSelector({
{model.validated !== {model.validated !==
true && ( true && (
<span <span
title={ className={cn(
dict "ml-auto shrink-0 pl-2 text-xs",
.modelConfig model.validated ===
.unvalidatedModelWarning false
} ? "text-destructive"
: "text-muted-foreground",
)}
> >
<AlertTriangle className="ml-auto h-3 w-3 text-warning" /> {model.validated ===
false
? dict
.modelConfig
.modelFailed
: dict
.modelConfig
.modelUntested}
</span> </span>
)} )}
</ModelSelectorItem> </ModelSelectorItem>
@@ -441,31 +466,46 @@ export function ModelSelector({
)} )}
</ModelSelectorList> </ModelSelectorList>
</div> </div>
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */} {/* Pinned footer: add a provider, configure models
<div className="relative z-10 shrink-0 border-t bg-background"> (z-10 above list shadow). Buttons, outside the
{onConfigure && ( search: reachable with Tab, never filtered out */}
<div className="px-3 py-2"> {(onAddProvider || onConfigure) && (
<ModelSelectorItem <div
value="__configure_models__" className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
onSelect={() => { // The search's Enter would pick the highlighted
onConfigure() // model instead of pressing the button
setOpen(false) onKeyDown={(e) => e.stopPropagation()}
}} >
className="flex cursor-pointer items-center gap-2 rounded-sm" {onAddProvider && (
<button
type="button"
onClick={() =>
goToSettings(onAddProvider)
}
className={footerButton}
>
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">
{dict.modelConfig.addProviderEntry}
</span>
</button>
)}
{onConfigure && (
<button
type="button"
onClick={() =>
goToSettings(onConfigure)
}
className={footerButton}
> >
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" /> <Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<ModelSelectorName> <span className="truncate">
{dict.modelConfig.configureModels} {dict.modelConfig.configureModels}
</ModelSelectorName> </span>
</ModelSelectorItem> </button>
</div> )}
)}
<div className="px-3 pb-2 text-xs text-muted-foreground">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div> </div>
</div> )}
</div> </div>
</ModelSelectorContent> </ModelSelectorContent>
</ModelSelectorRoot> </ModelSelectorRoot>
+366 -146
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { Key, Link2, Tag } from "lucide-react" import { ChevronRight, Key, Link2, Tag } from "lucide-react"
import type { ReactNode } from "react" import { type ReactNode, useState } from "react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
@@ -13,12 +13,14 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { OPTIONS_OPEN } from "@/lib/provider-setup"
import { import {
chatRequestUrl, chatRequestUrl,
normalizeBaseUrl, normalizeBaseUrl,
PROVIDER_INFO, PROVIDER_INFO,
type ProviderName, type ProviderName,
} from "@/lib/types/model-config" } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
// Logical secret field. The caller owns the actual input — plaintext for the // Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via // user dialog, write-only masked for the admin panel — supplied via
@@ -30,6 +32,9 @@ export type SecretField =
| "awsSecretAccessKey" | "awsSecretAccessKey"
| "vertexApiKey" | "vertexApiKey"
/** Bedrock signs in with an API key or with an access key pair */
export type BedrockAuth = "apiKey" | "accessKey"
// AWS regions offered for Bedrock (shared by both screens) // AWS regions offered for Bedrock (shared by both screens)
export const AWS_REGIONS: Array<[string, string]> = [ export const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"], ["us-east-1", "N. Virginia"],
@@ -66,6 +71,17 @@ interface ProviderCredentialsFieldsProps {
// Extra content after the fields — used for the Bedrock test row and the // Extra content after the fields — used for the Bedrock test row and the
// EdgeOne test button, which aren't beside a credential input. // EdgeOne test button, which aren't beside a credential input.
footer?: ReactNode footer?: ReactNode
// The user's settings page: the secrets come first, the display name and
// base URL fold under "More options", and Bedrock shows only the way of
// signing in that is chosen. Switching it empties the other way's
// secrets (clearSecrets), so a request carries one kind of credential.
settingsLayout?: boolean
clearSecrets?: (fields: SecretField[]) => void
// Bedrock credentials filled in, to choose the way of signing in shown
bedrockFilled?: { apiKey: boolean; accessKey: boolean }
// Bedrock's optional session token input, for temporary access keys
// (the admin panel has none)
sessionTokenInput?: ReactNode
} }
// Display name + per-provider credential inputs, shared by the user // Display name + per-provider credential inputs, shared by the user
@@ -80,6 +96,10 @@ export function ProviderCredentialsFields({
onChange, onChange,
renderSecret, renderSecret,
footer, footer,
settingsLayout,
clearSecrets,
bedrockFilled,
sessionTokenInput,
}: ProviderCredentialsFieldsProps) { }: ProviderCredentialsFieldsProps) {
const dict = useDictionary() const dict = useDictionary()
const info = PROVIDER_INFO[provider] const info = PROVIDER_INFO[provider]
@@ -87,32 +107,354 @@ export function ProviderCredentialsFields({
example: info.defaultBaseUrl || "https://api.example.com/v1", example: info.defaultBaseUrl || "https://api.example.com/v1",
}) })
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
// Open when it holds something the user set, or for a provider whose
// base URL usually needs changing or comes with a hint
const [optionsOpen, setOptionsOpen] = useState(
() =>
!!name ||
(!!baseUrl && baseUrl !== info.defaultBaseUrl) ||
OPTIONS_OPEN.includes(provider),
)
// Bedrock: the way that is filled in (an API key is used first when both
// are, as the server does), else the one shown last, so emptying the
// fields to type new keys keeps them on screen
const filledAuth: BedrockAuth | null = bedrockFilled?.apiKey
? "apiKey"
: bedrockFilled?.accessKey
? "accessKey"
: null
const [chosenAuth, setChosenAuth] = useState<BedrockAuth>(
filledAuth ?? "apiKey",
)
if (filledAuth && filledAuth !== chosenAuth) setChosenAuth(filledAuth)
const bedrockAuth: BedrockAuth = filledAuth ?? chosenAuth
const nameField = (
<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>
)
const regionField = (
<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>
)
const vertexBaseUrlField = (
<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, with a link to where the provider issues keys
const apiKeyField = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<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>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
)
const baseUrlField = (
<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)}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(e.target.value)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl || dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{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>
)
// EdgeOne needs no credentials — the caller supplies just a test button // EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") { if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div> return <div className="space-y-5">{footer}</div>
} }
if (settingsLayout) {
return (
<div className="space-y-4">
{provider === "bedrock" ? (
<>
{/* Buttons, not radios: arrow keys would empty
secrets without a deliberate press */}
<fieldset
aria-label={dict.modelConfig.awsCredentials}
className="inline-flex rounded-lg bg-muted p-0.5"
>
{(["apiKey", "accessKey"] as const).map((auth) => (
<button
key={auth}
type="button"
aria-pressed={bedrockAuth === auth}
disabled={disabled}
onClick={() => {
if (bedrockAuth === auth) return
setChosenAuth(auth)
clearSecrets?.(
auth === "apiKey"
? [
"awsAccessKeyId",
"awsSecretAccessKey",
]
: ["apiKey"],
)
}}
className={cn(
"inline-flex h-7 items-center rounded-md px-2.5 text-xs text-muted-foreground transition-colors",
bedrockAuth === auth &&
"bg-card font-medium text-foreground shadow-float",
)}
>
{auth === "apiKey"
? dict.modelConfig.bedrockAuthApiKey
: dict.modelConfig.bedrockAuthAccessKey}
</button>
))}
</fieldset>
{/* Both kinds kept (saved before the choice): the
access keys sit unused behind the API key */}
{bedrockFilled?.apiKey && bedrockFilled.accessKey && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.bedrockBothSaved}{" "}
<button
type="button"
disabled={disabled}
onClick={() => {
clearSecrets?.([
"awsAccessKeyId",
"awsSecretAccessKey",
])
// This note goes away with the keys
document
.getElementById("bedrock-api-key")
?.focus()
}}
className="font-medium text-foreground underline underline-offset-2"
>
{dict.modelConfig.removeAccessKeys}
</button>
</p>
)}
{bedrockAuth === "apiKey" ? (
<div className="space-y-2">
<Label
htmlFor="bedrock-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.bedrockApiKey}
</Label>
{renderSecret({
field: "apiKey",
id: "bedrock-api-key",
})}
</div>
) : (
<>
<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>
<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>
{sessionTokenInput && (
<div className="space-y-2">
<Label
htmlFor="aws-session-token"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSessionToken}
</Label>
{sessionTokenInput}
<p className="text-xs text-muted-foreground">
{
dict.modelConfig
.awsSessionTokenHint
}
</p>
</div>
)}
</>
)}
{regionField}
</>
) : provider === "vertexai" ? (
<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>
) : (
apiKeyField
)}
<div className="space-y-4">
<button
type="button"
onClick={() => setOptionsOpen(!optionsOpen)}
aria-expanded={optionsOpen}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform",
optionsOpen && "rotate-90",
)}
/>
{provider === "bedrock"
? dict.modelConfig.moreOptionsName
: dict.modelConfig.moreOptions}
</button>
{optionsOpen && (
<>
{provider !== "bedrock" &&
(provider === "vertexai"
? vertexBaseUrlField
: baseUrlField)}
{nameField}
</>
)}
</div>
{footer}
</div>
)
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Display Name */} {nameField}
<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" ? ( {provider === "bedrock" ? (
<> <>
@@ -166,37 +508,7 @@ export function ProviderCredentialsFields({
})} })}
</div> </div>
{/* AWS Region */} {regionField}
<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" ? ( ) : provider === "vertexai" ? (
<> <>
@@ -215,104 +527,12 @@ export function ProviderCredentialsFields({
})} })}
</div> </div>
{/* Base URL (optional) */} {vertexBaseUrlField}
<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, with a link to where the provider issues keys */} {apiKeyField}
<div className="space-y-2"> {baseUrlField}
<div className="flex items-center justify-between">
<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>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{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)
}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(
e.target.value,
)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{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>
</> </>
)} )}
-12
View File
@@ -85,18 +85,6 @@ export function QuotaLimitToast({
{/* Message */} {/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2"> <div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>{quotaMessage}</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 <p
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: tipHtml, __html: tipHtml,
+271 -214
View File
@@ -1,11 +1,11 @@
import { import {
Github, Github,
Info, Info,
KeyRound,
Monitor, Monitor,
Moon, Moon,
Palette, PenTool,
SlidersHorizontal, Settings2,
Sparkles,
Sun, Sun,
Terminal, Terminal,
} from "lucide-react" } from "lucide-react"
@@ -14,6 +14,7 @@ import { useEffect, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { useChatEngine } from "@/components/chat/chat-engine" import { useChatEngine } from "@/components/chat/chat-engine"
import { ModelConfigDialog } from "@/components/model-config-dialog" import { ModelConfigDialog } from "@/components/model-config-dialog"
import { SettingsHeader } from "@/components/settings/settings-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -130,7 +131,15 @@ function Segmented<T extends string>({
) )
} }
function AppearanceTab() { function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h3 className="pt-6 pb-1 text-[11px] font-medium text-muted-foreground first:pt-0">
{children}
</h3>
)
}
function GeneralTab({ open }: { open: boolean }) {
const dict = useDictionary() const dict = useDictionary()
const t = dict.settings const t = dict.settings
const engine = useChatEngine() const engine = useChatEngine()
@@ -139,99 +148,10 @@ function AppearanceTab() {
const search = useSearchParams() const search = useSearchParams()
const theme = useSettingsStore((s) => s.theme) const theme = useSettingsStore((s) => s.theme)
const setTheme = useSettingsStore((s) => s.setTheme) const setTheme = useSettingsStore((s) => s.setTheme)
const minimalStyle = useSettingsStore((s) => s.minimalStyle) const sendShortcut = useSettingsStore((s) => s.sendShortcut)
const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle) const setSendShortcut = useSettingsStore((s) => s.setSendShortcut)
const currentLang = const currentLang =
(pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale (pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale
// The page mounts anew in the other language: the chat is saved first
// (it may not be yet), and its session goes into the new URL
const changeLanguage = (lang: string) =>
engine.leavePage((sessionId) => {
localStorage.setItem(STORAGE_KEYS.locale, lang)
// The page reloads in the new language; close settings as before
useUiStore.getState().setSettingsOpen(false)
// Keep the desktop app's menu language in sync
window.electronAPI
?.setUserLocale?.(lang)
.catch((error: unknown) => {
console.error("Failed to sync locale with Electron:", error)
})
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const params = new URLSearchParams(search?.toString())
if (sessionId) params.set("session", sessionId)
const query = params.toString() ? `?${params.toString()}` : ""
router.push((parts.join("/") || "/") + query)
})
return (
<div className="divide-y divide-border">
<Row label={t.theme} description={t.themeDescription}>
<Segmented<ThemePreference>
label={t.theme}
value={theme}
onChange={setTheme}
options={[
{ value: "light", label: t.themeLight, icon: <Sun /> },
{
value: "dark",
label: t.themeDarkMode,
icon: <Moon />,
},
{
value: "system",
label: t.themeSystem,
icon: <Monitor />,
},
]}
/>
</Row>
<Row label={t.language} description={t.languageDescription}>
<Select
value={currentLang}
onValueChange={changeLanguage}
// Not while an answer runs: the page mounts anew
disabled={engine.isBusy}
>
<SelectTrigger
id="language-select"
className="h-8 w-[132px] rounded-lg"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{i18n.locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</Row>
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
<Segmented<"styled" | "minimal">
label={t.diagramStyle}
value={minimalStyle ? "minimal" : "styled"}
onChange={(value) => setMinimalStyle(value === "minimal")}
options={[
{ value: "styled", label: dict.chat.styledMode },
{ value: "minimal", label: dict.chat.minimalStyle },
]}
/>
</Row>
</div>
)
}
function AdvancedTab({ open }: { open: boolean }) {
const dict = useDictionary()
const t = dict.settings
const settings = useSettingsStore()
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState( const [accessCodeRequired, setAccessCodeRequired] = useState(
() => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true", () => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true",
@@ -269,6 +189,31 @@ function AdvancedTab({ open }: { open: boolean }) {
}) })
}, [open]) }, [open])
// The page mounts anew in the other language: the chat is saved first
// (it may not be yet), and its session goes into the new URL
const changeLanguage = (lang: string) =>
engine.leavePage((sessionId) => {
localStorage.setItem(STORAGE_KEYS.locale, lang)
// The page reloads in the new language; close settings as before
useUiStore.getState().setSettingsOpen(false)
// Keep the desktop app's menu language in sync
window.electronAPI
?.setUserLocale?.(lang)
.catch((error: unknown) => {
console.error("Failed to sync locale with Electron:", error)
})
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const params = new URLSearchParams(search?.toString())
if (sessionId) params.set("session", sessionId)
const query = params.toString() ? `?${params.toString()}` : ""
router.push((parts.join("/") || "/") + query)
})
const saveAccessCode = async () => { const saveAccessCode = async () => {
setAccessError("") setAccessError("")
setIsVerifying(true) setIsVerifying(true)
@@ -320,65 +265,212 @@ function AdvancedTab({ open }: { open: boolean }) {
} }
return ( return (
<div className="divide-y divide-border"> <div>
{accessCodeRequired && ( <SectionHeading>{t.sectionInterface}</SectionHeading>
<Row <div className="divide-y divide-border">
label={t.accessCode} <Row label={t.theme} description={t.themeDescription}>
description={t.accessCodeDescription} <Segmented<ThemePreference>
htmlFor="access-code" label={t.theme}
stacked value={theme}
> onChange={setTheme}
<div className="flex gap-2"> options={[
<Input {
id="access-code" value: "light",
type="password" label: t.themeLight,
value={accessCode} icon: <Sun />,
onChange={(e) => setAccessCode(e.target.value)} },
onKeyDown={(e) => { {
if (e.key === "Enter") { value: "dark",
e.preventDefault() label: t.themeDarkMode,
saveAccessCode() icon: <Moon />,
} },
}} {
placeholder={t.accessCodePlaceholder} value: "system",
autoComplete="off" label: t.themeSystem,
className="h-9" icon: <Monitor />,
/> },
<Button ]}
onClick={saveAccessCode} />
disabled={isVerifying || !accessCode.trim()}
className="h-9 rounded-lg px-4"
>
{isVerifying ? "…" : dict.common.save}
</Button>
</div>
{accessError && (
<p className="text-xs text-destructive">
{accessError}
</p>
)}
</Row> </Row>
)} <Row
<Row label={t.sendShortcut} description={t.sendShortcutDescription}> label={t.language}
<Select description={t.languageDescription}
value={settings.sendShortcut} htmlFor="language-select"
onValueChange={(value) =>
settings.setSendShortcut(value as SendShortcut)
}
> >
<SelectTrigger <Select
id="send-shortcut-select" value={currentLang}
className="h-8 w-auto rounded-lg" onValueChange={changeLanguage}
// Not while an answer runs: the page mounts anew
disabled={engine.isBusy}
> >
<SelectValue /> <SelectTrigger
</SelectTrigger> id="language-select"
<SelectContent> className="h-8 w-[132px] rounded-lg"
<SelectItem value="enter">{t.enterToSend}</SelectItem> >
<SelectItem value="ctrl-enter"> <SelectValue />
{t.ctrlEnterToSend} </SelectTrigger>
</SelectItem> <SelectContent>
</SelectContent> {i18n.locales.map((locale) => (
</Select> <SelectItem key={locale} value={locale}>
{LANGUAGE_LABELS[locale]}
</SelectItem>
))}
</SelectContent>
</Select>
</Row>
<Row
label={t.sendShortcut}
description={t.sendShortcutDescription}
htmlFor="send-shortcut-select"
>
<Select
value={sendShortcut}
onValueChange={(value) =>
setSendShortcut(value as SendShortcut)
}
>
<SelectTrigger
id="send-shortcut-select"
className="h-8 w-auto rounded-lg"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">
{t.enterToSend}
</SelectItem>
<SelectItem value="ctrl-enter">
{t.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</Row>
</div>
{(accessCodeRequired || isElectron) && (
<>
<SectionHeading>{t.sectionAccess}</SectionHeading>
<div className="divide-y divide-border">
{accessCodeRequired && (
<Row
label={t.accessCode}
description={t.accessCodeDescription}
htmlFor="access-code"
stacked
>
<div className="flex gap-2">
<Input
id="access-code"
type="password"
value={accessCode}
onChange={(e) =>
setAccessCode(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
saveAccessCode()
}
}}
placeholder={t.accessCodePlaceholder}
autoComplete="off"
className="h-9"
/>
<Button
onClick={saveAccessCode}
disabled={
isVerifying || !accessCode.trim()
}
className="h-9 rounded-lg px-4"
>
{isVerifying ? "…" : dict.common.save}
</Button>
</div>
{accessError && (
<p className="text-xs text-destructive">
{accessError}
</p>
)}
</Row>
)}
{isElectron && (
<Row
label={t.proxy}
description={t.proxyDescription}
stacked
>
<div className="flex gap-2">
<Input
id="http-proxy"
value={httpProxy}
onChange={(e) =>
setHttpProxy(e.target.value)
}
placeholder={`${t.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
value={httpsProxy}
onChange={(e) =>
setHttpsProxy(e.target.value)
}
placeholder={`${t.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
<Button
variant="outline"
onClick={applyProxy}
disabled={isApplyingProxy}
className="h-9 rounded-lg px-4"
>
{isApplyingProxy ? "…" : t.applyProxy}
</Button>
</div>
</Row>
)}
</div>
</>
)}
</div>
)
}
function DrawingTab() {
const dict = useDictionary()
const t = dict.settings
const settings = useSettingsStore()
return (
<div className="divide-y divide-border">
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
<Segmented<"styled" | "minimal">
label={t.diagramStyle}
value={settings.minimalStyle ? "minimal" : "styled"}
onChange={(value) =>
settings.setMinimalStyle(value === "minimal")
}
options={[
{ value: "styled", label: dict.chat.styledMode },
{ value: "minimal", label: dict.chat.minimalStyle },
]}
/>
</Row>
<Row
label={t.customSystemMessage}
description={t.customSystemMessageDescription}
htmlFor="custom-system-message"
stacked
>
<Textarea
id="custom-system-message"
value={settings.customSystemMessage}
onChange={(e) =>
settings.setCustomSystemMessage(e.target.value)
}
placeholder={t.customSystemMessagePlaceholder}
className="max-h-[180px] min-h-[96px] text-sm"
maxLength={5000}
/>
</Row> </Row>
<Row <Row
label={t.diagramValidation} label={t.diagramValidation}
@@ -408,50 +500,6 @@ function AdvancedTab({ open }: { open: boolean }) {
className="h-8 w-28 text-sm" className="h-8 w-28 text-sm"
/> />
</Row> </Row>
<Row
label={t.customSystemMessage}
description={t.customSystemMessageDescription}
htmlFor="custom-system-message"
stacked
>
<Textarea
id="custom-system-message"
value={settings.customSystemMessage}
onChange={(e) =>
settings.setCustomSystemMessage(e.target.value)
}
placeholder={t.customSystemMessagePlaceholder}
className="max-h-[180px] min-h-[96px] text-sm"
maxLength={5000}
/>
</Row>
{isElectron && (
<Row label={t.proxy} description={t.proxyDescription} stacked>
<div className="space-y-2">
<Input
id="http-proxy"
value={httpProxy}
onChange={(e) => setHttpProxy(e.target.value)}
placeholder={`${t.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
value={httpsProxy}
onChange={(e) => setHttpsProxy(e.target.value)}
placeholder={`${t.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
<Button
onClick={applyProxy}
disabled={isApplyingProxy}
className="h-9 w-full rounded-lg"
>
{isApplyingProxy ? "…" : t.applyProxy}
</Button>
</div>
</Row>
)}
</div> </div>
) )
} }
@@ -465,7 +513,7 @@ function AboutTab() {
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}` const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
return ( return (
<div className="space-y-5 pt-1"> <div className="space-y-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<BrandMark className="size-10 rounded-xl" /> <BrandMark className="size-10 rounded-xl" />
<div> <div>
@@ -534,20 +582,20 @@ export function SettingsDialog() {
const setTab = useUiStore((s) => s.setSettingsTab) const setTab = useUiStore((s) => s.setSettingsTab)
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "models", label: t.tabModels, icon: <KeyRound /> }, { id: "models", label: t.tabModels, icon: <Sparkles /> },
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> }, { id: "general", label: t.tabGeneral, icon: <Settings2 /> },
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> }, { id: "drawing", label: t.tabDrawing, icon: <PenTool /> },
{ id: "about", label: t.tabAbout, icon: <Info /> }, { id: "about", label: t.tabAbout, icon: <Info /> },
] ]
const current = tabs.find((item) => item.id === tab) ?? tabs[0] const current = tabs.find((item) => item.id === tab) ?? tabs[0]
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl lg:flex-row"> <DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl md:flex-row">
{/* On top below 1024 px: beside the model settings' own {/* On top below 768 px, where beside the page it would leave
provider list it would leave too little width */} too little width */}
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 lg:w-52 lg:flex-col lg:overflow-visible lg:border-r lg:border-b-0 lg:p-3"> <nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 md:w-48 md:flex-col md:overflow-visible md:border-r md:border-b-0 md:p-3">
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block"> <DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] md:block">
{t.title} {t.title}
</DialogTitle> </DialogTitle>
{tabs.map((item) => ( {tabs.map((item) => (
@@ -557,9 +605,12 @@ export function SettingsDialog() {
onClick={() => setTab(item.id)} onClick={() => setTab(item.id)}
className={cn( className={cn(
"flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4", "flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4",
tab === item.id && current.id === item.id &&
"bg-card font-medium text-foreground shadow-float", "bg-card font-medium text-foreground shadow-float",
)} )}
aria-current={
current.id === item.id ? "page" : undefined
}
data-testid={`settings-tab-${item.id}`} data-testid={`settings-tab-${item.id}`}
> >
{item.icon} {item.icon}
@@ -571,20 +622,26 @@ export function SettingsDialog() {
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
{t.description} {t.description}
</DialogDescription> </DialogDescription>
{tab === "models" ? ( {current.id === "models" ? (
<ModelConfigDialog <ModelConfigDialog
open={open} open={open}
modelConfig={engine.modelConfig} modelConfig={engine.modelConfig}
/> />
) : ( ) : (
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-5 pb-6 scrollbar-thin"> <>
<h2 className="mb-2 text-[15px] font-semibold"> <SettingsHeader>
{current.label} <h2 className="font-semibold">
</h2> {current.label}
{tab === "appearance" && <AppearanceTab />} </h2>
{tab === "advanced" && <AdvancedTab open={open} />} </SettingsHeader>
{tab === "about" && <AboutTab />} <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin">
</div> {current.id === "general" && (
<GeneralTab open={open} />
)}
{current.id === "drawing" && <DrawingTab />}
{current.id === "about" && <AboutTab />}
</div>
</>
)} )}
</section> </section>
</DialogContent> </DialogContent>
+144
View File
@@ -0,0 +1,144 @@
import { ChevronRight, Search } from "lucide-react"
import { useState } from "react"
import { ProviderLogo } from "@/components/provider-logo"
import { SettingsHeader } from "@/components/settings/settings-header"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { filterProviderGroups, PROVIDER_NOTE_KEYS } from "@/lib/provider-setup"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
/** Every provider in groups, with a search; picking one adds it */
export function ProviderPicker({
added,
onPick,
onBack,
}: {
/** Provider types the user has already added */
added: ProviderName[]
onPick: (provider: ProviderName) => void
onBack: () => void
}) {
const dict = useDictionary()
const t = dict.modelConfig
const [query, setQuery] = useState("")
const groups = filterProviderGroups(query)
const groupLabels: Record<string, string> = {
vendors: t.groupVendors,
aggregators: t.groupAggregators,
cloud: t.groupCloud,
selfHosted: t.groupSelfHosted,
}
const notes = t as Record<string, string>
return (
<div className="flex min-h-0 flex-1 flex-col">
<SettingsHeader>
<button
type="button"
onClick={onBack}
className="rounded text-muted-foreground hover:text-foreground"
>
{t.models}
</button>
<ChevronRight className="size-3.5 text-muted-foreground/60" />
<h2
className="font-semibold outline-none"
tabIndex={-1}
data-page-title
>
{t.addProvider}
</h2>
</SettingsHeader>
<div className="px-6 pb-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<input
// The page is opened to search it
autoFocus
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
// Enter that confirms an IME candidate picks nothing
if (
e.nativeEvent.isComposing ||
e.keyCode === 229
) {
return
}
// Enter picks the first match
const first = groups[0]?.providers[0]
if (e.key === "Enter" && query.trim() && first) {
e.preventDefault()
onPick(first)
}
}}
placeholder={t.searchProviders}
aria-label={t.searchProviders}
className="h-10 w-full rounded-xl border border-border bg-background pr-3 pl-9 text-[13px] outline-none placeholder:text-muted-foreground focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/20"
/>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="px-6 pb-6">
{groups.length === 0 && (
<p className="py-8 text-center text-[13px] text-muted-foreground">
{t.noProvidersFound}
</p>
)}
{groups.map((group) => (
<section key={group.id} className="mb-3.5">
<h3 className="mb-1.5 text-[11px] font-medium text-muted-foreground">
{groupLabels[group.id]}
</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-2">
{group.providers.map((provider) => {
const noteKey = PROVIDER_NOTE_KEYS[provider]
const note = [
noteKey && notes[noteKey],
added.includes(provider) && t.added,
]
.filter(Boolean)
.join(" · ")
return (
<button
key={provider}
type="button"
onClick={() => onPick(provider)}
data-testid={`pick-provider-${provider}`}
className="flex h-[46px] min-w-0 items-center gap-2.5 rounded-xl border border-border px-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ProviderLogo
provider={provider}
className="shrink-0"
/>
<span className="min-w-0 leading-tight">
<span
className="block truncate text-[12.5px] font-medium"
title={
PROVIDER_INFO[provider]
.label
}
>
{
PROVIDER_INFO[provider]
.label
}
</span>
{note && (
<span className="block truncate text-[11px] text-muted-foreground">
{note}
</span>
)}
</span>
</button>
)
})}
</div>
</section>
))}
</div>
</ScrollArea>
</div>
)
}
+271
View File
@@ -0,0 +1,271 @@
import {
AlertCircle,
CheckCircle2,
ChevronRight,
CircleDashed,
CircleDotDashed,
Lock,
Plus,
Server,
} from "lucide-react"
import { shortModelName } from "@/components/model-selector"
import { ProviderLogo } from "@/components/provider-logo"
import { SettingsHeader } from "@/components/settings/settings-header"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { POPULAR_PROVIDERS, providerStatus } from "@/lib/provider-setup"
import {
type FlattenedModel,
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
function InUse() {
const dict = useDictionary()
return (
<span className="rounded bg-primary px-1.5 py-0.5 text-[11px] font-medium text-primary-foreground">
{dict.modelConfig.inUse}
</span>
)
}
/** The models tab's first page: the user's providers and the server's models */
export function ProvidersList({
providers,
serverModels,
selectedModelId,
onOpen,
onAdd,
onBrowse,
}: {
providers: ProviderConfig[]
serverModels: FlattenedModel[]
selectedModelId: string | undefined
onOpen: (providerId: string) => void
onAdd: (provider: ProviderName) => void
/** Shows every provider to pick from */
onBrowse: () => void
}) {
const dict = useDictionary()
const t = dict.modelConfig
const userProviderInUse = providers.find((p) =>
p.models.some((m) => m.id === selectedModelId),
)
// No model chosen is the server's default model
const serverInUse =
!selectedModelId || serverModels.some((m) => m.id === selectedModelId)
const names = serverModels
.slice(0, 3)
.map((m) => shortModelName(m.modelId))
.join(t.listSeparator)
const serverCard = (serverModels.length > 0 || serverInUse) && (
<div className="flex items-center gap-3 rounded-xl border border-border-subtle bg-surface-1 px-4 py-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border-subtle bg-surface-0">
<Server className="size-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[13px] font-medium">
{t.serverModelsTitle}
{serverInUse && <InUse />}
</div>
<p className="truncate text-xs text-muted-foreground">
{serverModels.length > 0
? formatMessage(
serverModels.length === 1
? t.serverModelsSummaryOne
: t.serverModelsSummaryOther,
{
count: serverModels.length,
names:
serverModels.length > 3
? `${names}…`
: names,
},
)
: t.serverDefaultSummary}
</p>
</div>
</div>
)
const statusText = (p: ProviderConfig) => {
const status = providerStatus(p)
switch (status.kind) {
case "error":
return (
<span className="flex items-center gap-1 text-destructive">
<AlertCircle className="size-3.5" />
{formatMessage(t.statusError, { count: status.count })}
</span>
)
case "incomplete":
return (
<span className="flex items-center gap-1 text-amber-700 dark:text-amber-400">
<CircleDotDashed className="size-3.5" />
{t.statusIncomplete}
</span>
)
case "untested":
return (
<span className="flex items-center gap-1 text-muted-foreground">
<CircleDashed className="size-3.5" />
{formatMessage(t.statusUntested, {
count: status.count,
})}
</span>
)
case "ok":
return (
<span className="flex items-center gap-1 text-success">
<CheckCircle2 className="size-3.5" />
{formatMessage(t.statusOk, { count: status.count })}
</span>
)
}
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<SettingsHeader>
<h2
className="font-semibold outline-none"
tabIndex={-1}
data-page-title
>
{t.models}
</h2>
</SettingsHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-6 px-6 pb-6">
{providers.length === 0 ? (
<>
{serverCard}
<section className="space-y-3">
<div>
<h3 className="text-[13px] font-semibold">
{t.useOwnKey}
</h3>
<p className="text-xs text-muted-foreground">
{t.useOwnKeyHint}
</p>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(170px,1fr))] gap-2.5">
{POPULAR_PROVIDERS.map((provider) => (
<button
key={provider}
type="button"
onClick={() => onAdd(provider)}
data-testid={`pick-provider-${provider}`}
className="flex h-14 items-center gap-3 rounded-xl border border-border px-4 text-left text-[13px] font-medium transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
<ProviderLogo
provider={provider}
/>
</span>
<span className="truncate">
{PROVIDER_INFO[provider].label}
</span>
</button>
))}
</div>
<button
type="button"
onClick={onBrowse}
className="flex items-center gap-1 text-[13px] font-medium hover:underline"
>
{formatMessage(t.viewAllProviders, {
count: Object.keys(PROVIDER_INFO)
.length,
})}
<ChevronRight className="size-3.5" />
</button>
</section>
</>
) : (
<>
<section className="space-y-2.5">
<div className="flex items-center justify-between">
<h3 className="text-[13px] font-semibold">
{t.myProviders}
</h3>
<Button
variant="outline"
size="sm"
onClick={onBrowse}
className="h-8 gap-1.5 rounded-lg text-xs"
>
<Plus className="size-3.5" />
{t.addProvider}
</Button>
</div>
<div className="divide-y divide-border-subtle rounded-xl border border-border">
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onOpen(p.id)}
data-testid={`provider-row-${p.id}`}
className={cn(
"flex min-h-[60px] w-full items-center gap-3 px-4 py-2.5 text-left transition-colors first:rounded-t-xl last:rounded-b-xl",
"hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
<ProviderLogo
provider={p.provider}
/>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-[13px] font-medium">
<span className="truncate">
{p.name ||
PROVIDER_INFO[
p.provider
].label}
</span>
{userProviderInUse?.id ===
p.id && <InUse />}
</span>
<span className="block truncate font-mono text-xs text-muted-foreground">
{p.models.length > 0 ? (
p.models
.map(
(m) =>
m.modelId,
)
.join(
t.listSeparator,
)
) : (
<span className="font-sans">
{t.noModelsYet}
</span>
)}
</span>
</span>
<span className="shrink-0 text-xs">
{statusText(p)}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
</button>
))}
</div>
</section>
{serverCard}
</>
)}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="size-3.5 shrink-0" />
{t.keysStayLocal}
</p>
</div>
</ScrollArea>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
/** A settings page's title row; the dialog's close button sits at its end */
export function SettingsHeader({ children }: { children: React.ReactNode }) {
return (
<header className="flex h-14 shrink-0 items-center gap-1.5 pr-14 pl-6 text-[15px]">
{children}
</header>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react" import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage" import { CanvasStage } from "@/components/canvas/canvas-stage"
@@ -100,7 +100,7 @@ function StartBar({
onClick={() => openSettings()} onClick={() => openSettings()}
data-testid="settings-button" data-testid="settings-button"
> >
<Settings2 /> <Settings />
</IconButton> </IconButton>
</div> </div>
) )
+77 -182
View File
@@ -1,107 +1,98 @@
# Next AI Draw.io
<div align="center"> <div align="center">
**AI驱动的图表创建工具 - 对话、绘制、可视化** # Next AI Draw.io
**和 AI 对话,画出并修改 draw.io 图表。**
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md) [English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [**在线体验**](https://next-ai-drawio.jiang.jp/) · [**桌面应用**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP 服务器**](#在-ai-代理里使用mcp)
</div> </div>
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。 https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
> 注:感谢 <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 模型! 用一句话描述想要的图,AI 就在真正的 draw.io 画布上把它画出来。你可以像编辑任何 draw.io 文件一样手动修改,也可以选中几个图形、告诉 AI 要改什么。AI 的每次修改都是一个版本,可以对比、恢复或撤销;结果可以导出为 `.drawio`、`.png` 或 `.svg`。
<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> 提供网页版、Windows / macOS / Linux 桌面版,以及供 Claude Code、Cursor、VS Code 等 AI 代理调用的 MCP 服务器。
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979 ## 亮点
## 目录 **画**
- [Next AI Draw.io](#next-ai-drawio)
- [目录](#目录) - 一句话生成架构图、流程图、时序图等,内置 AWS、Azure、GCP、Kubernetes 等图标库
- [示例](#示例) - 连接线可以带流动动画
- [功能特性](#功能特性) - 上传截图或手绘图,让 AI 照着画;上传 PDF、Markdown、代码等文本文件,从内容生成图
- [MCP服务器](#mcp服务器)
- [Claude Code CLI](#claude-code-cli) **改**
- [快速开始](#快速开始)
- [在线试用](#在线试用) - 用对话修改:改动流式呈现,刚改过的图形在画布上高亮
- [桌面应用](#桌面应用) - 选中图形提问:在画布上选中几个图形,AI 只改这几个
- [使用Docker运行](#使用docker运行) - 版本与撤销:每次 AI 修改都是一张带缩略图的版本卡片,可以和画布对比、恢复、撤销;画布上按 Ctrl+Z 也能一步撤回 AI 的修改
- [安装](#安装) - 它就是普通的 draw.io:双击改名、拖动、调样式、多页,随时导出 `.drawio`、`.png`、`.svg`、`.drawio.svg`
- [部署](#部署)
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages) **用**
- [部署到Vercel](#部署到vercel)
- [部署到Cloudflare Workers](#部署到cloudflare-workers) - 支持 24 家模型服务商,可以在浏览器里填自己的 API Key,Key 只保存在本地
- [多提供商支持](#多提供商支持) - 支持推理的模型会显示思考过程
- [工作原理](#工作原理) - 深色模式;界面有英文、简体中文、繁体中文、日文
- [支持与联系](#支持与联系)
- [常见问题](#常见问题)
- [Star历史](#star历史)
## 示例 ## 示例
以下是一些示例提示词及其生成的图表:
<div align="center"> <div align="center">
<table width="100%"> <table width="100%">
<tr> <tr>
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br /> <strong>带动画连接线的 Transformer 架构</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p> <p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" /> <img src="../../public/animated_connectors.svg" alt="带动画连接线的 Transformer 架构" width="440" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>RAG技术图</strong><br /> <strong>RAG 架构图</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" /> <img src="../../public/rag_prod.svg" alt="RAG 架构图" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>React和AWS认证流程</strong><br /> <strong>React 加 AWS 的认证流程</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p> <p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="认证架构图" width="480" /> <img src="../../public/auth.svg" alt="认证架构图" width="400" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>开放式创新</strong><br /> <strong>开放式创新模型</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p> <p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="开放式创新图" width="480" /> <img src="../../public/inno.svg" alt="开放式创新图" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>猫咪素描</strong><br /> <strong>猫咪素描</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" /> <img src="../../public/cat_demo.svg" alt="猫咪绘图" width="200" />
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
## 功能特性 ## 使用方式
- **LLM驱动的图表创建**:利用大语言模型通过自然语言命令直接创建和操作draw.io图表 ### 在线体验
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
- **版本与撤销**:AI 每改一次图,聊天里就多一张带缩略图的版本卡片,可以和画布对比、恢复,也可以撤销这次修改;在画布上按 Ctrl+Z 也能一步撤回 AI 的修改。AI 刚改过的图形会在画布上标出来
- **针对选中图形提问**:在画布上选中图形,让 AI 只改这几个
- **交互式聊天界面**:与AI实时对话来完善您的图表
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器 打开 [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/) 即可使用,无需安装。演示站有用量限制;点击聊天面板里的设置图标,填入自己的服务商和 API Key 就不受限制。Key 只保存在浏览器本地,不会上传到服务器。
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。 ### 桌面应用
在 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载 Windows、macOS 或 Linux 安装包。
### 在 AI 代理里使用(MCP)
通过 MCP(Model Context Protocol,让 AI 代理调用外部工具的协议),Claude Desktop、Cursor、VS Code 等都能直接画 draw.io 图。在客户端的 MCP 配置里加上:
```json ```json
{ {
@@ -114,157 +105,61 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
} }
``` ```
### Claude Code CLI Claude Code 用一行命令:
```bash ```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
``` ```
然后让Claude创建图表: 然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
> "创建一个展示用户认证流程的流程图,包含登录、MFA和会话管理"
图表会实时显示在浏览器中! - 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
- 截图工具,AI 可以看一眼画好的图并自行修正
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
MCP服务器包含网页版的大部分画图功能: VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等) ## 自己部署
- 截图工具,AI可以查看画好的图并自行修正
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。 ### 本地运行
## 快速开始
### 在线试用
无需安装!直接在我们的演示站点试用:
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
### 桌面应用
从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用:
支持的平台:Windows、macOS、Linux。
### 使用Docker运行
[查看 Docker 指南](./docker.md)
### 安装
1. 克隆仓库:
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
npm install npm install
cp env.example .env.local cp env.example .env.local # 填入服务商和 API Key,见下文"模型与服务商"
```
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
2. 运行开发服务器:
```bash
npm run dev npm run dev
``` ```
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。 打开 [http://localhost:6002](http://localhost:6002)。
## 部署 ### 一键部署
### 部署到腾讯云EdgeOne Pages | 平台 | 方式 |
| --- | --- |
| 腾讯云 EdgeOne Pages | [![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) 部署后还能获得[每日免费的 DeepSeek 模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888) |
| Vercel | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) 记得在 Vercel 控制台里设置和 `.env.local` 相同的环境变量 |
| Cloudflare Workers | [Cloudflare 部署指南](./cloudflare-deploy.md) |
| Docker | [Docker 指南](./docker.md) |
| 离线或内网 | [离线部署说明](./offline-deployment.md) |
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。 ### 模型与服务商
直接点击此按钮一键部署: 支持 AWS Bedrock(默认)、OpenAI、Anthropic、Google AI、Google Vertex AI、Azure OpenAI、Ollama、OpenRouter、AIHubMix、DeepSeek、SiliconFlow、SGLang、Vercel AI Gateway、腾讯云 EdgeOne、字节跳动豆包、ModelScope、智谱 GLM、通义千问、七牛云、Kimi、MiniMax、Novita、小米 MiMo、Atlas Cloud 共 24 家。各家的环境变量和注意事项见[服务商配置指南](./ai-providers.md)。
[![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。 **选哪个模型**:这项任务要生成格式严格的长文本(draw.io XML),请选能力较强的模型,小模型容易画出错误的图。
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。 **多模型与管理面板**:在 `AI_MODEL` 里用逗号列出多个模型 ID,或用 `AI_MODELS_CONFIG` 环境变量 / `ai-models.json` 文件配置多家服务商的模型,所有用户无需自带 Key 即可使用。设置 `ADMIN_PASSWORD` 后访问 `/admin`,可以在网页里管理模型、访问码、功能开关、可观测性和配额,见[管理面板指南](./admin-panel.md)。
### 部署到Vercel ## 支持
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) - 问题和建议:提交 [GitHub Issue](https://github.com/DayuanJiang/next-ai-draw-io/issues),或发邮件到 me[at]jiang.jp
- 常见问题:[FAQ](./FAQ.md)
- 如果这个项目对你有用,欢迎[赞助](https://github.com/sponsors/DayuanJiang),帮助我维持演示站点的运行
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。 <div align="center">
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
### 部署到Cloudflare Workers
[查看 Cloudflare 部署指南](./cloudflare-deploy.md)
## 多提供商支持
- [字节跳动豆包](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
- Google AI
- Google Vertex AI
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 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)** — 启用方法、优先级规则和注意事项。
## 工作原理
本应用使用以下技术:
- **Next.js**:用于前端框架和路由
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
- **draw.io**:编辑器使用打包在 `public/drawio` 里的副本(由 `npm run dev` / `npm run build` 自动下载),应用可以直接控制它
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
## 支持与联系
**特别感谢[字节跳动豆包](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)来帮助我托管在线演示站点!
如需支持或咨询,请在GitHub仓库上提交issue或联系维护者:
- 邮箱:me[at]jiang.jp
## 常见问题
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
## Star历史
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
--- </div>
+1 -1
View File
@@ -9,7 +9,7 @@
## 可配置内容 ## 可配置内容
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。 1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,凭据输入框与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。 2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
## 优先级 ## 优先级
-2
View File
@@ -13,8 +13,6 @@
### 豆包 (字节跳动火山引擎) ### 豆包 (字节跳动火山引擎)
> **免费 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 ```bash
DOUBAO_API_KEY=your_api_key DOUBAO_API_KEY=your_api_key
AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型 AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型
+1 -1
View File
@@ -9,7 +9,7 @@ Instead of hand-editing `.env`, you can manage server settings in a web admin pa
## What you can configure ## 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). 1. **Models** — add providers with their API keys and model lists; the credential fields are the same as in 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"). 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 ## Precedence
-2
View File
@@ -13,8 +13,6 @@ This guide explains how to configure different AI model providers for next-ai-dr
### Doubao (ByteDance Volcengine) ### Doubao (ByteDance Volcengine)
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
```bash ```bash
DOUBAO_API_KEY=your_api_key DOUBAO_API_KEY=your_api_key
AI_MODEL=doubao-seed-1-8-251215 # or other Doubao model AI_MODEL=doubao-seed-1-8-251215 # or other Doubao model
+78 -182
View File
@@ -1,105 +1,98 @@
# Next AI Draw.io
<div align="center"> <div align="center">
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化** # Next AI Draw.io
**AI とチャットしながら draw.io のダイアグラムを描き、直す。**
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語 [English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [**オンラインデモ**](https://next-ai-drawio.jiang.jp/) · [**デスクトップアプリ**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP サーバー**](#ai-エージェントから使うmcp)
</div> </div>
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。 https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
> 注:<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 モデルを導入しました! 欲しいダイアグラムを一文で伝えると、AI が本物の draw.io キャンバスに描きます。普通の draw.io ファイルと同じように手で直すことも、図形をいくつか選んで AI に変更を頼むこともできます。AI の変更はすべてバージョンとして残り、比較・復元・取り消しができます。結果は `.drawio`、`.png`、`.svg` で書き出せます。
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979 Web アプリ、Windows / macOS / Linux 向けデスクトップアプリ、そして Claude Code、Cursor、VS Code などの AI エージェントから呼び出せる MCP サーバーとして使えます。
## 目次 ## 特長
- [Next AI Draw.io](#next-ai-drawio)
- [目次](#目次) **描く**
- [例](#例)
- [機能](#機能) - アーキテクチャ図、フローチャート、シーケンス図などを一文から生成。AWS、Azure、GCP、Kubernetes のアイコンライブラリを内蔵
- [MCPサーバー](#mcpサーバー) - コネクタに流れるアニメーションを付けられる
- [Claude Code CLI](#claude-code-cli) - スクリーンショットや手描きの図をアップロードすると AI が描き直す。PDF、Markdown、コードなどのテキストファイルからも内容を図にできる
- [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す) **直す**
- [デスクトップアプリケーション](#デスクトップアプリケーション)
- [Dockerで実行](#dockerで実行) - チャットで修正:変更はストリーミングでキャンバスに反映され、AI が変更した図形はハイライト表示
- [インストール](#インストール) - 選択して頼む:キャンバスで図形を選ぶと、AI はその図形だけを変更
- [デプロイ](#デプロイ) - バージョンと取り消し:AI の変更ごとにサムネイル付きのバージョンカードが残り、キャンバスと比較・復元・取り消しができる。キャンバス上の Ctrl+Z でも AI の変更を一度で戻せる
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ) - ただの draw.io ダイアグラム:ダブルクリックで名前を変え、ドラッグし、スタイルを変え、複数ページを使い、いつでも `.drawio`、`.png`、`.svg`、`.drawio.svg` に書き出せる
- [Vercelへのデプロイ](#vercelへのデプロイ)
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ) **使う**
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
- [仕組み](#仕組み) - 24 のモデルプロバイダーに対応。自分の API キーをブラウザに入力でき、キーは端末内にだけ保存される
- [サポート&お問い合わせ](#サポートお問い合わせ) - 推論するモデルは思考過程を表示
- [よくある質問](#よくある質問) - ダークモード。UI は英語、簡体字中国語、繁体字中国語、日本語に対応
- [スター履歴](#スター履歴)
## 例 ## 例
以下はいくつかのプロンプト例と生成されたダイアグラムです:
<div align="center"> <div align="center">
<table width="100%"> <table width="100%">
<tr> <tr>
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>アニメーションTransformerコネクタ</strong><br /> <strong>アニメーションコネクタ付き Transformer アーキテクチャ</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p> <p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" /> <img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付き Transformer アーキテクチャ" width="440" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>RAG技術ダイアグラム</strong><br /> <strong>RAG アーキテクチャ</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" /> <img src="../../public/rag_prod.svg" alt="RAG アーキテクチャ図" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>ReactとAWSによる認証</strong><br /> <strong>React と AWS による認証フロー</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p> <p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" /> <img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="400" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>オープンイノベーション</strong><br /> <strong>オープンイノベーションモデル</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p> <p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" /> <img src="../../public/inno.svg" alt="オープンイノベーション図" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>猫のスケッチ</strong><br /> <strong>猫のスケッチ</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" /> <img src="../../public/cat_demo.svg" alt="猫の絵" width="200" />
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
## 機能 ## 使い方
- **LLM搭載のダイアグラム作成**:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作 ### オンラインデモ
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
- **バージョンと取り消し**:AI が図を変更するたびに、サムネイル付きのバージョンカードがチャットに表示されます。キャンバスと比較したり、復元したり、その変更を取り消したりできます。キャンバスで Ctrl+Z を押しても AI の変更を一度に元に戻せます。AI が変更した図形はキャンバス上で強調表示されます
- **選択した図形について依頼**:キャンバスで図形を選択し、その図形だけを AI に直してもらえます
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバー [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/) を開けばすぐ使えます。インストールは不要です。デモサイトには利用上限があります。チャットパネルの設定アイコンから自分のプロバイダーと API キーを入力すると上限なしで使えます。キーはブラウザ内にだけ保存され、サーバーには送られません。
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。 ### デスクトップアプリ
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases) から Windows、macOS、Linux 向けのインストーラーをダウンロードしてください。
### AI エージェントから使う(MCP)
MCP(Model Context Protocol、AI エージェントが外部ツールを呼び出すためのプロトコル)を通して、Claude Desktop、Cursor、VS Code などから直接 draw.io のダイアグラムを描けます。クライアントの MCP 設定に次を追加します。
```json ```json
{ {
@@ -112,158 +105,61 @@ MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS Code
} }
``` ```
### Claude Code CLI Claude Code ならコマンド一つです。
```bash ```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
``` ```
Claudeにダイアグラムの作成を依頼: あとは AI に「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを描いて」と頼めば、描かれていく様子がブラウザに表示されます。MCP サーバーには Web アプリの作図機能のほとんどが入っています。
> 「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを作成してください」
ダイアグラムがリアルタイムでブラウザに表示されます! - 同じ作図ルールとアイコンライブラリ(AWS、Azure、GCP、Kubernetes など)
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
MCPサーバーには、Webアプリの主な作図機能が含まれています: VS Code、Cursor などの設定は [MCP サーバーの README](../../packages/mcp-server/README.md) を参照してください。
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど) ## 自分でホストする
- スクリーンショットツール:AIが描画結果を確認して修正できます
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。 ### ローカルで動かす
## はじめに
### オンラインで試す
インストール不要!デモサイトで直接お試しください:
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> **自分のAPIキーを使用**:自分のAPIキーを使用することで、デモサイトの利用制限を回避できます。チャットパネルの設定アイコンをクリックして、プロバイダーとAPIキーを設定してください。キーはブラウザのローカルに保存され、サーバーには保存されません。
### デスクトップアプリケーション
[Releases ページ](https://github.com/DayuanJiang/next-ai-draw-io/releases)からお使いのプラットフォーム用のネイティブデスクトップアプリをダウンロードしてください:
対応プラットフォーム:Windows、macOS、Linux。
### Dockerで実行
[Docker ガイドを参照](./docker.md)
### インストール
1. リポジトリをクローン:
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
npm install npm install
cp env.example .env.local cp env.example .env.local # プロバイダーと API キーを記入。下の「モデルとプロバイダー」を参照
```
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
2. 開発サーバーを起動:
```bash
npm run dev npm run dev
``` ```
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。 [http://localhost:6002](http://localhost:6002) を開きます。
## デプロイ ### ワンクリックデプロイ
### EdgeOne Pagesへのデプロイ | プラットフォーム | 方法 |
| --- | --- |
| Tencent EdgeOne Pages | [![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) デプロイすると [DeepSeek モデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai) も付きます |
| Vercel | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) `.env.local` と同じ環境変数を Vercel のダッシュボードで設定してください |
| Cloudflare Workers | [Cloudflare デプロイガイド](./cloudflare-deploy.md) |
| Docker | [Docker ガイド](./docker.md) |
| オフラインまたは社内ネットワーク | [オフラインデプロイ](./offline-deployment.md) |
[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。 ### モデルとプロバイダー
このボタンでデプロイ: AWS Bedrock(既定)、OpenAI、Anthropic、Google AI、Google Vertex AI、Azure OpenAI、Ollama、OpenRouter、AIHubMix、DeepSeek、SiliconFlow、SGLang、Vercel AI Gateway、Tencent EdgeOne、ByteDance Doubao、ModelScope、Zhipu GLM、Qwen、Qiniu、Kimi、MiniMax、Novita、Xiaomi MiMo、Atlas Cloud の 24 プロバイダーに対応しています。各プロバイダーの環境変数と注意点は[プロバイダー設定ガイド](./ai-providers.md)にあります。
[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) **どのモデルを選ぶか**:厳密な形式の長いテキスト(draw.io の XML)を生成するタスクなので、能力の高いモデルを選んでください。小さなモデルは壊れた図を出しがちです。
詳細は[Tencent EdgeOne Pagesドキュメント](https://pages.edgeone.ai/document/deployment-overview)をご覧ください。 **複数モデルと管理パネル**:`AI_MODEL` にモデル ID をカンマ区切りで並べるか、`AI_MODELS_CONFIG` 環境変数または `ai-models.json` ファイルで複数プロバイダーのモデルを設定すると、全ユーザーが自分のキーなしで使えます。`ADMIN_PASSWORD` を設定して `/admin` を開くと、モデル、アクセスコード、機能の切り替え、オブザーバビリティ、クォータを Web 画面から管理できます。[管理パネルガイド](./admin-panel.md)を参照してください。
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。 ## サポート
### Vercelへのデプロイ - 質問や提案:[GitHub Issue](https://github.com/DayuanJiang/next-ai-draw-io/issues) を立てるか、me[at]jiang.jp までメールしてください
- よくある問題:[FAQ](./FAQ.md)
- このプロジェクトが役に立ったら、デモサイトの運営のために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) <div align="center">
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
### Cloudflare Workersへのデプロイ
[Cloudflare デプロイガイドを参照](./cloudflare-deploy.md)
## マルチプロバイダーサポート
- [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
- Google AI
- Google Vertex AI
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
📖 **[詳細なプロバイダー設定ガイド](./ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人の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)** — 有効化の方法、優先順位ルール、注意事項。
## 仕組み
本アプリケーションは以下の技術を使用しています:
- **Next.js**:フロントエンドフレームワークとルーティング
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):ストリーミングAIレスポンスとマルチプロバイダーサポート
- **draw.io**:エディターは `public/drawio` に同梱したコピー(`npm run dev` / `npm run build` で自動ダウンロード)で動作し、アプリから直接操作できます
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
## サポート&お問い合わせ
**デモサイトの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)をご検討ください!
サポートやお問い合わせについては、GitHubリポジトリでissueを開くか、メンテナーにご連絡ください:
- メール:me[at]jiang.jp
## よくある質問
一般的な問題と解決策については [FAQ](./FAQ.md) をご覧ください。
## スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
--- </div>
+1 -1
View File
@@ -9,7 +9,7 @@
## 設定できる項目 ## 設定できる項目
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。 1. **Models(モデル)** — プロバイダーの API キーとモデルリストを追加します(認証情報の入力欄はアプリ内のモデル設定と同じです)。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。 2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
## 優先順位 ## 優先順位
-2
View File
@@ -13,8 +13,6 @@
### Doubao (ByteDance Volcengine) ### Doubao (ByteDance Volcengine)
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
```bash ```bash
DOUBAO_API_KEY=your_api_key DOUBAO_API_KEY=your_api_key
AI_MODEL=doubao-seed-1-8-251215 # または他の Doubao モデル AI_MODEL=doubao-seed-1-8-251215 # または他の Doubao モデル
+15 -14
View File
@@ -36,15 +36,19 @@ function migrateOldConfig(): MultiModelConfig | null {
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey) const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
const oldModel = localStorage.getItem(OLD_KEYS.aiModel) const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
// No old config to migrate // No old config to migrate. A local Ollama needs no key.
if (!oldProvider || !oldApiKey || !oldModel) return null if (!oldProvider || !oldModel) return null
if (!oldApiKey && oldProvider !== "ollama") return null
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl) const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
// Create new config from old format // Create new config from old format
const provider = createProviderConfig(oldProvider as ProviderName) const provider = createProviderConfig(oldProvider as ProviderName)
provider.apiKey = oldApiKey provider.apiKey = oldApiKey ?? ""
if (oldBaseUrl) provider.baseUrl = oldBaseUrl if (oldBaseUrl) provider.baseUrl = oldBaseUrl
// Without one, the old version used the server's Ollama (OLLAMA_BASE_URL
// or this machine), not the cloud address new providers get
else if (oldProvider === "ollama") provider.baseUrl = ""
const model = createModelConfig(oldModel) const model = createModelConfig(oldModel)
provider.models.push(model) provider.models.push(model)
@@ -161,11 +165,9 @@ export interface UseModelConfigReturn {
models: FlattenedModel[] models: FlattenedModel[]
selectedModel: FlattenedModel | undefined selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined selectedModelId: string | undefined
showUnvalidatedModels: boolean
// Actions // Actions
setSelectedModelId: (modelId: string | undefined) => void setSelectedModelId: (modelId: string | undefined) => void
setShowUnvalidatedModels: (show: boolean) => void
addProvider: (provider: ProviderName) => ProviderConfig addProvider: (provider: ProviderName) => ProviderConfig
updateProvider: ( updateProvider: (
providerId: string, providerId: string,
@@ -308,13 +310,6 @@ export function useModelConfig(): UseModelConfigReturn {
})) }))
}, []) }, [])
const setShowUnvalidatedModels = useCallback((show: boolean) => {
setConfig((prev) => ({
...prev,
showUnvalidatedModels: show,
}))
}, [])
const addProvider = useCallback( const addProvider = useCallback(
(provider: ProviderName): ProviderConfig => { (provider: ProviderName): ProviderConfig => {
const newProvider = createProviderConfig(provider) const newProvider = createProviderConfig(provider)
@@ -441,9 +436,7 @@ export function useModelConfig(): UseModelConfigReturn {
models, models,
selectedModel, selectedModel,
selectedModelId: config.selectedModelId, selectedModelId: config.selectedModelId,
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
setSelectedModelId, setSelectedModelId,
setShowUnvalidatedModels,
addProvider, addProvider,
updateProvider, updateProvider,
deleteProvider, deleteProvider,
@@ -473,6 +466,8 @@ export function getSelectedAIConfig(): {
selectedModelId: string selectedModelId: string
// Vertex AI credentials (Express Mode) // Vertex AI credentials (Express Mode)
vertexApiKey: string vertexApiKey: string
// The user's provider of the selected model ("" for a server model)
providerId: string
} { } {
const empty = { const empty = {
accessCode: "", accessCode: "",
@@ -486,6 +481,7 @@ export function getSelectedAIConfig(): {
awsSessionToken: "", awsSessionToken: "",
selectedModelId: "", selectedModelId: "",
vertexApiKey: "", vertexApiKey: "",
providerId: "",
} }
if (typeof window === "undefined") return empty if (typeof window === "undefined") return empty
@@ -510,6 +506,7 @@ export function getSelectedAIConfig(): {
awsSessionToken: "", awsSessionToken: "",
selectedModelId: "", selectedModelId: "",
vertexApiKey: "", vertexApiKey: "",
providerId: "",
} }
} }
@@ -566,5 +563,9 @@ export function getSelectedAIConfig(): {
selectedModelId: config.selectedModelId || "", selectedModelId: config.selectedModelId || "",
// Vertex AI credentials (Express Mode) // Vertex AI credentials (Express Mode)
vertexApiKey: model.vertexApiKey || "", vertexApiKey: model.vertexApiKey || "",
providerId:
config.providers.find((p) =>
p.models.some((m) => m.id === config.selectedModelId),
)?.id ?? "",
} }
} }
+133 -125
View File
@@ -740,146 +740,154 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{ {
promptText: "Draw a cat for me", promptText: "Draw a cat for me",
hasImage: false, hasImage: false,
xml: `<mxCell id="2" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1"> xml: `<mxCell id="2" edge="1" parent="1" style="curved=1;endArrow=none;html=1;strokeWidth=14;strokeColor=#F4A460;" value="">
<mxGeometry x="300" y="150" width="120" height="120" as="geometry"/> <mxGeometry relative="1" as="geometry">
</mxCell>
<mxCell id="3" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=30;" vertex="1" parent="1">
<mxGeometry x="280" y="120" width="50" height="60" as="geometry"/>
</mxCell>
<mxCell id="4" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;rotation=-30;" vertex="1" parent="1">
<mxGeometry x="390" y="120" width="50" height="60" as="geometry"/>
</mxCell>
<mxCell id="5" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=30;" vertex="1" parent="1">
<mxGeometry x="290" y="135" width="30" height="35" as="geometry"/>
</mxCell>
<mxCell id="6" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-30;" vertex="1" parent="1">
<mxGeometry x="400" y="135" width="30" height="35" as="geometry"/>
</mxCell>
<mxCell id="7" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="325" y="185" width="15" height="15" as="geometry"/>
</mxCell>
<mxCell id="8" value="" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="380" y="185" width="15" height="15" as="geometry"/>
</mxCell>
<mxCell id="9" value="" style="triangle;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=#000000;strokeWidth=1;rotation=180;" vertex="1" parent="1">
<mxGeometry x="350" y="210" width="20" height="15" as="geometry"/>
</mxCell>
<mxCell id="10" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="360" y="220" as="sourcePoint"/>
<mxPoint x="340" y="235" as="targetPoint"/>
<Array as="points"> <Array as="points">
<mxPoint x="355" y="230"/> <mxPoint x="580" y="480"/>
<mxPoint x="600" y="380"/>
</Array> </Array>
<mxPoint x="490" y="480" as="sourcePoint"/>
<mxPoint x="560" y="330" as="targetPoint"/>
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="11" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=2;" edge="1" parent="1"> <mxCell id="3" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry height="190" width="200" x="320" y="320" as="geometry"/>
<mxPoint x="360" y="220" as="sourcePoint"/> </mxCell>
<mxPoint x="380" y="235" as="targetPoint"/>
<mxCell id="4" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4C4;strokeColor=none;" value="" vertex="1">
<mxGeometry height="120" width="100" x="370" y="360" as="geometry"/>
</mxCell>
<mxCell id="5" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
<mxGeometry height="40" width="60" x="350" y="480" as="geometry"/>
</mxCell>
<mxCell id="6" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
<mxGeometry height="40" width="60" x="430" y="480" as="geometry"/>
</mxCell>
<mxCell id="7" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;rotation=-15;" value="" vertex="1">
<mxGeometry height="80" width="70" x="310" y="110" as="geometry"/>
</mxCell>
<mxCell id="8" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;rotation=15;" value="" vertex="1">
<mxGeometry height="80" width="70" x="460" y="110" as="geometry"/>
</mxCell>
<mxCell id="9" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=-15;" value="" vertex="1">
<mxGeometry height="45" width="36" x="327" y="135" as="geometry"/>
</mxCell>
<mxCell id="10" parent="1" style="triangle;direction=north;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;rotation=15;" value="" vertex="1">
<mxGeometry height="45" width="36" x="477" y="135" as="geometry"/>
</mxCell>
<mxCell id="11" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#F4A460;strokeColor=#8B5A2B;strokeWidth=2;" value="" vertex="1">
<mxGeometry height="200" width="240" x="300" y="140" as="geometry"/>
</mxCell>
<mxCell id="12" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#333333;strokeColor=none;" value="" vertex="1">
<mxGeometry height="44" width="36" x="355" y="200" as="geometry"/>
</mxCell>
<mxCell id="13" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#333333;strokeColor=none;" value="" vertex="1">
<mxGeometry height="44" width="36" x="449" y="200" as="geometry"/>
</mxCell>
<mxCell id="14" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" value="" vertex="1">
<mxGeometry height="13" width="13" x="363" y="207" as="geometry"/>
</mxCell>
<mxCell id="15" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" value="" vertex="1">
<mxGeometry height="13" width="13" x="457" y="207" as="geometry"/>
</mxCell>
<mxCell id="16" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;opacity=70;" value="" vertex="1">
<mxGeometry height="22" width="40" x="325" y="255" as="geometry"/>
</mxCell>
<mxCell id="17" parent="1" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFB6C1;strokeColor=none;opacity=70;" value="" vertex="1">
<mxGeometry height="22" width="40" x="475" y="255" as="geometry"/>
</mxCell>
<mxCell id="18" parent="1" style="triangle;direction=south;whiteSpace=wrap;html=1;fillColor=#FF69B4;strokeColor=none;" value="" vertex="1">
<mxGeometry height="16" width="24" x="408" y="252" as="geometry"/>
</mxCell>
<mxCell id="19" edge="1" parent="1" style="curved=1;endArrow=none;html=1;strokeWidth=2;strokeColor=#333333;" value="">
<mxGeometry relative="1" as="geometry">
<Array as="points"> <Array as="points">
<mxPoint x="365" y="230"/> <mxPoint x="410" y="288"/>
</Array> </Array>
<mxPoint x="400" y="278" as="sourcePoint"/>
<mxPoint x="420" y="270" as="targetPoint"/>
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="12" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1"> <mxCell id="20" edge="1" parent="1" style="curved=1;endArrow=none;html=1;strokeWidth=2;strokeColor=#333333;" value="">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry relative="1" as="geometry">
<mxPoint x="310" y="200" as="sourcePoint"/>
<mxPoint x="260" y="195" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="13" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="210" as="sourcePoint"/>
<mxPoint x="260" y="210" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="14" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="310" y="220" as="sourcePoint"/>
<mxPoint x="260" y="225" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="15" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="200" as="sourcePoint"/>
<mxPoint x="460" y="195" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="16" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="210" as="sourcePoint"/>
<mxPoint x="460" y="210" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="" style="endArrow=none;html=1;strokeColor=#000000;strokeWidth=1.5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="410" y="220" as="sourcePoint"/>
<mxPoint x="460" y="225" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="285" y="250" width="150" height="180" as="geometry"/>
</mxCell>
<mxCell id="19" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="315" y="280" width="90" height="120" as="geometry"/>
</mxCell>
<mxCell id="20" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="300" y="410" width="40" height="50" as="geometry"/>
</mxCell>
<mxCell id="21" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE6CC;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="380" y="410" width="40" height="50" as="geometry"/>
</mxCell>
<mxCell id="22" value="" style="curved=1;endArrow=none;html=1;strokeColor=#000000;strokeWidth=3;fillColor=#FFE6CC;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="285" y="340" as="sourcePoint"/>
<mxPoint x="240" y="260" as="targetPoint"/>
<Array as="points"> <Array as="points">
<mxPoint x="260" y="350"/> <mxPoint x="430" y="288"/>
<mxPoint x="240" y="320"/>
<mxPoint x="235" y="290"/>
</Array> </Array>
<mxPoint x="420" y="270" as="sourcePoint"/>
<mxPoint x="440" y="278" as="targetPoint"/>
</mxGeometry> </mxGeometry>
</mxCell>
<mxCell id="21" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
<mxGeometry relative="1" as="geometry">
<mxPoint x="370" y="262" as="sourcePoint"/>
<mxPoint x="270" y="245" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="22" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
<mxGeometry relative="1" as="geometry">
<mxPoint x="370" y="272" as="sourcePoint"/>
<mxPoint x="268" y="278" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="23" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
<mxGeometry relative="1" as="geometry">
<mxPoint x="470" y="262" as="sourcePoint"/>
<mxPoint x="570" y="245" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="24" edge="1" parent="1" style="endArrow=none;html=1;strokeWidth=2;strokeColor=#5C3A1A;" value="">
<mxGeometry relative="1" as="geometry">
<mxPoint x="470" y="272" as="sourcePoint"/>
<mxPoint x="572" y="278" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="25" parent="1" style="text;html=1;align=center;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#FF69B4;" value="Meow~ ♥" vertex="1">
<mxGeometry height="40" width="120" x="530" y="120" as="geometry"/>
</mxCell>`, </mxCell>`,
}, },
] ]
+154 -102
View File
@@ -72,7 +72,7 @@
"replicateDescription": "Upload and replicate an existing flowchart", "replicateDescription": "Upload and replicate an existing flowchart",
"creativeDrawing": "Creative drawing", "creativeDrawing": "Creative drawing",
"creativeDescription": "Draw something fun and creative", "creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples open instantly and don't use your model.", "cachedNote": "Examples use saved answers: they appear instantly and don't call the AI.",
"mcpServer": "MCP Server", "mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor" "mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
}, },
@@ -96,7 +96,7 @@
"language": "Language", "language": "Language",
"languageDescription": "Choose your interface language.", "languageDescription": "Choose your interface language.",
"theme": "Theme", "theme": "Theme",
"themeDescription": "Light or dark interface. The diagram always stays on a white sheet, the way it exports.", "themeDescription": "Light or dark interface. The canvas follows the same theme.",
"diagramStyle": "Diagram style", "diagramStyle": "Diagram style",
"diagramStyleDescription": "How the AI draws new diagrams: Styled uses colors and icons, Minimal keeps shapes plain.", "diagramStyleDescription": "How the AI draws new diagrams: Styled uses colors and icons, Minimal keeps shapes plain.",
"sendShortcut": "Send shortcut", "sendShortcut": "Send shortcut",
@@ -118,11 +118,9 @@
"customSystemMessage": "Custom instructions", "customSystemMessage": "Custom instructions",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.", "customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...", "customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
"maxOutputTokens": "Max output tokens", "maxOutputTokens": "Output token limit",
"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.", "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.",
"tabModels": "Models & keys", "tabModels": "Models",
"tabAppearance": "Appearance",
"tabAdvanced": "Advanced",
"tabAbout": "About", "tabAbout": "About",
"themeLight": "Light", "themeLight": "Light",
"themeDarkMode": "Dark", "themeDarkMode": "Dark",
@@ -131,7 +129,11 @@
"proxyInvalid": "Proxy addresses must start with http:// or https://", "proxyInvalid": "Proxy addresses must start with http:// or https://",
"proxyFailed": "Couldn't apply the proxy settings", "proxyFailed": "Couldn't apply the proxy settings",
"appVersion": "Version", "appVersion": "Version",
"projectWebsite": "Project website" "projectWebsite": "Project website",
"tabGeneral": "General",
"tabDrawing": "Drawing",
"sectionInterface": "Interface",
"sectionAccess": "Access"
}, },
"save": { "save": {
"title": "Export diagram", "title": "Export diagram",
@@ -209,14 +211,13 @@
"tpmLimit": "Rate Limit", "tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.", "tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.", "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.", "messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
"messageApiSelfHosted": null, "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.", "messageToken": "Looks like you've reached today's token limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
"messageTokenSelfHosted": null, "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.", "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.", "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.", "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", "configModel": "Use Your API Key",
"selfHost": "Self-host", "selfHost": "Self-host",
"sponsor": "Sponsor", "sponsor": "Sponsor",
@@ -351,24 +352,16 @@
}, },
"modelConfig": { "modelConfig": {
"configure": "Configure", "configure": "Configure",
"addProvider": "Add Provider", "addProvider": "Add provider",
"addModel": "Add Model", "addModel": "Add Model",
"modelId": "Model ID", "modelId": "Model ID",
"modelLabel": "Display Label", "modelLabel": "Display Label",
"streaming": "Enable Streaming", "streaming": "Enable Streaming",
"deleteProvider": "Delete Provider", "deleteProvider": "Delete provider",
"deleteModel": "Delete Model", "deleteModel": "Delete Model",
"noModels": "No models configured. Add a model to get started.", "noModels": "No models configured. Add a model to get started.",
"selectProvider": "Select a provider or add a new one", "validationError": "The test failed",
"configureMultiple": "Configure multiple AI providers and switch between them easily", "addModelFirst": "Add a model first.",
"apiKeyStored": "API keys are stored locally in your browser",
"test": "Test",
"validationError": "Validation failed",
"addModelFirst": "Add at least one model to validate",
"providers": "Providers",
"addProviderHint": "No providers yet",
"verified": "Verified",
"configuration": "Configuration",
"displayName": "Display Name", "displayName": "Display Name",
"bedrockApiKey": "Bedrock API key", "bedrockApiKey": "Bedrock API key",
"bedrockApiKeyHint": "With an API key, the access keys below are not needed.", "bedrockApiKeyHint": "With an API key, the access keys below are not needed.",
@@ -376,13 +369,12 @@
"awsSecretAccessKey": "AWS Secret Access Key", "awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region", "awsRegion": "AWS Region",
"selectRegion": "Select region", "selectRegion": "Select region",
"apiKey": "API Key", "apiKey": "API key",
"enterApiKey": "Enter your API key", "enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key", "enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL", "baseUrl": "Base URL",
"optional": "(optional)", "optional": "(optional)",
"getApiKey": "Get API key", "getApiKey": "Get API key",
"fetchModels": "Fetch models from the provider",
"noTools": "no tool calls", "noTools": "no tool calls",
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.", "mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
"requestUrl": "Requests go to {url}", "requestUrl": "Requests go to {url}",
@@ -393,17 +385,11 @@
"models": "Models", "models": "Models",
"customModelId": "Custom model ID...", "customModelId": "Custom model ID...",
"allAdded": "All added", "allAdded": "All added",
"suggested": "Suggested",
"noModelsConfigured": "No models configured",
"modelIdEmpty": "Model ID cannot be empty", "modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists", "modelIdExists": "This model ID already exists",
"configureProviders": "Set up AI providers",
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.", "deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm", "typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type provider name...", "typeProviderName": "Type the provider name...",
"modelsConfiguredCount": "{count} model(s) configured",
"validationFailedCount": "{count} model(s) failed validation",
"requestFailed": "Request failed ({status})", "requestFailed": "Request failed ({status})",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
@@ -411,65 +397,131 @@
"usingServerDefault": "Using server default model", "usingServerDefault": "Using server default model",
"selectModel": "Select Model", "selectModel": "Select Model",
"searchModels": "Search models...", "searchModels": "Search models...",
"noVerifiedModels": "No verified models. Test your models first.",
"noModelsFound": "No models found.", "noModelsFound": "No models found.",
"default": "Default", "default": "Default",
"serverDefault": "Server Default", "serverDefault": "Server model",
"serverModels": "Server Models", "serverModels": "Server Models",
"userModels": "User Models", "userModels": "User Models",
"configureModels": "Configure Models...", "configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown",
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model", "serverDefaultModel": "Server default model",
"showValue": "Show value", "showValue": "Show value",
"hideValue": "Hide value", "hideValue": "Hide value",
"noProvidersTitle": "Using the model set up on this server", "myProviders": "My providers",
"noProvidersHint": "To use other models, add a provider with your own API key below the list on the left." "inUse": "In use",
"listSeparator": ", ",
"serverModelsTitle": "Models from this server",
"serverDefaultSummary": "No API key needed: the server's default model.",
"statusError": "{count} failed",
"statusIncomplete": "Not set up yet",
"statusUntested": "{count} untested",
"statusOk": "{count} working",
"noModelsYet": "No models yet",
"keysStayLocal": "API keys stay in this browser on this device and go to the server only with your requests.",
"useOwnKey": "Use your own API key",
"useOwnKeyHint": "Add a provider to use its models from the chat's model picker. Pick one to start:",
"viewAllProviders": "See all {count} providers",
"searchProviders": "Search providers, e.g. DeepSeek, Qwen, Ollama",
"noProvidersFound": "No provider matches.",
"groupVendors": "Model makers",
"groupAggregators": "Aggregators",
"groupCloud": "Cloud platforms",
"groupSelfHosted": "Self-hosted",
"noteBedrock": "AWS credentials",
"noteVertex": "Express mode key",
"noteAzure": "Key and resource URL",
"noteEdgeone": "No key needed",
"noteOllama": "Key optional",
"noteSglang": "Your server's URL",
"added": "Added",
"awsCredentials": "AWS credentials",
"bedrockAuthApiKey": "Bedrock API key",
"bedrockAuthAccessKey": "Access keys",
"moreOptions": "More options: base URL, display name",
"moreOptionsName": "More options: display name",
"keyRejected": "The provider rejected this API key. Check that the key above was copied in full.",
"modelNotFound": "The provider can't find this model ID. Check its spelling.",
"modelWorks": "Works",
"modelWorksTime": "Works · {seconds} s",
"modelFailed": "Failed",
"modelUntested": "Untested",
"needKey": "Fill in the API key first.",
"needAws": "Fill in the AWS credentials and region first.",
"needModel": "Add a model first.",
"stepConnect": "Connection",
"edgeoneNoKey": "EdgeOne Pages uses the model service that comes with this site's deployment: no API key needed.",
"stepModels": "Models",
"fetchAllModels": "Get all models from {name}",
"suggestedLabel": "Suggested:",
"orTypeModelId": "Or type another model ID",
"add": "Add",
"browseModels": "Browse models",
"stepTest": "Test",
"testHint": "Sends each model a very short message to check that the key and the model work.",
"testOne": "Test {model}",
"testAll": "Test all {count} models",
"testPassedOne": "{model} works",
"testPassedAll": "All {count} models work",
"answeredIn": "(answered in {seconds} s)",
"useInChat": "Use {model} in the chat",
"backToProviders": "Back to providers",
"deleteProviderOnly": "Delete {name}",
"addProviderEntry": "Add a provider…",
"awsKeysRejected": "AWS rejected these access keys. Check that both were copied in full.",
"needAzure": "Fill in the API key and your Azure resource's base URL first.",
"validationFailedCountOne": "{count} model failed the test",
"validationFailedCountOther": "{count} models failed the test",
"deleteProviderNamedOne": "Delete {name} and its model",
"deleteProviderNamedOther": "Delete {name} and its {count} models",
"serverModelsSummaryOne": "No API key needed. {count} model: {names}",
"serverModelsSummaryOther": "No API key needed. {count} models: {names}",
"noModelsReturned": "The provider returned no models.",
"awsSessionToken": "AWS session token (optional)",
"awsSessionTokenHint": "Only for temporary credentials: access keys that start with ASIA.",
"bedrockBothSaved": "This provider also keeps an access key pair, unused while there is an API key.",
"removeAccessKeys": "Remove the access keys"
}, },
"admin": { "admin": {
"title": "Admin Settings", "title": "Admin Settings",
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.", "loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable)",
"password": "Password", "password": "Password",
"signIn": "Sign In", "signIn": "Sign In",
"signingIn": "Signing In…", "signingIn": "Signing in…",
"loginFailed": "Login failed", "loginFailed": "Login failed",
"precedence": "File overrides env · env overrides defaults", "precedence": "Priority: this page > environment variables > 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.", "notWritable": "Settings can't be saved on this deployment, only viewed; use environment variables instead",
"settingGroups": "Setting groups", "settingGroups": "Setting groups",
"enabled": "Enabled", "enabled": "On",
"disabled": "Disabled", "disabled": "Off",
"enableGroup": "Enable {group}", "enableGroup": "Turn on {group}",
"unsavedChanges": "Unsaved changes", "unsavedChanges": "Unsaved changes",
"saved": "Settings saved. Changes apply immediately.", "saved": "Saved. Changes apply right away, except items marked \"Needs restart\"",
"saveFailed": "Save failed. Check your connection and try again.", "saveFailed": "Save failed. Check your connection and try again",
"invalidSettings": "Some settings are invalid.", "invalidSettings": "Some settings are invalid; see the notes under them",
"discard": "Discard", "discard": "Discard changes",
"saveChanges": "Save Changes", "saveChanges": "Save changes",
"saving": "Saving…", "saving": "Saving…",
"sourceSaved": "Saved", "sourceSaved": "This page",
"sourceEnv": "Env", "sourceEnv": "Env",
"sourceSavedTitle": "Set in the admin settings file", "sourceSavedTitle": "Set on this page",
"sourceEnvTitle": "Set by an environment variable", "sourceEnvTitle": "From an environment variable",
"restartRequired": "Restart Required", "restartRequired": "Needs restart",
"modified": "Modified", "modified": "Modified",
"notSet": "Not set", "notSet": "Not set",
"savedReplace": "Saved ({hint}) — type to replace", "savedReplace": "Saved ({hint}); type a new value to replace it",
"showValue": "Show value", "showValue": "Show",
"hideValue": "Hide value", "hideValue": "Hide",
"removeValue": "Remove value", "removeValue": "Clear",
"removeValueTitle": "Remove the stored value", "removeValueTitle": "Clear the saved value",
"resetToDefault": "Reset to default", "resetToDefault": "Reset to default",
"models": "Models", "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.", "modelsDescription": "Models every user can use, no personal API key needed",
"addProviderHint": "Add a provider to offer server-side models to all users.", "addProviderHint": "Add a provider to offer models to all users",
"selectProviderHint": "Select or add a provider to configure its credentials and models.", "selectProviderHint": "Select or add a provider, then enter its key and models",
"addProviderToOfferModels": "Add at least one model to expose this provider to users.", "addProviderToOfferModels": "Add at least one model before users can see this provider",
"managedViaEnv": "(managed via env)", "managedViaEnv": "(environment variables)",
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.", "envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json; view only here, edit it there",
"defaultModel": "Default Model", "defaultModel": "Default Model",
"noModelsConfigured": "No models configured", "noModelsConfigured": "No models yet",
"modelCount": "{count} model", "modelCount": "{count} model",
"modelCountPlural": "{count} models", "modelCountPlural": "{count} models",
"default": "Default", "default": "Default",
@@ -479,65 +531,65 @@
"addModel": "Add model", "addModel": "Add model",
"suggested": "Suggested", "suggested": "Suggested",
"test": "Test", "test": "Test",
"testOk": "OK ({ms}ms)", "testOk": "Passed ({ms} ms)",
"testFailed": "Failed", "testFailed": "Failed",
"removeModel": "Remove {model}", "removeModel": "Delete {model}",
"deleteProviderTitle": "Delete {name}?", "deleteProviderTitle": "Delete {name}?",
"deleteProviderDesc": "Its credentials and models will be removed from the server after you save.", "deleteProviderDesc": "After you save, its key and models are deleted from the server",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
"groups": { "groups": {
"generation": { "generation": {
"title": "Generation", "title": "Replies",
"description": "Output parameters applied to all chat requests." "description": "How the AI replies, for every conversation"
}, },
"access": { "access": {
"title": "Access Control", "title": "Access",
"description": "Restrict who can use this deployment." "description": "Limit who can use this site"
}, },
"features": { "features": {
"title": "Features", "title": "Optional Features",
"description": "Optional features and security toggles." "description": "Extra features and safety options"
}, },
"observability": { "observability": {
"title": "Observability", "title": "Call Logs",
"description": "Langfuse tracing for LLM calls." "description": "Record model calls with Langfuse"
}, },
"quota": { "quota": {
"title": "Quota & Rate Limits", "title": "Usage Limits",
"description": "Per-IP usage limits. Enforcement requires a DynamoDB table." "description": "Per-IP limits; they take effect only with a DynamoDB table"
} }
}, },
"settings": { "settings": {
"TEMPERATURE": { "TEMPERATURE": {
"label": "Temperature", "label": "Temperature",
"description": "Leave unset for reasoning models that reject temperature." "description": "Higher is more random; leave empty for reasoning models that don't support it"
}, },
"MAX_OUTPUT_TOKENS": { "MAX_OUTPUT_TOKENS": {
"label": "Max Output Tokens" "label": "Output Token Limit"
}, },
"ACCESS_CODE_LIST": { "ACCESS_CODE_LIST": {
"label": "Access Codes", "label": "Access Codes",
"description": "Comma-separated list. Users must enter one to chat. Empty = open access." "description": "Separate several with commas; users must enter one to chat; empty means no limit"
}, },
"ENABLE_VLM_VALIDATION": { "ENABLE_VLM_VALIDATION": {
"label": "VLM Diagram Validation", "label": "Diagram Check",
"description": "Visually validate generated diagrams with a vision model." "description": "After drawing, a model that can see images checks a screenshot"
}, },
"VALIDATION_MODEL": { "VALIDATION_MODEL": {
"label": "Validation Model", "label": "Check Model",
"description": "Falls back to the default AI model when empty." "description": "Empty means the default model"
}, },
"VALIDATION_TIMEOUT": { "VALIDATION_TIMEOUT": {
"label": "Validation Timeout (ms)" "label": "Check Timeout (ms)"
}, },
"ENABLE_HISTORY_XML_REPLACE": { "ENABLE_HISTORY_XML_REPLACE": {
"label": "History XML Compression", "label": "Trim Old Diagrams",
"description": "Replace old diagram XML in history with placeholders." "description": "Old diagrams in the chat history are kept as a short marker"
}, },
"ALLOW_PRIVATE_URLS": { "ALLOW_PRIVATE_URLS": {
"label": "Allow Private URLs", "label": "Allow Private URLs",
"description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)." "description": "When off, custom model URLs can't point to private networks; safer"
}, },
"LANGFUSE_PUBLIC_KEY": { "LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key" "label": "Langfuse Public Key"
@@ -546,29 +598,29 @@
"label": "Langfuse Secret Key" "label": "Langfuse Secret Key"
}, },
"LANGFUSE_BASEURL": { "LANGFUSE_BASEURL": {
"label": "Langfuse Base URL" "label": "Langfuse URL"
}, },
"DAILY_REQUEST_LIMIT": { "DAILY_REQUEST_LIMIT": {
"label": "Daily Request Limit", "label": "Daily Requests",
"description": "Per IP per day." "description": "Requests per IP per day"
}, },
"DAILY_TOKEN_LIMIT": { "DAILY_TOKEN_LIMIT": {
"label": "Daily Token Limit", "label": "Daily Tokens",
"description": "Per IP per day." "description": "Tokens per IP per day"
}, },
"TPM_LIMIT": { "TPM_LIMIT": {
"label": "Tokens Per Minute" "label": "Tokens Per Minute"
}, },
"DYNAMODB_QUOTA_TABLE": { "DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB Table", "label": "DynamoDB Table Name",
"description": "Quota enforcement is disabled when empty." "description": "Empty means no usage limits"
}, },
"DYNAMODB_REGION": { "DYNAMODB_REGION": {
"label": "DynamoDB Region" "label": "DynamoDB Region"
}, },
"QUOTA_TIMEZONE": { "QUOTA_TIMEZONE": {
"label": "Quota Timezone", "label": "Time Zone",
"description": "Timezone for the daily reset boundary." "description": "Daily usage resets at midnight in this time zone"
} }
} }
}, },
@@ -612,7 +664,7 @@
"failed": "This attempt failed", "failed": "This attempt failed",
"failedRetrying": "This attempt failed, trying again", "failedRetrying": "This attempt failed, trying again",
"stopped": "Stopped", "stopped": "Stopped",
"truncatedHint": "The reply hit the output limit before the diagram was complete. Ask for a simpler diagram, or raise Max output tokens in Settings > Advanced.", "truncatedHint": "The reply hit the output limit before the diagram was complete. Ask for a simpler diagram, or raise Max output tokens in Settings > Drawing.",
"noChanges": "No visible changes", "noChanges": "No visible changes",
"listSeparator": ", ", "listSeparator": ", ",
"shapesAddedOne": "added 1 shape", "shapesAddedOne": "added 1 shape",
@@ -642,7 +694,7 @@
}, },
"lobby": { "lobby": {
"title": "What would you like to draw?", "title": "What would you like to draw?",
"lede": "Describe a diagram, or upload a screenshot or PDF. You get a regular draw.io diagram that you can keep editing and export.", "lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries", "placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams", "recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas", "drawYourself": "Start with a blank canvas",
+150 -98
View File
@@ -72,7 +72,7 @@
"replicateDescription": "既存のフローチャートをアップロードして複製", "replicateDescription": "既存のフローチャートをアップロードして複製",
"creativeDrawing": "自由なイラスト", "creativeDrawing": "自由なイラスト",
"creativeDescription": "楽しくてクリエイティブなものを描く", "creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はすぐに開き、モデルは使いません。", "cachedNote": "例は保存済みの回答を使うので、すぐに表示され、AI を呼び出しません。",
"mcpServer": "MCP サーバー", "mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用" "mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
}, },
@@ -96,7 +96,7 @@
"language": "言語", "language": "言語",
"languageDescription": "インターフェース言語を選択します。", "languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ", "theme": "テーマ",
"themeDescription": "画面をライトにするかダークにするか。図はエクスポートしたときと同じく、常に白い用紙の上に表示されます。", "themeDescription": "画面をライトにするかダークにするか。キャンバスも同じテーマになります。",
"diagramStyle": "図のスタイル", "diagramStyle": "図のスタイル",
"diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。", "diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。",
"sendShortcut": "送信のショートカット", "sendShortcut": "送信のショートカット",
@@ -118,11 +118,9 @@
"customSystemMessage": "カスタム指示", "customSystemMessage": "カスタム指示",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。", "customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...", "customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"maxOutputTokens": "最大出力トークン数", "maxOutputTokens": "出力トークン上限",
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。", "maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
"tabModels": "モデルとキー", "tabModels": "モデル",
"tabAppearance": "外観",
"tabAdvanced": "詳細",
"tabAbout": "このアプリについて", "tabAbout": "このアプリについて",
"themeLight": "ライト", "themeLight": "ライト",
"themeDarkMode": "ダーク", "themeDarkMode": "ダーク",
@@ -131,7 +129,11 @@
"proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください", "proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください",
"proxyFailed": "プロキシ設定を適用できませんでした", "proxyFailed": "プロキシ設定を適用できませんでした",
"appVersion": "バージョン", "appVersion": "バージョン",
"projectWebsite": "プロジェクトのサイト" "projectWebsite": "プロジェクトのサイト",
"tabGeneral": "一般",
"tabDrawing": "描画",
"sectionInterface": "表示",
"sectionAccess": "アクセス"
}, },
"save": { "save": {
"title": "図をエクスポート", "title": "図をエクスポート",
@@ -209,14 +211,13 @@
"tpmLimit": "レート制限", "tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。", "tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。", "tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。", "messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
"messageApiSelfHosted": null, "messageApiSelfHosted": null,
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。", "messageToken": "今日のトークン利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
"messageTokenSelfHosted": null, "messageTokenSelfHosted": null,
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。", "tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。", "tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます。", "reset": "制限は明日リセットされます。ご理解ありがとうございます。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
"configModel": "APIキーを使用", "configModel": "APIキーを使用",
"selfHost": "セルフホスト", "selfHost": "セルフホスト",
"sponsor": "スポンサー", "sponsor": "スポンサー",
@@ -314,16 +315,8 @@
"deleteProvider": "プロバイダーを削除", "deleteProvider": "プロバイダーを削除",
"deleteModel": "モデルを削除", "deleteModel": "モデルを削除",
"noModels": "モデルが設定されていません。モデルを追加してください。", "noModels": "モデルが設定されていません。モデルを追加してください。",
"selectProvider": "プロバイダーを選択または追加してください", "validationError": "テストに失敗しました",
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え", "addModelFirst": "先にモデルを追加してください。",
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
"test": "テスト",
"validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
"providers": "プロバイダー",
"addProviderHint": "プロバイダーはまだありません",
"verified": "検証済み",
"configuration": "設定",
"displayName": "表示名", "displayName": "表示名",
"bedrockApiKey": "Bedrock API キー", "bedrockApiKey": "Bedrock API キー",
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。", "bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
@@ -337,7 +330,6 @@
"baseUrl": "ベース URL", "baseUrl": "ベース URL",
"optional": "(オプション)", "optional": "(オプション)",
"getApiKey": "API キーを取得", "getApiKey": "API キーを取得",
"fetchModels": "プロバイダーからモデル一覧を取得",
"noTools": "ツール呼び出し非対応", "noTools": "ツール呼び出し非対応",
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。", "mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
"requestUrl": "リクエスト先: {url}", "requestUrl": "リクエスト先: {url}",
@@ -348,17 +340,11 @@
"models": "モデル", "models": "モデル",
"customModelId": "カスタムモデル ID...", "customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み", "allAdded": "すべて追加済み",
"suggested": "おすすめ",
"noModelsConfigured": "モデルが設定されていません",
"modelIdEmpty": "モデル ID は空にできません", "modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します", "modelIdExists": "このモデル ID は既に存在します",
"configureProviders": "AI プロバイダーの設定",
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。", "deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力", "typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...", "typeProviderName": "プロバイダー名を入力...",
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
"validationFailedCount": "{count} 個のモデルの検証に失敗",
"requestFailed": "リクエストに失敗しました({status})", "requestFailed": "リクエストに失敗しました({status})",
"cancel": "キャンセル", "cancel": "キャンセル",
"delete": "削除", "delete": "削除",
@@ -366,22 +352,88 @@
"usingServerDefault": "サーバーデフォルトモデルを使用中", "usingServerDefault": "サーバーデフォルトモデルを使用中",
"selectModel": "モデルを選択", "selectModel": "モデルを選択",
"searchModels": "モデルを検索...", "searchModels": "モデルを検索...",
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
"noModelsFound": "モデルが見つかりません。", "noModelsFound": "モデルが見つかりません。",
"default": "デフォルト", "default": "デフォルト",
"serverDefault": "サーバーデフォルト", "serverDefault": "サーバーのモデル",
"serverModels": "サーバーモデル", "serverModels": "サーバーモデル",
"userModels": "ユーザーモデル", "userModels": "ユーザーモデル",
"configureModels": "モデルを設定...", "configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示",
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル", "serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示", "showValue": "値を表示",
"hideValue": "値を非表示", "hideValue": "値を非表示",
"noProvidersTitle": "このサーバーで設定されたモデルを使用中", "myProviders": "マイプロバイダー",
"noProvidersHint": "ほかのモデルを使うには、左のリストの下でプロバイダーを追加し、自分の API キーを入力してください。" "inUse": "使用中",
"listSeparator": "、",
"serverModelsTitle": "このサーバーのモデル",
"serverDefaultSummary": "API キーは不要です。サーバーの既定のモデルを使います。",
"statusError": "{count} 個でエラー",
"statusIncomplete": "設定が終わっていません",
"statusUntested": "{count} 個が未テスト",
"statusOk": "{count} 個が使用可能",
"noModelsYet": "モデルはまだありません",
"keysStayLocal": "API キーはこの端末のブラウザーにだけ保存され、リクエストのときだけサーバーに送られます。",
"useOwnKey": "自分の API キーを使う",
"useOwnKeyHint": "プロバイダーを追加すると、チャットのモデル選択でそのモデルを使えます。まず一つ選んでください:",
"viewAllProviders": "{count} 個のプロバイダーをすべて表示",
"searchProviders": "プロバイダーを検索(例:DeepSeek、Qwen、Ollama)",
"noProvidersFound": "一致するプロバイダーはありません。",
"groupVendors": "モデル開発元",
"groupAggregators": "アグリゲーター",
"groupCloud": "クラウド",
"groupSelfHosted": "セルフホスト",
"noteBedrock": "AWS 認証情報",
"noteVertex": "Express モードのキー",
"noteAzure": "キーとリソース URL",
"noteEdgeone": "キー不要",
"noteOllama": "キーは任意",
"noteSglang": "サーバーの URL",
"added": "追加済み",
"awsCredentials": "AWS 認証情報",
"bedrockAuthApiKey": "Bedrock API キー",
"bedrockAuthAccessKey": "アクセスキー",
"moreOptions": "その他のオプション:ベース URL、表示名",
"moreOptionsName": "その他のオプション:表示名",
"keyRejected": "プロバイダーがこの API キーを拒否しました。上のキーが最後までコピーされているか確認してください。",
"modelNotFound": "プロバイダーがこのモデル ID を見つけられません。綴りを確認してください。",
"modelWorks": "使用可能",
"modelWorksTime": "使用可能 · {seconds} 秒",
"modelFailed": "エラー",
"modelUntested": "未テスト",
"needKey": "先に API キーを入力してください。",
"needAws": "先に AWS 認証情報とリージョンを入力してください。",
"needModel": "先にモデルを追加してください。",
"stepConnect": "接続",
"edgeoneNoKey": "EdgeOne Pages はこのサイトのデプロイに付属するモデルサービスを使うため、API キーは不要です。",
"stepModels": "モデル",
"fetchAllModels": "{name} から全モデルを取得",
"suggestedLabel": "おすすめ:",
"orTypeModelId": "または別のモデル ID を入力",
"add": "追加",
"browseModels": "モデル一覧",
"stepTest": "テスト",
"testHint": "各モデルに短いメッセージを送り、キーとモデルが使えるか確認します。",
"testOne": "{model} をテスト",
"testAll": "{count} 個のモデルをすべてテスト",
"testPassedOne": "{model} は使用できます",
"testPassedAll": "{count} 個のモデルすべてが使用できます",
"answeredIn": "(応答 {seconds} 秒)",
"useInChat": "チャットで {model} を使う",
"backToProviders": "プロバイダー一覧に戻る",
"deleteProviderOnly": "{name} を削除",
"addProviderEntry": "プロバイダーを追加…",
"awsKeysRejected": "AWS がこのアクセスキーを拒否しました。2 つとも最後までコピーされているか確認してください。",
"needAzure": "先に API キーと Azure リソースのベース URL を入力してください。",
"validationFailedCountOne": "{count} 個のモデルがテストに失敗しました",
"validationFailedCountOther": "{count} 個のモデルがテストに失敗しました",
"deleteProviderNamedOne": "{name} とそのモデルを削除",
"deleteProviderNamedOther": "{name} と {count} 個のモデルを削除",
"serverModelsSummaryOne": "API キーは不要です。{count} 個:{names}",
"serverModelsSummaryOther": "API キーは不要です。{count} 個:{names}",
"noModelsReturned": "プロバイダーからモデルが返されませんでした。",
"awsSessionToken": "AWS セッショントークン(任意)",
"awsSessionTokenHint": "一時的な認証情報(ASIA で始まるアクセスキー)の場合だけ必要です。",
"bedrockBothSaved": "このプロバイダーにはアクセスキーも保存されていますが、API キーがある間は使われません。",
"removeAccessKeys": "アクセスキーを削除"
}, },
"templates": { "templates": {
"emptyTitle": "保存したプロンプトはまだありません", "emptyTitle": "保存したプロンプトはまだありません",
@@ -430,114 +482,114 @@
}, },
"admin": { "admin": {
"title": "管理者設定", "title": "管理者設定",
"loginPrompt": "サーバー設定を管理するには、管理者パスワード(ADMIN_PASSWORD 環境変数)を入力してください。", "loginPrompt": "管理者パスワード(環境変数 ADMIN_PASSWORD)を入力してください",
"password": "パスワード", "password": "パスワード",
"signIn": "ログイン", "signIn": "ログイン",
"signingIn": "ログイン中…", "signingIn": "ログイン中…",
"loginFailed": "ログインに失敗しました", "loginFailed": "ログインに失敗しました",
"precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き", "precedence": "優先順位:このページ > 環境変数 > 既定値",
"notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。", "notWritable": "この環境では設定を保存できず、表示のみです。環境変数で設定してください",
"settingGroups": "設定グループ", "settingGroups": "設定の分類",
"enabled": "有効", "enabled": "オン",
"disabled": "無効", "disabled": "オフ",
"enableGroup": "{group} を有効化", "enableGroup": "{group} をオンにする",
"unsavedChanges": "未保存の変更があります", "unsavedChanges": "未保存の変更があります",
"saved": "設定を保存しました。変更は即座に反映されます。", "saved": "保存しました。「再起動が必要」の項目以外はすぐに反映されます",
"saveFailed": "保存に失敗しました。接続を確認して再試行してください。", "saveFailed": "保存に失敗しました。接続を確認してもう一度お試しください",
"invalidSettings": "一部の設定が無効です。", "invalidSettings": "入力に誤りがある設定があります。項目の下の説明を確認してください",
"discard": "破棄", "discard": "変更を破棄",
"saveChanges": "変更を保存", "saveChanges": "変更を保存",
"saving": "保存中…", "saving": "保存中…",
"sourceSaved": "保存済み", "sourceSaved": "このページ",
"sourceEnv": "環境変数", "sourceEnv": "環境変数",
"sourceSavedTitle": "管理者設定ファイルで設定", "sourceSavedTitle": "このページで設定した値",
"sourceEnvTitle": "環境変数で設定", "sourceEnvTitle": "環境変数による設定",
"restartRequired": "再起動が必要", "restartRequired": "再起動が必要",
"modified": "変更済み", "modified": "変更済み",
"notSet": "未設定", "notSet": "未設定",
"savedReplace": "保存済み({hint})——入力して置き換え", "savedReplace": "保存済み({hint})。新しい値を入力すると置き換わります",
"showValue": "値を表示", "showValue": "表示",
"hideValue": "値を非表示", "hideValue": "非表示",
"removeValue": "値を削除", "removeValue": "クリア",
"removeValueTitle": "保存された値を削除", "removeValueTitle": "保存した値をクリア",
"resetToDefault": "デフォルトに戻す", "resetToDefault": "デフォルトに戻す",
"models": "モデル", "models": "モデル",
"modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。", "modelsDescription": "全ユーザーが使えるモデル。個人の API キーは不要です",
"addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。", "addProviderHint": "プロバイダーを追加して、全ユーザーにモデルを提供します",
"selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。", "selectProviderHint": "プロバイダーを選択または追加し、キーとモデルを入力します",
"addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。", "addProviderToOfferModels": "モデルを 1 つ以上追加すると、ユーザーにこのプロバイダーが表示されます",
"managedViaEnv": "(環境変数で管理)", "managedViaEnv": "(環境変数)",
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。", "envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義されています。ここでは表示のみで、変更はそちらで行ってください",
"defaultModel": "デフォルトモデル", "defaultModel": "デフォルトモデル",
"noModelsConfigured": "モデルが構成されていません", "noModelsConfigured": "モデルはまだありません",
"modelCount": "{count} 個のモデル", "modelCount": "{count} 個のモデル",
"modelCountPlural": "{count} 個のモデル", "modelCountPlural": "{count} 個のモデル",
"default": "デフォルト", "default": "デフォルト",
"setAsDefault": "デフォルトプロバイダーに設定", "setAsDefault": "既定のプロバイダーにする",
"defaultProvider": "デフォルトプロバイダー", "defaultProvider": "既定のプロバイダー",
"modelIdPlaceholder": "モデル ID…", "modelIdPlaceholder": "モデル ID…",
"addModel": "モデルを追加", "addModel": "モデルを追加",
"suggested": "おすすめ", "suggested": "おすすめ",
"test": "テスト", "test": "テスト",
"testOk": "正常({ms}ms)", "testOk": "成功({ms} ms)",
"testFailed": "失敗", "testFailed": "失敗",
"removeModel": "{model} を削除", "removeModel": "{model} を削除",
"deleteProviderTitle": "{name} を削除しますか?", "deleteProviderTitle": "{name} を削除しますか?",
"deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。", "deleteProviderDesc": "保存すると、このプロバイダーのキーとモデルがサーバーから削除されます",
"cancel": "キャンセル", "cancel": "キャンセル",
"delete": "削除", "delete": "削除",
"groups": { "groups": {
"generation": { "generation": {
"title": "生成", "title": "応答の設定",
"description": "すべてのチャットリクエストに適用される出力パラメーター。" "description": "AI の応答のしかたを決めます。すべての会話に適用されます"
}, },
"access": { "access": {
"title": "アクセス制御", "title": "アクセス権",
"description": "このデプロイを使用できるユーザーを制限します。" "description": "このサイトを使える人を制限します"
}, },
"features": { "features": {
"title": "機能", "title": "オプション機能",
"description": "オプション機能とセキュリティの切り替え。" "description": "追加機能と安全のための設定"
}, },
"observability": { "observability": {
"title": "オブザーバビリティ", "title": "呼び出し記録",
"description": "LLM 呼び出しの Langfuse トレース。" "description": "モデルの呼び出しを Langfuse に記録します"
}, },
"quota": { "quota": {
"title": "クォータとレート制限", "title": "使用量の制限",
"description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。" "description": "IP ごとの制限。DynamoDB テーブルがあるときだけ有効です"
} }
}, },
"settings": { "settings": {
"TEMPERATURE": { "TEMPERATURE": {
"label": "温度", "label": "温度",
"description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。" "description": "高いほどランダムになります。温度に対応しない推論モデルでは空にしてください"
}, },
"MAX_OUTPUT_TOKENS": { "MAX_OUTPUT_TOKENS": {
"label": "最大出力トークン数" "label": "出力トークン上限"
}, },
"ACCESS_CODE_LIST": { "ACCESS_CODE_LIST": {
"label": "アクセスコード", "label": "アクセスコード",
"description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。" "description": "複数はカンマ区切り。いずれかを入力しないと使えません。空なら制限なし"
}, },
"ENABLE_VLM_VALIDATION": { "ENABLE_VLM_VALIDATION": {
"label": "VLM 図検証", "label": "図のチェック",
"description": "ビジョンモデルで生成された図を視覚的に検証します。" "description": "描画後、画像を読めるモデルがスクリーンショットをチェックします"
}, },
"VALIDATION_MODEL": { "VALIDATION_MODEL": {
"label": "検証モデル", "label": "チェック用モデル",
"description": "空の場合はデフォルトの AI モデルにフォールバックします。" "description": "空なら既定のモデルを使います"
}, },
"VALIDATION_TIMEOUT": { "VALIDATION_TIMEOUT": {
"label": "検証タイムアウト(ms)" "label": "チェックのタイムアウト(ms)"
}, },
"ENABLE_HISTORY_XML_REPLACE": { "ENABLE_HISTORY_XML_REPLACE": {
"label": "履歴 XML 圧縮", "label": "古い図を省略",
"description": "履歴内の古い図 XML をプレースホルダーで置き換えます。" "description": "会話履歴の古い図は短い印だけ残します"
}, },
"ALLOW_PRIVATE_URLS": { "ALLOW_PRIVATE_URLS": {
"label": "プライベート URL を許可", "label": "内部ネットワークへのアクセス",
"description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックします(SSRF 保護)。" "description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です"
}, },
"LANGFUSE_PUBLIC_KEY": { "LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key" "label": "Langfuse Public Key"
@@ -546,29 +598,29 @@
"label": "Langfuse Secret Key" "label": "Langfuse Secret Key"
}, },
"LANGFUSE_BASEURL": { "LANGFUSE_BASEURL": {
"label": "Langfuse Base URL" "label": "Langfuse の URL"
}, },
"DAILY_REQUEST_LIMIT": { "DAILY_REQUEST_LIMIT": {
"label": "1 日あたりのリクエスト上限", "label": "1 日のリクエスト数",
"description": "IP ごと 1 日あたり。" "description": "IP ごとの 1 日あたりのリクエスト数の上限"
}, },
"DAILY_TOKEN_LIMIT": { "DAILY_TOKEN_LIMIT": {
"label": "1 日あたりのトークン上限", "label": "1 日のトークン数",
"description": "IP ごと 1 日あたり。" "description": "IP ごとの 1 日あたりのトークン使用量の上限"
}, },
"TPM_LIMIT": { "TPM_LIMIT": {
"label": "1 分あたりのトークン数" "label": "1 分あたりのトークン数"
}, },
"DYNAMODB_QUOTA_TABLE": { "DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB テーブル", "label": "DynamoDB テーブル名",
"description": "空の場合、クォータの強制は無効になります。" "description": "空なら使用量を制限しません"
}, },
"DYNAMODB_REGION": { "DYNAMODB_REGION": {
"label": "DynamoDB リージョン" "label": "DynamoDB リージョン"
}, },
"QUOTA_TIMEZONE": { "QUOTA_TIMEZONE": {
"label": "クォータタイムゾーン", "label": "タイムゾーン",
"description": "1 日のリセット境界に使用するタイムゾーン。" "description": "1 日の使用量はこのタイムゾーンの 0 時にリセットされます"
} }
} }
}, },
@@ -612,7 +664,7 @@
"failed": "今回はうまくいきませんでした", "failed": "今回はうまくいきませんでした",
"failedRetrying": "うまくいかなかったので再試行しています", "failedRetrying": "うまくいかなかったので再試行しています",
"stopped": "停止しました", "stopped": "停止しました",
"truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 詳細 で最大出力トークン数を増やしてください。", "truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 描画 で最大出力トークン数を増やしてください。",
"noChanges": "目に見える変更はありません", "noChanges": "目に見える変更はありません",
"listSeparator": "、", "listSeparator": "、",
"shapesAddedOne": "図形を 1 個追加", "shapesAddedOne": "図形を 1 個追加",
@@ -642,7 +694,7 @@
}, },
"lobby": { "lobby": {
"title": "どんな図を描きますか?", "title": "どんな図を描きますか?",
"lede": "図の内容を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは通常の draw.io の図なので、続けて編集もエクスポートもできます。", "lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図", "placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図", "recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める", "drawYourself": "白紙のキャンバスから始める",
+174 -122
View File
@@ -72,7 +72,7 @@
"replicateDescription": "上傳並複製現有流程圖", "replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪畫", "creativeDrawing": "創意繪畫",
"creativeDescription": "繪製有趣且富有創意的內容", "creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例會立即開啟,不呼叫模型。", "cachedNote": "範例使用保存好的答案,立即顯示,不呼叫 AI。",
"mcpServer": "MCP 伺服器", "mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用" "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
}, },
@@ -82,9 +82,9 @@
"accessCode": "存取碼", "accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼", "accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。", "accessCodeDescription": "使用此應用程式需要存取碼。",
"aiProvider": "AI 提供商設定", "aiProvider": "AI 服務商設定",
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。", "aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
"provider": "提供商", "provider": "服務商",
"modelId": "模型 ID", "modelId": "模型 ID",
"apiKey": "API 金鑰", "apiKey": "API 金鑰",
"apiKeyPlaceholder": "您的 API 金鑰", "apiKeyPlaceholder": "您的 API 金鑰",
@@ -96,7 +96,7 @@
"language": "語言", "language": "語言",
"languageDescription": "選擇介面語言。", "languageDescription": "選擇介面語言。",
"theme": "主題", "theme": "主題",
"themeDescription": "介面用淺色還是深色。圖紙始終是白底,和匯出後的樣子一致。", "themeDescription": "介面用淺色還是深色,畫布跟隨同一主題。",
"diagramStyle": "圖形風格", "diagramStyle": "圖形風格",
"diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。", "diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。",
"sendShortcut": "傳送快速鍵", "sendShortcut": "傳送快速鍵",
@@ -118,11 +118,9 @@
"customSystemMessage": "自訂指令", "customSystemMessage": "自訂指令",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。", "customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...", "customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"maxOutputTokens": "最大輸出 token 數", "maxOutputTokens": "輸出 token 上限",
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。", "maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
"tabModels": "模型與金鑰", "tabModels": "模型",
"tabAppearance": "外觀",
"tabAdvanced": "進階",
"tabAbout": "關於", "tabAbout": "關於",
"themeLight": "淺色", "themeLight": "淺色",
"themeDarkMode": "深色", "themeDarkMode": "深色",
@@ -131,7 +129,11 @@
"proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭", "proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭",
"proxyFailed": "代理設定沒有生效", "proxyFailed": "代理設定沒有生效",
"appVersion": "版本", "appVersion": "版本",
"projectWebsite": "專案網站" "projectWebsite": "專案網站",
"tabGeneral": "一般",
"tabDrawing": "繪圖",
"sectionInterface": "介面",
"sectionAccess": "存取"
}, },
"save": { "save": {
"title": "匯出圖紙", "title": "匯出圖紙",
@@ -205,19 +207,18 @@
}, },
"quota": { "quota": {
"dailyLimit": "已達每日配額", "dailyLimit": "已達每日配額",
"tokenLimit": "已達每日令牌限制", "tokenLimit": "已達每日 token 上限",
"tpmLimit": "速率限制", "tpmLimit": "速率限制",
"tpmMessage": "請求過多。請稍等片刻。", "tpmMessage": "請求過多。請稍等片刻。",
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。", "tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。", "messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
"messageApiSelfHosted": null, "messageApiSelfHosted": null,
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。", "messageToken": "看來您今天的 token 用量已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
"messageTokenSelfHosted": null, "messageTokenSelfHosted": null,
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。", "tip": "<strong>提示:</strong>您可以使用自己的 API Key(點擊設定圖示)或自行託管專案來繞過這些限制。",
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。", "tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API Key 以繼續使用服務。",
"reset": "您的限制將在明天重設。感謝您的理解。", "reset": "您的限制將在明天重設。感謝您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。", "configModel": "使用自己的 API Key",
"configModel": "使用您的金鑰",
"selfHost": "自行託管", "selfHost": "自行託管",
"sponsor": "贊助", "sponsor": "贊助",
"learnMore": "了解更多 →", "learnMore": "了解更多 →",
@@ -351,38 +352,29 @@
}, },
"modelConfig": { "modelConfig": {
"configure": "配置", "configure": "配置",
"addProvider": "新增提供商", "addProvider": "新增服務商",
"addModel": "新增模型", "addModel": "新增模型",
"modelId": "模型 ID", "modelId": "模型 ID",
"modelLabel": "顯示名稱", "modelLabel": "顯示名稱",
"streaming": "啟用串流輸出", "streaming": "啟用串流輸出",
"deleteProvider": "刪除提供商", "deleteProvider": "刪除服務商",
"deleteModel": "刪除模型", "deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。", "noModels": "尚未配置模型。新增模型以開始使用。",
"selectProvider": "選擇一個提供商或新增", "validationError": "測試沒通過",
"configureMultiple": "配置多個 AI 提供商並輕鬆切換", "addModelFirst": "先新增一個模型。",
"apiKeyStored": "API 金鑰儲存在您的瀏覽器本機",
"test": "測試",
"validationError": "驗證失敗",
"addModelFirst": "請先新增至少一個模型以進行驗證",
"providers": "提供商",
"addProviderHint": "還沒有服務商",
"verified": "已驗證",
"configuration": "配置",
"displayName": "顯示名稱", "displayName": "顯示名稱",
"bedrockApiKey": "Bedrock API 金鑰", "bedrockApiKey": "Bedrock API Key",
"bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。", "bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。",
"awsAccessKeyId": "AWS 存取金鑰 ID", "awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key", "awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域", "awsRegion": "AWS 區域",
"selectRegion": "選擇區域", "selectRegion": "選擇區域",
"apiKey": "API 金鑰", "apiKey": "API Key",
"enterApiKey": "輸入您的 API 金鑰", "enterApiKey": "輸入 API Key",
"enterSecretKey": "輸入您的 Secret Key", "enterSecretKey": "輸入 Secret Access Key",
"baseUrl": "基礎 URL", "baseUrl": "基礎 URL",
"optional": "(可選)", "optional": "(可選)",
"getApiKey": "取得 API Key", "getApiKey": "取得 API Key",
"fetchModels": "從服務商取得模型清單",
"noTools": "不支援工具呼叫", "noTools": "不支援工具呼叫",
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。", "mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
"requestUrl": "請求將傳送至 {url}", "requestUrl": "請求將傳送至 {url}",
@@ -393,17 +385,11 @@
"models": "模型", "models": "模型",
"customModelId": "自訂模型 ID...", "customModelId": "自訂模型 ID...",
"allAdded": "已全部新增", "allAdded": "已全部新增",
"suggested": "推薦",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能為空", "modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在", "modelIdExists": "此模型 ID 已存在",
"configureProviders": "設定 AI 服務商",
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。", "deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認", "typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入提供商名稱...", "typeProviderName": "輸入服務商名稱...",
"modelsConfiguredCount": "已配置 {count} 個模型",
"validationFailedCount": "{count} 個模型驗證失敗",
"requestFailed": "請求失敗({status})", "requestFailed": "請求失敗({status})",
"cancel": "取消", "cancel": "取消",
"delete": "刪除", "delete": "刪除",
@@ -411,164 +397,230 @@
"usingServerDefault": "使用伺服器預設模型", "usingServerDefault": "使用伺服器預設模型",
"selectModel": "選擇模型", "selectModel": "選擇模型",
"searchModels": "搜尋模型...", "searchModels": "搜尋模型...",
"noVerifiedModels": "沒有已驗證的模型。請先測試您的模型。",
"noModelsFound": "未找到模型。", "noModelsFound": "未找到模型。",
"default": "預設", "default": "預設",
"serverDefault": "伺服器預設", "serverDefault": "伺服器模型",
"serverModels": "伺服器模型", "serverModels": "伺服器模型",
"userModels": "使用者模型", "userModels": "使用者模型",
"configureModels": "配置模型...", "configureModels": "配置模型...",
"onlyVerifiedShown": "僅顯示已驗證的模型",
"showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型", "serverDefaultModel": "伺服器預設模型",
"showValue": "顯示值", "showValue": "顯示值",
"hideValue": "隱藏值", "hideValue": "隱藏值",
"noProvidersTitle": "正在使用伺服器上設定的模型", "myProviders": "我的服務商",
"noProvidersHint": "想用其他模型,可以在左側清單下方新增服務商並填寫自己的 API Key。" "inUse": "正在使用",
"listSeparator": "、",
"serverModelsTitle": "伺服器提供的模型",
"serverDefaultSummary": "不用填寫 API Key,使用伺服器設定的預設模型。",
"statusError": "{count} 個模型出錯",
"statusIncomplete": "還沒設定完",
"statusUntested": "{count} 個模型未測試",
"statusOk": "{count} 個模型可用",
"noModelsYet": "還沒有模型",
"keysStayLocal": "API Key 只儲存在這台裝置的瀏覽器裡,發送請求時才帶給伺服器。",
"useOwnKey": "使用自己的 API Key",
"useOwnKeyHint": "接入其他服務商後,就能在聊天框的模型選擇器裡換用它們的模型。選一個開始:",
"viewAllProviders": "查看全部 {count} 個服務商",
"searchProviders": "搜尋服務商,例如 DeepSeek、Qwen、Ollama",
"noProvidersFound": "沒有找到這個服務商。",
"groupVendors": "模型廠商",
"groupAggregators": "聚合平台",
"groupCloud": "雲端平台",
"groupSelfHosted": "自行部署",
"noteBedrock": "AWS 憑證",
"noteVertex": "Express 模式 Key",
"noteAzure": "Key 和資源位址",
"noteEdgeone": "不用 Key",
"noteOllama": "Key 可不填",
"noteSglang": "填服務位址",
"added": "已新增",
"awsCredentials": "AWS 憑證",
"bedrockAuthApiKey": "Bedrock API Key",
"bedrockAuthAccessKey": "存取金鑰",
"moreOptions": "更多選項:Base URL、顯示名稱",
"moreOptionsName": "更多選項:顯示名稱",
"keyRejected": "服務商拒絕了這個 API Key。檢查上面的 Key 是否複製完整。",
"modelNotFound": "服務商找不到這個模型 ID,請檢查拼寫。",
"modelWorks": "可用",
"modelWorksTime": "可用 · {seconds} 秒",
"modelFailed": "出錯",
"modelUntested": "未測試",
"needKey": "先填好 API Key。",
"needAws": "先填好 AWS 憑證和區域。",
"needModel": "先新增一個模型。",
"stepConnect": "連線",
"edgeoneNoKey": "EdgeOne Pages 使用這個網站部署時內建的模型服務,不用填寫 API Key。",
"stepModels": "選擇模型",
"fetchAllModels": "從 {name} 取得全部模型",
"suggestedLabel": "推薦:",
"orTypeModelId": "或者輸入其他模型 ID",
"add": "新增",
"browseModels": "瀏覽模型",
"stepTest": "測試",
"testHint": "給每個模型發一條很短的訊息,確認 Key 和模型都能用。",
"testOne": "測試 {model}",
"testAll": "測試全部 {count} 個模型",
"testPassedOne": "{model} 可以用了",
"testPassedAll": "{count} 個模型都可以用了",
"answeredIn": "(回覆用了 {seconds} 秒)",
"useInChat": "在聊天裡使用 {model}",
"backToProviders": "返回服務商列表",
"deleteProviderOnly": "刪除 {name}",
"addProviderEntry": "新增服務商…",
"awsKeysRejected": "AWS 拒絕了這組存取金鑰。檢查兩個金鑰是否都複製完整。",
"needAzure": "先填好 API Key 和 Azure 資源的 Base URL。",
"validationFailedCountOne": "{count} 個模型沒通過測試",
"validationFailedCountOther": "{count} 個模型沒通過測試",
"deleteProviderNamedOne": "刪除 {name} 和它的 {count} 個模型",
"deleteProviderNamedOther": "刪除 {name} 和它的 {count} 個模型",
"serverModelsSummaryOne": "不用填寫 API Key。共 {count} 個:{names}",
"serverModelsSummaryOther": "不用填寫 API Key。共 {count} 個:{names}",
"noModelsReturned": "服務商沒有回傳任何模型。",
"awsSessionToken": "AWS Session Token(選填)",
"awsSessionTokenHint": "只有臨時憑證才需要,也就是以 ASIA 開頭的存取金鑰。",
"bedrockBothSaved": "這個服務商還保存著一對存取金鑰,有 API Key 時它們不會被用到。",
"removeAccessKeys": "刪除存取金鑰"
}, },
"admin": { "admin": {
"title": "管理員設定", "title": "後台設定",
"loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。", "loginPrompt": "輸入管理員密碼(環境變數 ADMIN_PASSWORD)",
"password": "密碼", "password": "密碼",
"signIn": "登入", "signIn": "登入",
"signingIn": "正在登入…", "signingIn": "登入中…",
"loginFailed": "登入失敗", "loginFailed": "登入失敗",
"precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值", "precedence": "優先順序:本頁設定 > 環境變數 > 預設值",
"notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。", "notWritable": "這裡無法儲存設定,只能檢視;請改用環境變數",
"settingGroups": "設定分組", "settingGroups": "設定分類",
"enabled": "已啟用", "enabled": "已開啟",
"disabled": "已停用", "disabled": "已關閉",
"enableGroup": "啟用 {group}", "enableGroup": "開啟{group}",
"unsavedChanges": "有未儲存的變更", "unsavedChanges": "有未儲存的修改",
"saved": "設定已儲存,變更立即生效。", "saved": "已儲存,立即生效(需重啟的項目除外)",
"saveFailed": "儲存失敗。請檢查網路連線後重試。", "saveFailed": "儲存失敗,請檢查網路後重試",
"invalidSettings": "部分設定無效。", "invalidSettings": "有設定填寫有誤,請依提示修改",
"discard": "捨棄", "discard": "捨棄修改",
"saveChanges": "儲存變更", "saveChanges": "儲存修改",
"saving": "正在儲存…", "saving": "儲存中…",
"sourceSaved": "已儲存", "sourceSaved": "本頁設定",
"sourceEnv": "環境變數", "sourceEnv": "環境變數",
"sourceSavedTitle": "在管理員設定檔中設定", "sourceSavedTitle": "來自本頁設定",
"sourceEnvTitle": "透過環境變數設定", "sourceEnvTitle": "來自環境變數",
"restartRequired": "需要重新啟動", "restartRequired": "需重啟",
"modified": "已修改", "modified": "已修改",
"notSet": "未設定", "notSet": "未設定",
"savedReplace": "已儲存({hint})——輸入以取代", "savedReplace": "已儲存({hint}),輸入新值即可取代",
"showValue": "顯示值", "showValue": "顯示",
"hideValue": "隱藏值", "hideValue": "隱藏",
"removeValue": "移除值", "removeValue": "清除",
"removeValueTitle": "移除已儲存的值", "removeValueTitle": "清除已儲存的值",
"resetToDefault": "重設為預設", "resetToDefault": "重設為預設",
"models": "模型", "models": "模型",
"modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。", "modelsDescription": "給所有使用者用的模型,無需自備金鑰",
"addProviderHint": "新增一個 provider,為所有使用者提供伺服器端模型。", "addProviderHint": "新增服務商,為所有使用者提供模型",
"selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。", "selectProviderHint": "選擇或新增服務商,填寫金鑰和模型",
"addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。", "addProviderToOfferModels": "至少新增一個模型,使用者才能看到這個服務商",
"managedViaEnv": "(透過環境變數管理)", "managedViaEnv": "(環境變數)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。", "envReadOnly": "由 AI_MODELS_CONFIG / ai-models.json 定義,這裡只能檢視,請到那裡修改",
"defaultModel": "預設模型", "defaultModel": "預設模型",
"noModelsConfigured": "未設定模型", "noModelsConfigured": "尚無模型",
"modelCount": "{count} 個模型", "modelCount": "{count} 個模型",
"modelCountPlural": "{count} 個模型", "modelCountPlural": "{count} 個模型",
"default": "預設", "default": "預設",
"setAsDefault": "設為預設 provider", "setAsDefault": "設為預設服務商",
"defaultProvider": "預設 provider", "defaultProvider": "預設服務商",
"modelIdPlaceholder": "模型 ID…", "modelIdPlaceholder": "模型 ID…",
"addModel": "新增模型", "addModel": "新增模型",
"suggested": "推薦", "suggested": "推薦",
"test": "測試", "test": "測試",
"testOk": "正常({ms} 毫秒)", "testOk": "通過({ms} 毫秒)",
"testFailed": "失敗", "testFailed": "失敗",
"removeModel": "移除 {model}", "removeModel": "刪除 {model}",
"deleteProviderTitle": "刪除 {name}?", "deleteProviderTitle": "刪除 {name}?",
"deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。", "deleteProviderDesc": "儲存後,它的金鑰和模型會從伺服器刪除",
"cancel": "取消", "cancel": "取消",
"delete": "刪除", "delete": "刪除",
"groups": { "groups": {
"generation": { "generation": {
"title": "生成", "title": "回覆設定",
"description": "套用於所有聊天請求的輸出參數。" "description": "控制 AI 怎麼回覆,對所有對話生效"
}, },
"access": { "access": {
"title": "存取控制", "title": "存取權限",
"description": "限制誰可以使用此部署。" "description": "限制誰可以使用這個網站"
}, },
"features": { "features": {
"title": "功能", "title": "選用功能",
"description": "選用功能和安全開關。" "description": "附加功能和安全選項"
}, },
"observability": { "observability": {
"title": "可觀測性", "title": "呼叫記錄",
"description": "對 LLM 呼叫進行 Langfuse 追蹤。" "description": "用 Langfuse 記錄模型呼叫"
}, },
"quota": { "quota": {
"title": "配額與速率限制", "title": "用量限制",
"description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。" "description": "按 IP 限制用量,需要 DynamoDB 資料表才生效"
} }
}, },
"settings": { "settings": {
"TEMPERATURE": { "TEMPERATURE": {
"label": "溫度", "label": "溫度",
"description": "對於拒絕溫度參數的推理模型,請留空。" "description": "越高越隨機;不支援溫度的推理模型請留空"
}, },
"MAX_OUTPUT_TOKENS": { "MAX_OUTPUT_TOKENS": {
"label": "最大輸出 token 數" "label": "輸出 token 上限"
}, },
"ACCESS_CODE_LIST": { "ACCESS_CODE_LIST": {
"label": "存取碼", "label": "存取碼",
"description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。" "description": "逗號分隔多個;輸入其中一個才能用;留空則不限"
}, },
"ENABLE_VLM_VALIDATION": { "ENABLE_VLM_VALIDATION": {
"label": "VLM 圖表驗證", "label": "圖表檢查",
"description": "使用視覺模型對產生的圖表進行視覺化驗證。" "description": "畫完後讓能看圖的模型檢查截圖"
}, },
"VALIDATION_MODEL": { "VALIDATION_MODEL": {
"label": "驗證模型", "label": "檢查模型",
"description": "留空時回退到預設 AI 模型。" "description": "留空則用預設模型"
}, },
"VALIDATION_TIMEOUT": { "VALIDATION_TIMEOUT": {
"label": "驗證逾時(毫秒)" "label": "檢查逾時(毫秒)"
}, },
"ENABLE_HISTORY_XML_REPLACE": { "ENABLE_HISTORY_XML_REPLACE": {
"label": "歷史 XML 壓縮", "label": "精簡舊圖",
"description": "用占位符取代歷史記錄中的舊圖表 XML。" "description": "對話歷史裡的舊圖表只留簡短標記"
}, },
"ALLOW_PRIVATE_URLS": { "ALLOW_PRIVATE_URLS": {
"label": "允許私有 URL", "label": "存取內網",
"description": "關閉以阻擋對私有 IP 和內部主機名的請求(SSRF 防護)。" "description": "關閉後,自訂模型位址不能指向內網,更安全"
}, },
"LANGFUSE_PUBLIC_KEY": { "LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key" "label": "Langfuse 公鑰"
}, },
"LANGFUSE_SECRET_KEY": { "LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key" "label": "Langfuse 金鑰"
}, },
"LANGFUSE_BASEURL": { "LANGFUSE_BASEURL": {
"label": "Langfuse Base URL" "label": "Langfuse 位址"
}, },
"DAILY_REQUEST_LIMIT": { "DAILY_REQUEST_LIMIT": {
"label": "每日請求上限", "label": "每日請求",
"description": "每個 IP 每天。" "description": "每個 IP 每天的請求次數上限"
}, },
"DAILY_TOKEN_LIMIT": { "DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限", "label": "每日 token",
"description": "每個 IP 每天。" "description": "每個 IP 每天的 token 用量上限"
}, },
"TPM_LIMIT": { "TPM_LIMIT": {
"label": "每分鐘 token 數" "label": "每分鐘 token"
}, },
"DYNAMODB_QUOTA_TABLE": { "DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表", "label": "DynamoDB 資料表名稱",
"description": "留空時配額強制執行被停用。" "description": "留空則不限制用量"
}, },
"DYNAMODB_REGION": { "DYNAMODB_REGION": {
"label": "DynamoDB 區域" "label": "DynamoDB 區域"
}, },
"QUOTA_TIMEZONE": { "QUOTA_TIMEZONE": {
"label": "配額時區", "label": "時區",
"description": "每日重置邊界所用的時區。" "description": "每日用量按這個時區的零點重置"
} }
} }
}, },
@@ -612,7 +664,7 @@
"failed": "這次沒有成功", "failed": "這次沒有成功",
"failedRetrying": "這次沒有成功,正在重試", "failedRetrying": "這次沒有成功,正在重試",
"stopped": "已停止", "stopped": "已停止",
"truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 進階 中調高最大輸出長度。", "truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 繪圖 中調高最大輸出長度。",
"noChanges": "沒有可見的變化", "noChanges": "沒有可見的變化",
"listSeparator": ",", "listSeparator": ",",
"shapesAddedOne": "新增 1 個圖形", "shapesAddedOne": "新增 1 個圖形",
@@ -642,7 +694,7 @@
}, },
"lobby": { "lobby": {
"title": "說說你要畫什麼", "title": "說說你要畫什麼",
"lede": "描述一張圖,或上傳截圖、PDF。畫出來的是標準的 draw.io 圖表,可以接著改,也可以匯出。", "lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試", "placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙", "recent": "最近的圖紙",
"drawYourself": "從空白畫布開始", "drawYourself": "從空白畫布開始",
+174 -122
View File
@@ -72,7 +72,7 @@
"replicateDescription": "上传并复制现有流程图", "replicateDescription": "上传并复制现有流程图",
"creativeDrawing": "创意绘画", "creativeDrawing": "创意绘画",
"creativeDescription": "绘制有趣且富有创意的内容", "creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例会立即打开,不调用模型。", "cachedNote": "示例使用保存好的答案,立即显示,不调用 AI。",
"mcpServer": "MCP 服务器", "mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用" "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
}, },
@@ -82,9 +82,9 @@
"accessCode": "访问码", "accessCode": "访问码",
"accessCodePlaceholder": "输入访问码", "accessCodePlaceholder": "输入访问码",
"accessCodeDescription": "使用此应用程序需要访问码。", "accessCodeDescription": "使用此应用程序需要访问码。",
"aiProvider": "AI 提供商设置", "aiProvider": "AI 服务商设置",
"aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。", "aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。",
"provider": "提供商", "provider": "服务商",
"modelId": "模型 ID", "modelId": "模型 ID",
"apiKey": "API 密钥", "apiKey": "API 密钥",
"apiKeyPlaceholder": "您的 API 密钥", "apiKeyPlaceholder": "您的 API 密钥",
@@ -96,7 +96,7 @@
"language": "语言", "language": "语言",
"languageDescription": "选择界面语言。", "languageDescription": "选择界面语言。",
"theme": "主题", "theme": "主题",
"themeDescription": "界面用浅色还是深色。图纸始终是白底,和导出后的样子一致。", "themeDescription": "界面用浅色还是深色,画布跟随同一主题。",
"diagramStyle": "图形风格", "diagramStyle": "图形风格",
"diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。", "diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。",
"sendShortcut": "发送快捷键", "sendShortcut": "发送快捷键",
@@ -118,11 +118,9 @@
"customSystemMessage": "自定义指令", "customSystemMessage": "自定义指令",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。", "customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...", "customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"maxOutputTokens": "最大输出 token 数", "maxOutputTokens": "输出 token 上限",
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。", "maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
"tabModels": "模型和密钥", "tabModels": "模型",
"tabAppearance": "外观",
"tabAdvanced": "高级",
"tabAbout": "关于", "tabAbout": "关于",
"themeLight": "浅色", "themeLight": "浅色",
"themeDarkMode": "深色", "themeDarkMode": "深色",
@@ -131,7 +129,11 @@
"proxyInvalid": "代理地址必须以 http:// 或 https:// 开头", "proxyInvalid": "代理地址必须以 http:// 或 https:// 开头",
"proxyFailed": "代理设置没有生效", "proxyFailed": "代理设置没有生效",
"appVersion": "版本", "appVersion": "版本",
"projectWebsite": "项目网站" "projectWebsite": "项目网站",
"tabGeneral": "通用",
"tabDrawing": "绘图",
"sectionInterface": "界面",
"sectionAccess": "访问"
}, },
"save": { "save": {
"title": "导出图纸", "title": "导出图纸",
@@ -205,19 +207,18 @@
}, },
"quota": { "quota": {
"dailyLimit": "已达每日配额", "dailyLimit": "已达每日配额",
"tokenLimit": "已达每日令牌限制", "tokenLimit": "已达每日 token 上限",
"tpmLimit": "速率限制", "tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。", "tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。", "tpmMessageDetailed": "达到速率限制(每分钟 {limit} token)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。", "messageApi": "看来您今天的体验次数已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
"messageApiSelfHosted": null, "messageApiSelfHosted": null,
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。", "messageToken": "看来您今天的 token 用量已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
"messageTokenSelfHosted": null, "messageTokenSelfHosted": null,
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。", "tip": "<strong>提示:</strong>您可以使用自己的 API Key(点击设置图标)或自托管项目来绕过这些限制。",
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。", "tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API Key 以继续使用服务。",
"reset": "您的限制将在明天重置。感谢您的理解。", "reset": "您的限制将在明天重置。感谢您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。", "configModel": "使用自己的 API Key",
"configModel": "使用您的密钥",
"selfHost": "自托管", "selfHost": "自托管",
"sponsor": "赞助", "sponsor": "赞助",
"learnMore": "了解更多 →", "learnMore": "了解更多 →",
@@ -351,38 +352,29 @@
}, },
"modelConfig": { "modelConfig": {
"configure": "配置", "configure": "配置",
"addProvider": "添加提供商", "addProvider": "添加服务商",
"addModel": "添加模型", "addModel": "添加模型",
"modelId": "模型 ID", "modelId": "模型 ID",
"modelLabel": "显示名称", "modelLabel": "显示名称",
"streaming": "启用流式输出", "streaming": "启用流式输出",
"deleteProvider": "删除提供商", "deleteProvider": "删除服务商",
"deleteModel": "删除模型", "deleteModel": "删除模型",
"noModels": "尚未配置模型。添加模型以开始使用。", "noModels": "尚未配置模型。添加模型以开始使用。",
"selectProvider": "选择一个提供商或添加新的", "validationError": "测试没通过",
"configureMultiple": "配置多个 AI 提供商并轻松切换", "addModelFirst": "先添加一个模型。",
"apiKeyStored": "API 密钥存储在您的浏览器本地",
"test": "测试",
"validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证",
"providers": "提供商",
"addProviderHint": "还没有服务商",
"verified": "已验证",
"configuration": "配置",
"displayName": "显示名称", "displayName": "显示名称",
"bedrockApiKey": "Bedrock API 密钥", "bedrockApiKey": "Bedrock API Key",
"bedrockApiKeyHint": "填了 API 密钥,下面的访问密钥就不用填。", "bedrockApiKeyHint": "填了 API 密钥,下面的访问密钥就不用填。",
"awsAccessKeyId": "AWS 访问密钥 ID", "awsAccessKeyId": "AWS 访问密钥 ID",
"awsSecretAccessKey": "AWS Secret Access Key", "awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域", "awsRegion": "AWS 区域",
"selectRegion": "选择区域", "selectRegion": "选择区域",
"apiKey": "API 密钥", "apiKey": "API Key",
"enterApiKey": "输入您的 API 密钥", "enterApiKey": "输入 API Key",
"enterSecretKey": "输入您的 Secret Key", "enterSecretKey": "输入 Secret Access Key",
"baseUrl": "基础 URL", "baseUrl": "基础 URL",
"optional": "(可选)", "optional": "(可选)",
"getApiKey": "获取 API Key", "getApiKey": "获取 API Key",
"fetchModels": "从服务商获取模型列表",
"noTools": "不支持工具调用", "noTools": "不支持工具调用",
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。", "mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
"requestUrl": "请求将发往 {url}", "requestUrl": "请求将发往 {url}",
@@ -393,17 +385,11 @@
"models": "模型", "models": "模型",
"customModelId": "自定义模型 ID...", "customModelId": "自定义模型 ID...",
"allAdded": "已全部添加", "allAdded": "已全部添加",
"suggested": "推荐",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能为空", "modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在", "modelIdExists": "此模型 ID 已存在",
"configureProviders": "设置 AI 服务商",
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。", "deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认", "typeToConfirm": "输入 \"{name}\" 以确认",
"typeProviderName": "输入提供商名称...", "typeProviderName": "输入服务商名称...",
"modelsConfiguredCount": "已配置 {count} 个模型",
"validationFailedCount": "{count} 个模型验证失败",
"requestFailed": "请求失败({status})", "requestFailed": "请求失败({status})",
"cancel": "取消", "cancel": "取消",
"delete": "删除", "delete": "删除",
@@ -411,164 +397,230 @@
"usingServerDefault": "使用服务器默认模型", "usingServerDefault": "使用服务器默认模型",
"selectModel": "选择模型", "selectModel": "选择模型",
"searchModels": "搜索模型...", "searchModels": "搜索模型...",
"noVerifiedModels": "没有已验证的模型。请先测试您的模型。",
"noModelsFound": "未找到模型。", "noModelsFound": "未找到模型。",
"default": "默认", "default": "默认",
"serverDefault": "服务器默认", "serverDefault": "服务器模型",
"serverModels": "服务器模型", "serverModels": "服务器模型",
"userModels": "用户模型", "userModels": "用户模型",
"configureModels": "配置模型...", "configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型",
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型", "serverDefaultModel": "服务器默认模型",
"showValue": "显示值", "showValue": "显示值",
"hideValue": "隐藏值", "hideValue": "隐藏值",
"noProvidersTitle": "正在使用服务器上配置的模型", "myProviders": "我的服务商",
"noProvidersHint": "想用其他模型,可以在左侧列表下方添加服务商并填写自己的 API Key。" "inUse": "正在使用",
"listSeparator": "、",
"serverModelsTitle": "服务器提供的模型",
"serverDefaultSummary": "不用填写 API Key,使用服务器配置的默认模型。",
"statusError": "{count} 个模型出错",
"statusIncomplete": "还没设置完",
"statusUntested": "{count} 个模型未测试",
"statusOk": "{count} 个模型可用",
"noModelsYet": "还没有模型",
"keysStayLocal": "API Key 只保存在这台设备的浏览器里,发请求时才带给服务器。",
"useOwnKey": "用自己的 API Key",
"useOwnKeyHint": "接入其他服务商后,就能在聊天框的模型选择器里换用它们的模型。选一个开始:",
"viewAllProviders": "查看全部 {count} 个服务商",
"searchProviders": "搜索服务商,比如 DeepSeek、Qwen、Ollama",
"noProvidersFound": "没有找到这个服务商。",
"groupVendors": "模型厂商",
"groupAggregators": "聚合平台",
"groupCloud": "云平台",
"groupSelfHosted": "自己部署",
"noteBedrock": "AWS 凭证",
"noteVertex": "Express 模式 Key",
"noteAzure": "Key 和资源地址",
"noteEdgeone": "不用 Key",
"noteOllama": "Key 可不填",
"noteSglang": "填服务地址",
"added": "已添加",
"awsCredentials": "AWS 凭证",
"bedrockAuthApiKey": "Bedrock API Key",
"bedrockAuthAccessKey": "访问密钥",
"moreOptions": "更多选项:Base URL、显示名称",
"moreOptionsName": "更多选项:显示名称",
"keyRejected": "服务商拒绝了这个 API Key。检查上面的 Key 是否复制完整。",
"modelNotFound": "服务商找不到这个模型 ID,请检查拼写。",
"modelWorks": "可用",
"modelWorksTime": "可用 · {seconds} 秒",
"modelFailed": "出错",
"modelUntested": "未测试",
"needKey": "先填好 API Key。",
"needAws": "先填好 AWS 凭证和区域。",
"needModel": "先添加一个模型。",
"stepConnect": "连接",
"edgeoneNoKey": "EdgeOne Pages 使用这个网站部署时自带的模型服务,不用填写 API Key。",
"stepModels": "选择模型",
"fetchAllModels": "从 {name} 获取全部模型",
"suggestedLabel": "推荐:",
"orTypeModelId": "或者输入其他模型 ID",
"add": "添加",
"browseModels": "浏览模型",
"stepTest": "测试",
"testHint": "给每个模型发一条很短的消息,确认 Key 和模型都能用。",
"testOne": "测试 {model}",
"testAll": "测试全部 {count} 个模型",
"testPassedOne": "{model} 可以用了",
"testPassedAll": "{count} 个模型都可以用了",
"answeredIn": "(回复用了 {seconds} 秒)",
"useInChat": "在聊天里使用 {model}",
"backToProviders": "返回服务商列表",
"deleteProviderOnly": "删除 {name}",
"addProviderEntry": "添加服务商…",
"awsKeysRejected": "AWS 拒绝了这组访问密钥。检查两个密钥是否都复制完整。",
"needAzure": "先填好 API Key 和 Azure 资源的 Base URL。",
"validationFailedCountOne": "{count} 个模型没通过测试",
"validationFailedCountOther": "{count} 个模型没通过测试",
"deleteProviderNamedOne": "删除 {name} 和它的 {count} 个模型",
"deleteProviderNamedOther": "删除 {name} 和它的 {count} 个模型",
"serverModelsSummaryOne": "不用填写 API Key。共 {count} 个:{names}",
"serverModelsSummaryOther": "不用填写 API Key。共 {count} 个:{names}",
"noModelsReturned": "服务商没有返回任何模型。",
"awsSessionToken": "AWS Session Token(可选)",
"awsSessionTokenHint": "只有临时凭证才需要,也就是以 ASIA 开头的访问密钥。",
"bedrockBothSaved": "这个服务商还保存着一对访问密钥,有 API Key 时它们不会被用到。",
"removeAccessKeys": "删除访问密钥"
}, },
"admin": { "admin": {
"title": "管理员设置", "title": "后台设置",
"loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。", "loginPrompt": "输入管理员密码(环境变量 ADMIN_PASSWORD)",
"password": "密码", "password": "密码",
"signIn": "登录", "signIn": "登录",
"signingIn": "正在登录…", "signingIn": "登录中…",
"loginFailed": "登录失败", "loginFailed": "登录失败",
"precedence": "文件覆盖环境变量 · 环境变量覆盖默认值", "precedence": "优先级:本页设置 > 环境变量 > 默认值",
"notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。", "notWritable": "这里无法保存设置,只能查看;请改用环境变量",
"settingGroups": "设置分组", "settingGroups": "设置分类",
"enabled": "已启用", "enabled": "已开启",
"disabled": "已禁用", "disabled": "已关闭",
"enableGroup": "启用 {group}", "enableGroup": "开启{group}",
"unsavedChanges": "有未保存的更改", "unsavedChanges": "有未保存的修改",
"saved": "设置已保存,更改立即生效。", "saved": "已保存,立即生效(需重启的项除外)",
"saveFailed": "保存失败。请检查网络连接后重试。", "saveFailed": "保存失败,请检查网络后重试",
"invalidSettings": "部分设置无效。", "invalidSettings": "有设置填写有误,请按提示修改",
"discard": "放弃", "discard": "放弃修改",
"saveChanges": "保存更改", "saveChanges": "保存修改",
"saving": "正在保存…", "saving": "保存中…",
"sourceSaved": "已保存", "sourceSaved": "本页设置",
"sourceEnv": "环境变量", "sourceEnv": "环境变量",
"sourceSavedTitle": "在管理员设置文件中设置", "sourceSavedTitle": "来自本页设置",
"sourceEnvTitle": "通过环境变量设置", "sourceEnvTitle": "来自环境变量",
"restartRequired": "需要重启", "restartRequired": "需重启",
"modified": "已修改", "modified": "已修改",
"notSet": "未设置", "notSet": "未设置",
"savedReplace": "已保存({hint})——输入以替换", "savedReplace": "已保存({hint}),输入新值可替换",
"showValue": "显示值", "showValue": "显示",
"hideValue": "隐藏值", "hideValue": "隐藏",
"removeValue": "移除值", "removeValue": "清除",
"removeValueTitle": "移除已保存的值", "removeValueTitle": "清除已保存的值",
"resetToDefault": "恢复默认", "resetToDefault": "恢复默认",
"models": "模型", "models": "模型",
"modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。", "modelsDescription": "给所有用户用的模型,无需自带密钥",
"addProviderHint": "添加一个 provider,为所有用户提供服务端模型。", "addProviderHint": "添加服务商,为所有用户提供模型",
"selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。", "selectProviderHint": "选择或添加服务商,填写密钥和模型",
"addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。", "addProviderToOfferModels": "至少添加一个模型,用户才能看到这个服务商",
"managedViaEnv": "(通过环境变量管理)", "managedViaEnv": "(环境变量)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。", "envReadOnly": "由 AI_MODELS_CONFIG / ai-models.json 定义,这里只能查看,请到那里修改",
"defaultModel": "默认模型", "defaultModel": "默认模型",
"noModelsConfigured": "未配置模型", "noModelsConfigured": "暂无模型",
"modelCount": "{count} 个模型", "modelCount": "{count} 个模型",
"modelCountPlural": "{count} 个模型", "modelCountPlural": "{count} 个模型",
"default": "默认", "default": "默认",
"setAsDefault": "设为默认 provider", "setAsDefault": "设为默认服务商",
"defaultProvider": "默认 provider", "defaultProvider": "默认服务商",
"modelIdPlaceholder": "模型 ID…", "modelIdPlaceholder": "模型 ID…",
"addModel": "添加模型", "addModel": "添加模型",
"suggested": "推荐", "suggested": "推荐",
"test": "测试", "test": "测试",
"testOk": "正常({ms} 毫秒)", "testOk": "通过({ms} 毫秒)",
"testFailed": "失败", "testFailed": "失败",
"removeModel": "移除 {model}", "removeModel": "删除 {model}",
"deleteProviderTitle": "删除 {name}?", "deleteProviderTitle": "删除 {name}?",
"deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。", "deleteProviderDesc": "保存后,它的密钥和模型会从服务器删除",
"cancel": "取消", "cancel": "取消",
"delete": "删除", "delete": "删除",
"groups": { "groups": {
"generation": { "generation": {
"title": "生成", "title": "回复设置",
"description": "应用于所有聊天请求的输出参数。" "description": "控制 AI 怎么回复,对所有对话生效"
}, },
"access": { "access": {
"title": "访问控制", "title": "访问权限",
"description": "限制谁可以使用此部署。" "description": "限制谁可以使用这个站点"
}, },
"features": { "features": {
"title": "功能", "title": "可选功能",
"description": "可选功能和安全开关。" "description": "附加功能和安全选项"
}, },
"observability": { "observability": {
"title": "可观测性", "title": "调用记录",
"description": "对 LLM 调用进行 Langfuse 追踪。" "description": "用 Langfuse 记录模型调用"
}, },
"quota": { "quota": {
"title": "配额与速率限制", "title": "用量限制",
"description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。" "description": "按 IP 限制用量,需要 DynamoDB 表才生效"
} }
}, },
"settings": { "settings": {
"TEMPERATURE": { "TEMPERATURE": {
"label": "温度", "label": "温度",
"description": "对于拒绝温度参数的推理模型,请留空。" "description": "越高越随机;不支持温度的推理模型请留空"
}, },
"MAX_OUTPUT_TOKENS": { "MAX_OUTPUT_TOKENS": {
"label": "最大输出 token 数" "label": "输出 token 上限"
}, },
"ACCESS_CODE_LIST": { "ACCESS_CODE_LIST": {
"label": "访问码", "label": "访问码",
"description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。" "description": "逗号分隔多个;输入其中一个才能用;留空则不限"
}, },
"ENABLE_VLM_VALIDATION": { "ENABLE_VLM_VALIDATION": {
"label": "VLM 图表验证", "label": "图表检查",
"description": "使用视觉模型对生成的图表进行可视化验证。" "description": "画完后让能看图的模型检查截图"
}, },
"VALIDATION_MODEL": { "VALIDATION_MODEL": {
"label": "验证模型", "label": "检查模型",
"description": "留空时回退到默认 AI 模型。" "description": "留空则用默认模型"
}, },
"VALIDATION_TIMEOUT": { "VALIDATION_TIMEOUT": {
"label": "验证超时(毫秒)" "label": "检查超时(毫秒)"
}, },
"ENABLE_HISTORY_XML_REPLACE": { "ENABLE_HISTORY_XML_REPLACE": {
"label": "历史 XML 压缩", "label": "精简旧图",
"description": "用占位符替换历史记录中的旧图表 XML。" "description": "对话历史里的旧图表只留简短标记"
}, },
"ALLOW_PRIVATE_URLS": { "ALLOW_PRIVATE_URLS": {
"label": "允许私有 URL", "label": "访问内网",
"description": "关闭以阻止对私有 IP 和内部主机名的请求(SSRF 防护)。" "description": "关闭后,自定义模型地址不能指向内网,更安全"
}, },
"LANGFUSE_PUBLIC_KEY": { "LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key" "label": "Langfuse 公钥"
}, },
"LANGFUSE_SECRET_KEY": { "LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key" "label": "Langfuse 密钥"
}, },
"LANGFUSE_BASEURL": { "LANGFUSE_BASEURL": {
"label": "Langfuse Base URL" "label": "Langfuse 地址"
}, },
"DAILY_REQUEST_LIMIT": { "DAILY_REQUEST_LIMIT": {
"label": "每日请求上限", "label": "每日请求",
"description": "每个 IP 每天。" "description": "每个 IP 每天的请求次数上限"
}, },
"DAILY_TOKEN_LIMIT": { "DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限", "label": "每日 token",
"description": "每个 IP 每天。" "description": "每个 IP 每天的 token 用量上限"
}, },
"TPM_LIMIT": { "TPM_LIMIT": {
"label": "每分钟 token 数" "label": "每分钟 token"
}, },
"DYNAMODB_QUOTA_TABLE": { "DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表", "label": "DynamoDB 表名",
"description": "留空时配额强制执行被禁用。" "description": "留空则不限制用量"
}, },
"DYNAMODB_REGION": { "DYNAMODB_REGION": {
"label": "DynamoDB 区域" "label": "DynamoDB 区域"
}, },
"QUOTA_TIMEZONE": { "QUOTA_TIMEZONE": {
"label": "配额时区", "label": "时区",
"description": "每日重置边界所用的时区。" "description": "每日用量按这个时区的零点重置"
} }
} }
}, },
@@ -612,7 +664,7 @@
"failed": "这次没有成功", "failed": "这次没有成功",
"failedRetrying": "这次没有成功,正在重试", "failedRetrying": "这次没有成功,正在重试",
"stopped": "已停止", "stopped": "已停止",
"truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 高级 里调高最大输出长度。", "truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 绘图 里调高最大输出长度。",
"noChanges": "没有可见的变化", "noChanges": "没有可见的变化",
"listSeparator": ",", "listSeparator": ",",
"shapesAddedOne": "新增 1 个图形", "shapesAddedOne": "新增 1 个图形",
@@ -642,7 +694,7 @@
}, },
"lobby": { "lobby": {
"title": "说说你要画什么", "title": "说说你要画什么",
"lede": "描述一张图,或者上传截图、PDF。画出来的是标准的 draw.io 图表,可以接着改,也可以导出。", "lede": "描述一张图,或上传截图、PDF。得到的是可以继续编辑和导出的 draw.io 图。",
"placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试", "placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试",
"recent": "最近的图纸", "recent": "最近的图纸",
"drawYourself": "从空白画布开始", "drawYourself": "从空白画布开始",
+138
View File
@@ -0,0 +1,138 @@
// What the model settings show about providers: the groups of the provider
// picker, and how far a provider's setup has come
import {
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"
export type ProviderGroupId = "vendors" | "aggregators" | "cloud" | "selfHosted"
/** The provider picker's groups, each provider in exactly one */
export const PROVIDER_GROUPS: {
id: ProviderGroupId
providers: ProviderName[]
}[] = [
{
id: "vendors",
providers: [
"openai",
"anthropic",
"google",
"deepseek",
"qwen",
"doubao",
"glm",
"kimi",
"minimax",
"mimo",
],
},
{
id: "aggregators",
providers: [
"openrouter",
"aihubmix",
"siliconflow",
"modelscope",
"novita",
"atlascloud",
"qiniu",
"gateway",
],
},
{ id: "cloud", providers: ["bedrock", "vertexai", "azure", "edgeone"] },
{ id: "selfHosted", providers: ["ollama", "sglang"] },
]
/** Shown when the user has no provider yet */
export const POPULAR_PROVIDERS: ProviderName[] = [
"openai",
"anthropic",
"google",
"deepseek",
"openrouter",
"qwen",
]
/** Providers that need something other than a plain API key */
export const PROVIDER_NOTE_KEYS: Partial<Record<ProviderName, string>> = {
bedrock: "noteBedrock",
vertexai: "noteVertex",
azure: "noteAzure",
edgeone: "noteEdgeone",
ollama: "noteOllama",
sglang: "noteSglang",
}
/**
* Providers whose base URL usually needs changing (Azure's default is only
* an example; Ollama's is its cloud, SGLang's a local server) or comes with
* a hint (MiniMax, MiMo): their folded options start open
*/
export const OPTIONS_OPEN: ProviderName[] = [
"azure",
"ollama",
"sglang",
"minimax",
"mimo",
]
/** The groups with only the providers whose name matches the search */
export function filterProviderGroups(query: string) {
const q = query.trim().toLowerCase()
if (!q) return PROVIDER_GROUPS
return PROVIDER_GROUPS.map((group) => ({
...group,
providers: group.providers.filter(
(p) =>
PROVIDER_INFO[p].label.toLowerCase().includes(q) ||
p.includes(q),
),
})).filter((group) => group.providers.length > 0)
}
/** The credentials a test or a chat request needs are filled in */
export function hasCredentials(p: ProviderConfig): boolean {
switch (p.provider) {
case "bedrock":
return (
(!!p.apiKey ||
(!!p.awsAccessKeyId && !!p.awsSecretAccessKey)) &&
!!p.awsRegion
)
case "vertexai":
return !!p.vertexApiKey
// Its default base URL names no real resource
case "azure":
return (
!!p.apiKey &&
!!p.baseUrl &&
p.baseUrl !== PROVIDER_INFO.azure.defaultBaseUrl
)
case "edgeone":
case "ollama":
return true
default:
return !!p.apiKey
}
}
export type ProviderStatus =
| { kind: "error"; count: number }
| { kind: "incomplete" }
| { kind: "untested"; count: number }
| { kind: "ok"; count: number }
/** The one thing about a provider that most needs the user's attention */
export function providerStatus(p: ProviderConfig): ProviderStatus {
const failed = p.models.filter((m) => m.validated === false).length
if (failed > 0) return { kind: "error", count: failed }
if (!hasCredentials(p) || p.models.length === 0) {
return { kind: "incomplete" }
}
const untested = p.models.filter((m) => m.validated !== true).length
if (untested > 0) return { kind: "untested", count: untested }
return { kind: "ok", count: p.models.length }
}
+3 -1
View File
@@ -60,7 +60,9 @@ export interface MultiModelConfig {
version: 1 version: 1
providers: ProviderConfig[] providers: ProviderConfig[]
selectedModelId?: string // Currently selected model's UUID selectedModelId?: string // Currently selected model's UUID
showUnvalidatedModels?: boolean // Show models that haven't been validated // Older versions saved here whether untested models were listed in the
// model picker; they always are now
showUnvalidatedModels?: boolean
} }
// Flattened model for dropdown display // Flattened model for dropdown display
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.16", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.16", "version": "0.5.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.191", "@ai-sdk/amazon-bedrock": "^4.0.191",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.16", "version": "0.5.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -216,6 +216,20 @@ export function applyDiagramOperations(
continue continue
} }
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML // Parse the new XML
const newDoc = parser.parseFromString( const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`, `<wrapper>${op.new_xml}</wrapper>`,
@@ -268,6 +282,20 @@ export function applyDiagramOperations(
continue continue
} }
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML // Parse the new XML
const newDoc = parser.parseFromString( const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`, `<wrapper>${op.new_xml}</wrapper>`,
@@ -149,3 +149,52 @@ describe("a wrapped mxCell with its wrapper's id", () => {
expect(result).toContain('label="B"') expect(result).toContain('label="B"')
}) })
}) })
describe("cut-off new_xml", () => {
// While edit_diagram streams, the last operation's XML is often
// incomplete. Chrome's DOMParser keeps the partial cell, so it must be
// refused before it reaches the diagram.
const CUT = `<mxCell id="b" value="B2" style="rounded=1;" vertex="1" parent="1">`
it("refuses an update whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{ operation: "update", cell_id: "b", new_xml: CUT },
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "update", cellId: "b" })
expect(errors[0].message).toContain("not well-formed")
expect(result).toContain('value="B"')
expect(result).not.toContain("B2")
})
it("refuses an add whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="1" y="2" width="3"`,
},
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "add", cellId: "n" })
expect(result).not.toContain('id="n"')
})
it("applies the complete operations before a cut-off one", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "update",
cell_id: "b",
new_xml: `<mxCell id="b" value="B3" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`,
},
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1">`,
},
])
expect(errors).toHaveLength(1)
expect(result).toContain('value="B3"')
expect(result).not.toContain('id="n"')
})
})
-12
View File
@@ -1,12 +0,0 @@
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.9477 25.7973C30.9997 25.7973 29.4796 25.2568 28.3986 24.1871C27.3176 23.1174 26.7771 21.6085 26.7771 19.6492V12.0148H23.8157V8.28764H24.131C24.9755 8.28764 25.6286 8.06243 26.0902 7.62328C26.5407 7.17287 26.7771 6.53104 26.7771 5.68652V4.34655H30.9772V8.28764H34.9521V12.0148H30.9772V19.4353C30.9772 20.0095 31.0785 20.4937 31.2812 20.8991C31.4839 21.3045 31.7992 21.6085 32.2383 21.8224C32.6775 22.0364 33.2292 22.1377 33.9049 22.1377C34.0512 22.1377 34.2314 22.1377 34.4341 22.104C34.6368 22.0814 34.8282 22.0589 35.0196 22.0364V25.6059C34.7269 25.6509 34.3778 25.696 34.0062 25.7297C33.6233 25.7748 33.2743 25.7973 32.959 25.7973H32.9477Z" fill="#FAF7F8"/>
<path d="M36.5734 25.6059V1.52026H40.7734V25.6059H36.5734Z" fill="#FAF7F8"/>
<path d="M48.284 25.9887C47.0792 25.9887 46.0207 25.7861 45.1199 25.3807C44.219 24.9753 43.5209 24.401 43.0367 23.6466C42.5525 22.8922 42.3048 22.0139 42.3048 21.023C42.3048 20.0321 42.5188 19.2101 42.9579 18.4556C43.3971 17.7012 44.0501 17.0706 44.951 16.5639C45.8405 16.0572 46.9665 15.6969 48.329 15.4829L53.9592 14.5596V17.7462L49.1173 18.602C48.2953 18.7484 47.6759 19.0074 47.2706 19.379C46.8652 19.7506 46.6625 20.246 46.6625 20.8541C46.6625 21.4621 46.8877 21.9238 47.3494 22.2729C47.7998 22.6219 48.3741 22.8021 49.0497 22.8021C49.9167 22.8021 50.6937 22.6219 51.358 22.2503C52.0224 21.8787 52.5404 21.3608 52.9007 20.7077C53.261 20.0546 53.4412 19.3339 53.4412 18.5795V14.0867C53.4412 13.3435 53.1597 12.7242 52.5854 12.2287C52.0111 11.7333 51.2454 11.4855 50.2996 11.4855C49.41 11.4855 48.6218 11.7333 47.9237 12.2175C47.2368 12.7017 46.7301 13.3322 46.4148 14.0979L43.0142 12.4427C43.352 11.5306 43.8925 10.7424 44.6244 10.0668C45.3563 9.39114 46.2234 8.87317 47.2143 8.49032C48.2164 8.10748 49.2974 7.91605 50.4572 7.91605C51.876 7.91605 53.1259 8.17504 54.2069 8.69301C55.2879 9.21098 56.1324 9.94289 56.7404 10.8775C57.3485 11.8121 57.6525 12.8818 57.6525 14.0867V25.6059H53.7114V22.6444L54.601 22.6107C54.1506 23.3313 53.6214 23.9506 52.9908 24.4573C52.3602 24.9641 51.6621 25.3469 50.8851 25.6059C50.1082 25.8649 49.2411 25.9887 48.2953 25.9887H48.284Z" fill="#FAF7F8"/>
<path d="M66.4804 25.9887C64.6337 25.9887 63.0235 25.5496 61.661 24.6713C60.2872 23.793 59.3414 22.5994 58.8121 21.0905L61.965 19.5929C62.4154 20.5726 63.0347 21.3383 63.8229 21.89C64.6224 22.4418 65.5007 22.712 66.4804 22.712C67.2235 22.712 67.8203 22.5431 68.2595 22.2053C68.7099 21.8675 68.9238 21.4171 68.9238 20.8653C68.9238 20.5275 68.8338 20.246 68.6536 20.0208C68.4734 19.7956 68.237 19.6042 67.9329 19.4465C67.6402 19.2889 67.2911 19.1538 66.9195 19.0524L64.0819 18.253C62.6406 17.8476 61.5371 17.2058 60.7827 16.3275C60.0282 15.4492 59.6566 14.4132 59.6566 13.2196C59.6566 12.1612 59.9269 11.2266 60.4674 10.4383C61.0079 9.65013 61.7623 9.01955 62.7307 8.58041C63.6991 8.13 64.8026 7.91605 66.0525 7.91605C67.6852 7.91605 69.1265 8.31016 70.3764 9.09838C71.6263 9.88659 72.5159 10.9901 73.0451 12.4089L69.8584 13.9065C69.5657 13.1183 69.059 12.499 68.3608 12.0486C67.6627 11.5982 66.8745 11.3617 66.0074 11.3617C65.3093 11.3617 64.7575 11.5193 64.3522 11.8234C63.9468 12.1274 63.7441 12.5553 63.7441 13.0845C63.7441 13.3773 63.8342 13.6475 64.0031 13.884C64.172 14.1204 64.4085 14.3119 64.7238 14.4583C65.0278 14.6046 65.3881 14.7398 65.7935 14.8749L68.5635 15.6969C69.9823 16.1248 71.0858 16.7553 71.8628 17.6111C72.6397 18.4556 73.0226 19.5028 73.0226 20.7302C73.0226 21.7661 72.7411 22.6895 72.2006 23.4777C71.6488 24.2772 70.8831 24.8965 69.9147 25.3356C68.9351 25.7861 67.7978 26 66.4804 26V25.9887Z" fill="#FAF7F8"/>
<path d="M90.2283 25.9889C88.528 25.9889 86.9628 25.6849 85.5215 25.0656C84.0802 24.4463 82.8303 23.5905 81.7718 22.487C80.7134 21.3835 79.8801 20.0886 79.2721 18.6022C78.664 17.1159 78.36 15.4944 78.36 13.7378C78.36 11.9812 78.6527 10.3484 79.2495 8.85083C79.8463 7.35322 80.6796 6.05829 81.7493 4.96605C82.819 3.8738 84.0689 3.02929 85.499 2.42123C86.929 1.81318 88.5055 1.50915 90.2283 1.50915C91.9511 1.50915 93.4487 1.79066 94.8 2.36493C96.1512 2.9392 97.2885 3.69364 98.2231 4.6395C99.1577 5.58536 99.822 6.6213 100.227 7.74733L96.3426 9.59401C95.8922 8.38916 95.149 7.39826 94.0793 6.6213C93.0208 5.84435 91.7372 5.4615 90.2283 5.4615C88.7194 5.4615 87.4357 5.81057 86.2985 6.5087C85.1612 7.20684 84.2829 8.17522 83.6523 9.40258C83.0217 10.63 82.7177 12.0713 82.7177 13.7265C82.7177 15.3818 83.033 16.8343 83.6523 18.073C84.2829 19.3116 85.1612 20.28 86.2985 20.9894C87.4357 21.6875 88.7419 22.0366 90.2283 22.0366C91.7146 22.0366 93.0208 21.6537 94.0793 20.8768C95.1378 20.0998 95.8922 19.1202 96.3426 17.9378L100.227 19.7507C99.822 20.8768 99.1577 21.9127 98.2231 22.8586C97.2885 23.8044 96.1512 24.5589 94.8 25.1331C93.4487 25.7074 91.9286 25.9889 90.2283 25.9889Z" fill="#FAF7F8"/>
<path d="M101.748 25.6059V1.52026H105.948V25.6059H101.748Z" fill="#FAF7F8"/>
<path d="M116.645 25.9884C114.967 25.9884 113.436 25.5943 112.051 24.806C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.49 18.6917 107.49 16.9464C107.49 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86369 112.04 9.08674C113.413 8.29852 114.956 7.90441 116.656 7.90441C118.357 7.90441 119.922 8.29852 121.307 9.08674C122.681 9.87495 123.784 10.9334 124.606 12.2846C125.417 13.6359 125.833 15.1898 125.833 16.9464C125.833 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.669 24.0291 121.284 24.806C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1711C117.602 22.1711 118.435 21.9459 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6488C121.25 18.8494 121.453 17.9598 121.453 16.9576C121.453 15.9555 121.25 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.435 11.9919 117.602 11.7779 116.645 11.7779C115.688 11.7779 114.888 12.0031 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.467 14.289C112.051 15.0772 111.848 15.9667 111.848 16.9576C111.848 17.9485 112.051 18.8494 112.467 19.6488C112.884 20.4483 113.447 21.0676 114.168 21.5068C114.888 21.9572 115.722 22.1711 116.645 22.1711Z" fill="#FAF7F8"/>
<path d="M133.535 25.9883C132.173 25.9883 131.013 25.6956 130.033 25.0988C129.054 24.502 128.299 23.68 127.77 22.6215C127.241 21.5631 126.97 20.3244 126.97 18.8944V8.2985H131.171V18.5453C131.171 19.266 131.317 19.8965 131.598 20.437C131.88 20.9775 132.297 21.4054 132.837 21.7094C133.378 22.0135 133.986 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7094C137.015 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.2209 138.174 18.4552V8.2985H142.341V25.6055H138.4V22.2049L138.715 22.8129C138.31 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.763 25.9996 133.535 25.9996V25.9883Z" fill="#FAF7F8"/>
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.9204 148.207 9.12093C149.525 8.31019 151.011 7.91608 152.666 7.91608C153.984 7.91608 155.155 8.17506 156.179 8.69304C157.204 9.21101 158.015 9.94292 158.612 10.8775L157.97 11.7333V1.52026H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0407 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1715C154.074 22.1715 154.907 21.9463 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9446 157.778 15.0663 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7783 153.139 11.7783C152.205 11.7783 151.371 12.0035 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1715 153.139 22.1715Z" fill="#FAF7F8"/>
<path d="M13.4447 0L0 25.9886C6.22692 23.5226 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06449C13.4447 9.06449 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1103 26.8895 25.9886L13.4447 0Z" fill="#FAF7F8"/>
</svg>

Before

Width:  |  Height:  |  Size: 8.1 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.9475 25.7973C30.9995 25.7973 29.4793 25.2568 28.3984 24.1871C27.3174 23.1174 26.7769 21.6085 26.7769 19.6492V12.0148H23.8154V8.28766H24.1307C24.9752 8.28766 25.6283 8.06245 26.09 7.6233C26.5404 7.17289 26.7769 6.53106 26.7769 5.68654V4.34657H30.9769V8.28766H34.9518V12.0148H30.9769V19.4353C30.9769 20.0096 31.0783 20.4938 31.281 20.8991C31.4837 21.3045 31.7989 21.6085 32.2381 21.8225C32.6772 22.0364 33.229 22.1378 33.9046 22.1378C34.051 22.1378 34.2312 22.1378 34.4338 22.104C34.6365 22.0814 34.828 22.0589 35.0194 22.0364V25.6059C34.7266 25.6509 34.3775 25.696 34.006 25.7298C33.6231 25.7748 33.274 25.7973 32.9588 25.7973H32.9475Z" fill="#0F1111"/>
<path d="M36.5732 25.6059V1.52028H40.7733V25.6059H36.5732Z" fill="#0F1111"/>
<path d="M48.2839 25.9888C47.079 25.9888 46.0206 25.7861 45.1197 25.3807C44.2189 24.9753 43.5208 24.4011 43.0366 23.6466C42.5524 22.8922 42.3047 22.0139 42.3047 21.023C42.3047 20.0321 42.5186 19.2101 42.9578 18.4557C43.3969 17.7012 44.05 17.0706 44.9508 16.5639C45.8404 16.0572 46.9664 15.6969 48.3289 15.483L53.959 14.5596V17.7463L49.1171 18.602C48.2951 18.7484 47.6758 19.0074 47.2705 19.379C46.8651 19.7506 46.6624 20.246 46.6624 20.8541C46.6624 21.4621 46.8876 21.9238 47.3493 22.2729C47.7997 22.6219 48.374 22.8021 49.0496 22.8021C49.9166 22.8021 50.6936 22.6219 51.3579 22.2504C52.0223 21.8788 52.5403 21.3608 52.9006 20.7077C53.2609 20.0546 53.4411 19.334 53.4411 18.5795V14.0867C53.4411 13.3435 53.1596 12.7242 52.5853 12.2287C52.011 11.7333 51.2453 11.4856 50.2995 11.4856C49.4099 11.4856 48.6217 11.7333 47.9236 12.2175C47.2367 12.7017 46.73 13.3322 46.4147 14.0979L43.0141 12.4427C43.3519 11.5306 43.8924 10.7424 44.6243 10.0668C45.3562 9.39116 46.2233 8.87319 47.2142 8.49034C48.2163 8.10749 49.2973 7.91607 50.4571 7.91607C51.8759 7.91607 53.1258 8.17505 54.2068 8.69303C55.2878 9.211 56.1323 9.94291 56.7403 10.8775C57.3484 11.8121 57.6524 12.8818 57.6524 14.0867V25.6059H53.7113V22.6445L54.6009 22.6107C54.1505 23.3313 53.6212 23.9507 52.9907 24.4574C52.3601 24.9641 51.662 25.3469 50.885 25.6059C50.108 25.8649 49.241 25.9888 48.2951 25.9888H48.2839Z" fill="#0F1111"/>
<path d="M66.4802 25.9888C64.6336 25.9888 63.0233 25.5496 61.6609 24.6713C60.2871 23.793 59.3412 22.5994 58.812 21.0906L61.9649 19.5929C62.4153 20.5726 63.0346 21.3383 63.8228 21.89C64.6223 22.4418 65.5006 22.712 66.4802 22.712C67.2234 22.712 67.8202 22.5431 68.2594 22.2053C68.7098 21.8675 68.9237 21.4171 68.9237 20.8653C68.9237 20.5275 68.8336 20.246 68.6535 20.0208C68.4733 19.7956 68.2368 19.6042 67.9328 19.4466C67.64 19.2889 67.291 19.1538 66.9194 19.0524L64.0818 18.253C62.6405 17.8476 61.537 17.2058 60.7826 16.3275C60.0281 15.4492 59.6565 14.4132 59.6565 13.2196C59.6565 12.1612 59.9268 11.2266 60.4673 10.4384C61.0078 9.65015 61.7622 9.01957 62.7306 8.58042C63.699 8.13001 64.8025 7.91607 66.0524 7.91607C67.6851 7.91607 69.1264 8.31018 70.3763 9.09839C71.6262 9.88661 72.5157 10.9901 73.045 12.4089L69.8583 13.9065C69.5656 13.1183 69.0588 12.499 68.3607 12.0486C67.6626 11.5982 66.8743 11.3617 66.0073 11.3617C65.3092 11.3617 64.7574 11.5193 64.3521 11.8234C63.9467 12.1274 63.744 12.5553 63.744 13.0845C63.744 13.3773 63.8341 13.6475 64.003 13.884C64.1719 14.1205 64.4084 14.3119 64.7236 14.4583C65.0277 14.6047 65.388 14.7398 65.7934 14.8749L68.5634 15.6969C69.9822 16.1248 71.0857 16.7554 71.8626 17.6111C72.6396 18.4557 73.0224 19.5029 73.0224 20.7302C73.0224 21.7662 72.7409 22.6895 72.2005 23.4777C71.6487 24.2772 70.883 24.8965 69.9146 25.3357C68.935 25.7861 67.7977 26 66.4802 26V25.9888Z" fill="#0F1111"/>
<path d="M90.2282 25.9889C88.5279 25.9889 86.9627 25.6849 85.5214 25.0656C84.0801 24.4463 82.8302 23.5905 81.7717 22.487C80.7133 21.3835 79.88 20.0886 79.2719 18.6022C78.6639 17.1159 78.3599 15.4944 78.3599 13.7378C78.3599 11.9812 78.6526 10.3485 79.2494 8.85085C79.8462 7.35324 80.6795 6.05831 81.7492 4.96606C82.8189 3.87382 84.0688 3.0293 85.4989 2.42125C86.9289 1.8132 88.5053 1.50917 90.2282 1.50917C91.951 1.50917 93.4486 1.79068 94.7998 2.36495C96.1511 2.93922 97.2884 3.69366 98.223 4.63952C99.1576 5.58538 99.8219 6.62132 100.227 7.74735L96.3425 9.59403C95.8921 8.38918 95.1489 7.39828 94.0792 6.62132C93.0207 5.84436 91.737 5.46152 90.2282 5.46152C88.7193 5.46152 87.4356 5.81058 86.2983 6.50872C85.1611 7.20685 84.2828 8.17523 83.6522 9.4026C83.0216 10.63 82.7176 12.0713 82.7176 13.7265C82.7176 15.3818 83.0329 16.8344 83.6522 18.073C84.2828 19.3116 85.1611 20.28 86.2983 20.9894C87.4356 21.6875 88.7418 22.0366 90.2282 22.0366C91.7145 22.0366 93.0207 21.6537 94.0792 20.8768C95.1376 20.0998 95.8921 19.1202 96.3425 17.9379L100.227 19.7508C99.8219 20.8768 99.1576 21.9127 98.223 22.8586C97.2884 23.8045 96.1511 24.5589 94.7998 25.1332C93.4486 25.7074 91.9285 25.9889 90.2282 25.9889Z" fill="#0F1111"/>
<path d="M101.748 25.6059V1.52028H105.948V25.6059H101.748Z" fill="#0F1111"/>
<path d="M116.645 25.9884C114.968 25.9884 113.436 25.5943 112.051 24.8061C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.491 18.6917 107.491 16.9464C107.491 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86371 112.04 9.08676C113.414 8.29854 114.956 7.90443 116.657 7.90443C118.357 7.90443 119.922 8.29854 121.307 9.08676C122.681 9.87497 123.784 10.9334 124.606 12.2847C125.417 13.6359 125.834 15.1898 125.834 16.9464C125.834 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.67 24.0291 121.285 24.8061C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1712C117.602 22.1712 118.436 21.946 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6489C121.251 18.8494 121.453 17.9598 121.453 16.9577C121.453 15.9555 121.251 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.436 11.9919 117.602 11.778 116.645 11.778C115.688 11.778 114.889 12.0032 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.468 14.289C112.051 15.0772 111.848 15.9668 111.848 16.9577C111.848 17.9486 112.051 18.8494 112.468 19.6489C112.884 20.4483 113.447 21.0677 114.168 21.5068C114.889 21.9572 115.722 22.1712 116.645 22.1712Z" fill="#0F1111"/>
<path d="M133.535 25.9884C132.172 25.9884 131.013 25.6956 130.033 25.0988C129.053 24.502 128.299 23.68 127.77 22.6215C127.24 21.5631 126.97 20.3245 126.97 18.8944V8.29852H131.17V18.5453C131.17 19.266 131.317 19.8966 131.598 20.4371C131.88 20.9775 132.296 21.4054 132.837 21.7095C133.377 22.0135 133.985 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7095C137.014 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.221 138.174 18.4553V8.29852H142.34V25.6055H138.399V22.2049L138.715 22.813C138.309 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.762 25.9996 133.535 25.9996V25.9884Z" fill="#0F1111"/>
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.92042 148.207 9.12094C149.525 8.31021 151.011 7.9161 152.666 7.9161C153.984 7.9161 155.155 8.17508 156.179 8.69305C157.204 9.21103 158.015 9.94294 158.612 10.8775L157.97 11.7333V1.52028H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0408 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1716C154.074 22.1716 154.907 21.9464 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9447 157.778 15.0664 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7784 153.139 11.7784C152.205 11.7784 151.371 12.0036 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1716 153.139 22.1716Z" fill="#0F1111"/>
<path d="M13.4447 1.71661e-05L0 25.9887C6.22692 23.5227 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06451C13.4447 9.06451 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1104 26.8895 25.9887L13.4447 1.71661e-05Z" fill="#0F1111"/>
</svg>

Before

Width:  |  Height:  |  Size: 8.1 KiB

+1 -4
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

-1
View File
@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

+12 -3
View File
@@ -1,6 +1,8 @@
import { create } from "zustand" import { create } from "zustand"
export type SettingsTab = "models" | "appearance" | "advanced" | "about" export type SettingsTab = "models" | "general" | "drawing" | "about"
/** The models tab: its provider list, the provider picker or a provider */
export type ModelsPage = "list" | "picker" | { providerId: string }
export type MobileView = "canvas" | "chat" export type MobileView = "canvas" | "chat"
interface UiState { interface UiState {
@@ -12,6 +14,7 @@ interface UiState {
mobileView: MobileView mobileView: MobileView
settingsOpen: boolean settingsOpen: boolean
settingsTab: SettingsTab settingsTab: SettingsTab
modelsPage: ModelsPage
/** Version shown in the compare dialog, or null when closed */ /** Version shown in the compare dialog, or null when closed */
compareVersionId: string | null compareVersionId: string | null
saveDialogOpen: boolean saveDialogOpen: boolean
@@ -21,9 +24,12 @@ interface UiState {
setHeroDismissed: (dismissed: boolean) => void setHeroDismissed: (dismissed: boolean) => void
togglePanel: () => void togglePanel: () => void
setMobileView: (view: MobileView) => void setMobileView: (view: MobileView) => void
openSettings: (tab?: SettingsTab) => void /** Opens on the given tab (else the last one); the models tab on its
* list unless a page is given */
openSettings: (tab?: SettingsTab, modelsPage?: ModelsPage) => void
setSettingsOpen: (open: boolean) => void setSettingsOpen: (open: boolean) => void
setSettingsTab: (tab: SettingsTab) => void setSettingsTab: (tab: SettingsTab) => void
setModelsPage: (page: ModelsPage) => void
openCompare: (versionId: string) => void openCompare: (versionId: string) => void
closeCompare: () => void closeCompare: () => void
setSaveDialogOpen: (open: boolean) => void setSaveDialogOpen: (open: boolean) => void
@@ -36,6 +42,7 @@ export const useUiStore = create<UiState>((set) => ({
mobileView: "chat", mobileView: "chat",
settingsOpen: false, settingsOpen: false,
settingsTab: "models", settingsTab: "models",
modelsPage: "list",
compareVersionId: null, compareVersionId: null,
saveDialogOpen: false, saveDialogOpen: false,
focusComposerToken: 0, focusComposerToken: 0,
@@ -43,13 +50,15 @@ export const useUiStore = create<UiState>((set) => ({
setHeroDismissed: (dismissed) => set({ heroDismissed: dismissed }), setHeroDismissed: (dismissed) => set({ heroDismissed: dismissed }),
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })), togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),
setMobileView: (view) => set({ mobileView: view }), setMobileView: (view) => set({ mobileView: view }),
openSettings: (tab) => openSettings: (tab, modelsPage = "list") =>
set((state) => ({ set((state) => ({
settingsOpen: true, settingsOpen: true,
settingsTab: tab ?? state.settingsTab, settingsTab: tab ?? state.settingsTab,
modelsPage,
})), })),
setSettingsOpen: (open) => set({ settingsOpen: open }), setSettingsOpen: (open) => set({ settingsOpen: open }),
setSettingsTab: (tab) => set({ settingsTab: tab }), setSettingsTab: (tab) => set({ settingsTab: tab }),
setModelsPage: (page) => set({ modelsPage: page }),
openCompare: (versionId) => set({ compareVersionId: versionId }), openCompare: (versionId) => set({ compareVersionId: versionId }),
closeCompare: () => set({ compareVersionId: null }), closeCompare: () => set({ compareVersionId: null }),
setSaveDialogOpen: (open) => set({ saveDialogOpen: open }), setSaveDialogOpen: (open) => set({ saveDialogOpen: open }),
+10 -4
View File
@@ -32,10 +32,13 @@ test("a rejected key shows a hint, the provider's words and a settings button",
// First match: the Next.js dev overlay at the end also lists the error // First match: the Next.js dev overlay at the end also lists the error
await expect(page.getByText("Authentication Fails").first()).toBeVisible() await expect(page.getByText("Authentication Fails").first()).toBeVisible()
await page.getByRole("button", { name: "Open model settings" }).click() await page.getByRole("button", { name: "Open model settings" }).click()
// Settings opens on its models tab // A server model answered: settings opens on the models tab's list
const dialog = page.getByRole("dialog", { name: "Settings" }) const dialog = page.getByRole("dialog", { name: "Settings" })
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible() await expect(
dialog.getByRole("heading", { name: "Models", exact: true }),
).toBeVisible()
await expect(dialog.getByText("Use your own API key")).toBeVisible()
}) })
test("an error before the stream shows its text, not raw JSON", async ({ test("an error before the stream shows its text, not raw JSON", async ({
@@ -99,8 +102,11 @@ test("a refused server key shows only the quota hint and a settings button", asy
0, 0,
) )
await page.getByRole("button", { name: "Open model settings" }).click() await page.getByRole("button", { name: "Open model settings" }).click()
// Settings opens on its models tab // A server model answered: settings opens on the models tab's list
const dialog = page.getByRole("dialog", { name: "Settings" }) const dialog = page.getByRole("dialog", { name: "Settings" })
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible() await expect(
dialog.getByRole("heading", { name: "Models", exact: true }),
).toBeVisible()
await expect(dialog.getByText("Use your own API key")).toBeVisible()
}) })
+1 -1
View File
@@ -15,7 +15,7 @@ import { createMockSSEResponse } from "./lib/helpers"
/** Pick a language in the settings, then close them */ /** Pick a language in the settings, then close them */
async function pickLanguage(page: Page, name: string) { async function pickLanguage(page: Page, name: string) {
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
await page.locator("#language-select").click() await page.locator("#language-select").click()
await page.getByRole("option", { name, exact: true }).click() await page.getByRole("option", { name, exact: true }).click()
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
+2 -2
View File
@@ -112,10 +112,10 @@ export async function openSettings(page: Page) {
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }) await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 })
} }
/** Open settings on one of its pages: models, appearance, advanced, about */ /** Open settings on one of its pages: models, general, drawing, about */
export async function openSettingsTab( export async function openSettingsTab(
page: Page, page: Page,
tab: "models" | "appearance" | "advanced" | "about", tab: "models" | "general" | "drawing" | "about",
) { ) {
await openSettings(page) await openSettings(page)
await page.locator(`[data-testid="settings-tab-${tab}"]`).click() await page.locator(`[data-testid="settings-tab-${tab}"]`).click()
+6 -4
View File
@@ -51,14 +51,14 @@ test("the Test button checks all models at once and shows each result", async ({
await page.getByTestId("model-selector").click() await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click() await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await dialog.getByText("GLM (Zhipu)").first().click() await dialog.getByTestId("provider-row-p1").click()
await dialog.getByRole("button", { name: "Test", exact: true }).click() await dialog.getByTestId("test-models").click()
await expect( await expect(
dialog.getByText("answered without calling a tool"), dialog.getByText("answered without calling a tool"),
).toBeVisible({ timeout: 15000 }) ).toBeVisible({ timeout: 15000 })
await expect(dialog.getByText("Model not found")).toBeVisible() await expect(dialog.getByText("Model not found")).toBeVisible()
await expect(dialog.locator('[title="1.2 s"]')).toBeVisible() await expect(dialog.getByText("Works · 1.2 s")).toBeVisible()
expect(started.sort()).toEqual([ expect(started.sort()).toEqual([
"model-broken", "model-broken",
"model-no-tools", "model-no-tools",
@@ -78,7 +78,7 @@ test("the key link and the base URL cleanup", async ({ page }) => {
await page.getByTestId("model-selector").click() await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click() await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await dialog.getByText("GLM (Zhipu)").first().click() await dialog.getByTestId("provider-row-p1").click()
await expect( await expect(
dialog.getByRole("link", { name: "Get API key" }), dialog.getByRole("link", { name: "Get API key" }),
@@ -86,6 +86,8 @@ test("the key link and the base URL cleanup", async ({ page }) => {
"href", "href",
"https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
) )
// The base URL is under "More options"
await dialog.getByRole("button", { name: /^More options/ }).click()
const baseUrl = dialog.locator("#base-url") const baseUrl = dialog.locator("#base-url")
await baseUrl.fill("https://proxy.example.com/v4/chat/completions/") await baseUrl.fill("https://proxy.example.com/v4/chat/completions/")
await baseUrl.blur() await baseUrl.blur()
+63 -64
View File
@@ -1,4 +1,4 @@
import { expect, type Page, test } from "@playwright/test" import { expect, type Locator, type Page, test } from "@playwright/test"
import { getIframe } from "./lib/fixtures" import { getIframe } from "./lib/fixtures"
// qwen-mt-plus is a translation model; models.dev lists no tool calls for it // qwen-mt-plus is a translation model; models.dev lists no tool calls for it
@@ -14,6 +14,13 @@ const CONFIG = {
], ],
} }
/** Show a provider's page, going back to the provider list first */
async function showProvider(dialog: Locator, id: string) {
const back = dialog.locator("header button", { hasText: /^(Models|模型)$/ })
if (await back.count()) await back.click()
await dialog.getByTestId(`provider-row-${id}`).click()
}
async function openQwenSettings(page: Page, config: object = CONFIG) { async function openQwenSettings(page: Page, config: object = CONFIG) {
await page.addInitScript((config) => { await page.addInitScript((config) => {
localStorage.setItem( localStorage.setItem(
@@ -26,10 +33,14 @@ async function openQwenSettings(page: Page, config: object = CONFIG) {
await page.getByTestId("model-selector").click() await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click() await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click() await showProvider(dialog, "p1")
return dialog return dialog
} }
const fetchButton = (dialog: Locator) =>
dialog.getByRole("button", { name: /^Get all models from/ })
const testButton = (dialog: Locator) => dialog.getByTestId("test-models")
test("fetches the provider's models and adds one from the picker", async ({ test("fetches the provider's models and adds one from the picker", async ({
page, page,
}) => { }) => {
@@ -51,9 +62,7 @@ test("fetches the provider's models and adds one from the picker", async ({
dialog.getByText("may not be able to draw").first(), dialog.getByText("may not be able to draw").first(),
).toBeVisible() ).toBeVisible()
await dialog await fetchButton(dialog).click()
.getByRole("button", { name: "Fetch models from the provider" })
.click()
const picker = page.locator('[role="listbox"]') const picker = page.locator('[role="listbox"]')
await expect(picker.getByText("qwen-new-max")).toBeVisible() await expect(picker.getByText("qwen-new-max")).toBeVisible()
await expect( await expect(
@@ -79,9 +88,7 @@ test("the model picker scrolls with the mouse wheel", async ({ page }) => {
}), }),
) )
const dialog = await openQwenSettings(page) const dialog = await openQwenSettings(page)
await dialog await fetchButton(dialog).click()
.getByRole("button", { name: "Fetch models from the provider" })
.click()
const list = page.locator("[cmdk-list]") const list = page.locator("[cmdk-list]")
await expect(list.getByText("qwen-model-0")).toBeVisible() await expect(list.getByText("qwen-model-0")).toBeVisible()
await list.hover() await list.hover()
@@ -99,12 +106,10 @@ test("shows a hint when the provider rejects the key", async ({ page }) => {
}), }),
) )
const dialog = await openQwenSettings(page) const dialog = await openQwenSettings(page)
await dialog await fetchButton(dialog).click()
.getByRole("button", { name: "Fetch models from the provider" })
.click()
await expect( await expect(
dialog.getByText( dialog.getByText(
"The provider rejected the API key. Check it in model settings. Incorrect API key", "The provider rejected this API key. Check that the key above was copied in full. Incorrect API key",
), ),
).toBeVisible() ).toBeVisible()
}) })
@@ -123,12 +128,10 @@ test("a fetch error stays with its provider", async ({ page }) => {
{ id: "p2", provider: "glm", apiKey: "k", models: [] }, { id: "p2", provider: "glm", apiKey: "k", models: [] },
], ],
}) })
await dialog await fetchButton(dialog).click()
.getByRole("button", { name: "Fetch models from the provider" })
.click()
const error = dialog.getByText("Incorrect API key") const error = dialog.getByText("Incorrect API key")
await expect(error).toBeVisible() await expect(error).toBeVisible()
await dialog.getByText("GLM (Zhipu)").first().click() await showProvider(dialog, "p2")
await expect(error).toHaveCount(0) await expect(error).toHaveCount(0)
}) })
@@ -190,10 +193,8 @@ test("a model list that arrives after switching provider stays with its provider
error: "Incorrect API key", error: "Incorrect API key",
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog await fetchButton(dialog).click()
.getByRole("button", { name: "Fetch models from the provider" }) await showProvider(dialog, "p2")
.click()
await dialog.getByText("GLM (Zhipu)").first().click()
release() release()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect(dialog.getByText("Incorrect API key")).toHaveCount(0) await expect(dialog.getByText("Incorrect API key")).toHaveCount(0)
@@ -207,13 +208,11 @@ test("a test result that arrives after switching provider stays with its provide
error: "Model not found", error: "Model not found",
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
await dialog.getByText("GLM (Zhipu)").first().click() await showProvider(dialog, "p2")
release() release()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect(dialog.getByText(/model\(s\) failed validation/)).toHaveCount( await expect(dialog.getByText(/failed the test/)).toHaveCount(0)
0,
)
}) })
test("a test result does not count for a model id changed meanwhile", async ({ test("a test result does not count for a model id changed meanwhile", async ({
@@ -224,13 +223,13 @@ test("a test result does not count for a model id changed meanwhile", async ({
responseTime: 1000, responseTime: 1000,
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
const input = dialog.locator('input[title="qwen-max"]') const input = dialog.locator('input[title="qwen-max"]')
await input.fill("qwen-plus") await input.fill("qwen-plus")
await input.blur() await input.blur()
release() release()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(0) await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(0)
}) })
test("a model list fetched with an old API key is dropped", async ({ test("a model list fetched with an old API key is dropped", async ({
@@ -240,9 +239,7 @@ test("a model list fetched with an old API key is dropped", async ({
models: [{ id: "model-of-old-key", tools: true }], models: [{ id: "model-of-old-key", tools: true }],
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog await fetchButton(dialog).click()
.getByRole("button", { name: "Fetch models from the provider" })
.click()
// The user corrects the key while the list is loading // The user corrects the key while the list is loading
await dialog.locator("#api-key").fill("new-key") await dialog.locator("#api-key").fill("new-key")
release() release()
@@ -256,7 +253,7 @@ test("a test result for an old API key is dropped", async ({ page }) => {
responseTime: 1000, responseTime: 1000,
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
// Another tab saves a different key for this provider // Another tab saves a different key for this provider
await page.evaluate(() => { await page.evaluate(() => {
const key = "next-ai-draw-io-model-configs" const key = "next-ai-draw-io-model-configs"
@@ -270,11 +267,9 @@ test("a test result for an old API key is dropped", async ({ page }) => {
}) })
release() release()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(0) await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(0)
// The test is over: the button works again and nothing spins // The test is over: the button works again and nothing spins
await expect( await expect(testButton(dialog)).toBeEnabled()
dialog.getByRole("button", { name: "Test", exact: true }),
).toBeEnabled()
await expect(dialog.locator(".animate-spin")).toHaveCount(0) await expect(dialog.locator(".animate-spin")).toHaveCount(0)
}) })
@@ -289,17 +284,17 @@ test("an older test does not end a newer one's spinners", async ({ page }) => {
}) })
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
await expect.poll(() => releases.length).toBe(1) await expect.poll(() => releases.length).toBe(1)
// The user corrects the key and tests again // The user corrects the key and tests again
await dialog.locator("#api-key").fill("new-key") await dialog.locator("#api-key").fill("new-key")
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
await expect.poll(() => releases.length).toBe(2) await expect.poll(() => releases.length).toBe(2)
releases[0]() releases[0]()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect(dialog.locator(".animate-spin").first()).toBeVisible() await expect(dialog.locator(".animate-spin").first()).toBeVisible()
releases[1]() releases[1]()
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(1) await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(1)
}) })
test("an older test touches nothing, also when the key came back", async ({ test("an older test touches nothing, also when the key came back", async ({
@@ -316,19 +311,19 @@ test("an older test touches nothing, also when the key came back", async ({
}) })
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
await expect.poll(() => releases.length).toBe(1) await expect.poll(() => releases.length).toBe(1)
// The key changes and comes back, and the user tests again // The key changes and comes back, and the user tests again
await dialog.locator("#api-key").fill("other-key") await dialog.locator("#api-key").fill("other-key")
await dialog.locator("#api-key").fill("test-key") await dialog.locator("#api-key").fill("test-key")
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
await expect.poll(() => releases.length).toBe(2) await expect.poll(() => releases.length).toBe(2)
releases[0]() releases[0]()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect(dialog.locator(".animate-spin").first()).toBeVisible() await expect(dialog.locator(".animate-spin").first()).toBeVisible()
await expect(dialog.locator('[title="9.0 s"]')).toHaveCount(0) await expect(dialog.getByText("Works · 9.0 s")).toHaveCount(0)
releases[1]() releases[1]()
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(1) await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(1)
}) })
test("no spinner stays after another tab's change while elsewhere", async ({ test("no spinner stays after another tab's change while elsewhere", async ({
@@ -339,9 +334,9 @@ test("no spinner stays after another tab's change while elsewhere", async ({
responseTime: 1000, responseTime: 1000,
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
// The user looks at the other provider while another tab changes the key // The user looks at the other provider while another tab changes the key
await dialog.getByText("GLM (Zhipu)").first().click() await showProvider(dialog, "p2")
await page.evaluate(() => { await page.evaluate(() => {
const key = "next-ai-draw-io-model-configs" const key = "next-ai-draw-io-model-configs"
const config = JSON.parse(localStorage.getItem(key) ?? "{}") const config = JSON.parse(localStorage.getItem(key) ?? "{}")
@@ -354,7 +349,7 @@ test("no spinner stays after another tab's change while elsewhere", async ({
}) })
release() release()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await dialog.getByText("Qwen (Alibaba)").first().click() await showProvider(dialog, "p1")
await expect(dialog.locator(".animate-spin")).toHaveCount(0) await expect(dialog.locator(".animate-spin")).toHaveCount(0)
}) })
@@ -373,7 +368,7 @@ test("a test result that arrives after the settings closed is kept", async ({
responseTime: 1000, responseTime: 1000,
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
release() release()
@@ -393,11 +388,11 @@ test("a test result for a key changed on another settings tab is dropped", async
responseTime: 1000, responseTime: 1000,
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
// The panel goes away while the test runs, then the key changes // The panel goes away while the test runs, then the key changes
await page.locator('[data-testid="settings-tab-advanced"]').click() await page.locator('[data-testid="settings-tab-general"]').click()
await page.locator('[data-testid="settings-tab-models"]').click() await page.locator('[data-testid="settings-tab-models"]').click()
await dialog.getByText("Qwen (Alibaba)").first().click() await showProvider(dialog, "p1")
await dialog.locator("input[type=password]").first().fill("new-key") await dialog.locator("input[type=password]").first().fill("new-key")
release() release()
await page.waitForTimeout(800) await page.waitForTimeout(800)
@@ -415,11 +410,11 @@ test("a test result for a model renamed on another settings tab is dropped", asy
responseTime: 1000, responseTime: 1000,
}) })
const dialog = await openQwenSettings(page, TWO_PROVIDERS) const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click() await testButton(dialog).click()
// The panel goes away while the test runs, then the model id changes // The panel goes away while the test runs, then the model id changes
await page.locator('[data-testid="settings-tab-advanced"]').click() await page.locator('[data-testid="settings-tab-general"]').click()
await page.locator('[data-testid="settings-tab-models"]').click() await page.locator('[data-testid="settings-tab-models"]').click()
await dialog.getByText("Qwen (Alibaba)").first().click() await showProvider(dialog, "p1")
const input = dialog.locator('input[title="qwen-max"]') const input = dialog.locator('input[title="qwen-max"]')
await input.fill("qwen-plus") await input.fill("qwen-plus")
await input.blur() await input.blur()
@@ -443,9 +438,14 @@ test("a failed test speaks the page's language", async ({ page }) => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.getByTestId("settings-button").first().click() await page.getByTestId("settings-button").first().click()
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click() await showProvider(dialog, "p1")
await dialog.getByRole("button", { name: "测试", exact: true }).click() await testButton(dialog).click()
await expect(dialog.getByText("1 个模型验证失败")).toBeVisible() // The visible line, not the screen reader's copy
await expect(
dialog
.getByText("1 个模型没通过测试")
.and(dialog.locator(':not([role="status"])')),
).toBeVisible()
await expect( await expect(
dialog.getByText("网络错误。请检查您的连接。").first(), dialog.getByText("网络错误。请检查您的连接。").first(),
).toBeAttached() ).toBeAttached()
@@ -471,11 +471,10 @@ test("testing a provider without models speaks the page's language", async ({
await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.getByTestId("settings-button").first().click() await page.getByTestId("settings-button").first().click()
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click() await showProvider(dialog, "p1")
await dialog.getByRole("button", { name: "测试", exact: true }).click() // Nothing to test yet: the button says what is missing
await expect( await expect(testButton(dialog)).toBeDisabled()
dialog.getByText("请先添加至少一个模型以进行验证"), await expect(dialog.getByText("先添加一个模型。")).toBeVisible()
).toBeVisible()
}) })
test("adding a model id twice is refused in the page's language", async ({ test("adding a model id twice is refused in the page's language", async ({
@@ -492,8 +491,8 @@ test("adding a model id twice is refused in the page's language", async ({
await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.getByTestId("settings-button").first().click() await page.getByTestId("settings-button").first().click()
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click() await showProvider(dialog, "p1")
const input = dialog.getByPlaceholder("自定义模型 ID...") const input = dialog.getByPlaceholder("或者输入其他模型 ID")
await input.fill("qwen-mt-plus") await input.fill("qwen-mt-plus")
await input.press("Enter") await input.press("Enter")
await expect(dialog.getByText("此模型 ID 已存在")).toBeVisible() await expect(dialog.getByText("此模型 ID 已存在")).toBeVisible()
@@ -534,8 +533,8 @@ test("Bedrock can be tested and used with an API key alone", async ({
await page.getByTestId("model-selector").click() await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click() await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Amazon Bedrock").first().click() await showProvider(dialog, "b1")
const test = dialog.getByRole("button", { name: "Test", exact: true }) const test = testButton(dialog)
await expect(test).toBeDisabled() await expect(test).toBeDisabled()
await dialog.getByLabel("Bedrock API key").fill("bedrock-api-key") await dialog.getByLabel("Bedrock API key").fill("bedrock-api-key")
await expect(test).toBeEnabled() await expect(test).toBeEnabled()
+504
View File
@@ -0,0 +1,504 @@
import type { Page } from "@playwright/test"
import {
expect,
getIframe,
openSettings,
openSettingsTab,
sendMessage,
test,
} from "./lib/fixtures"
const KEY = "next-ai-draw-io-model-configs"
async function openApp(page: Page, config?: object) {
if (config) {
await page.addInitScript(
([key, config]) => {
// Only on the first load: a reload keeps what the test did
if (!sessionStorage.getItem("seeded")) {
localStorage.setItem(key, JSON.stringify(config))
sessionStorage.setItem("seeded", "1")
}
},
[KEY, config] as const,
)
}
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
}
const saved = (page: Page) =>
page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? "{}"), KEY)
const dialogOf = (page: Page) => page.getByRole("dialog", { name: "Settings" })
// Three providers: one with a failed model, one not set up, one working
const PROVIDERS = {
version: 1,
selectedModelId: "m4",
providers: [
{
id: "p1",
provider: "openai",
apiKey: "sk-test",
models: [
{ id: "m1", modelId: "gpt-6-astra", validated: true },
{
id: "m2",
modelId: "gpt-6.1-sol",
validated: false,
validationError: "Model not found",
},
{ id: "m3", modelId: "gpt-6-luna" },
],
},
{ id: "p2", provider: "bedrock", apiKey: "", models: [] },
{
id: "p3",
provider: "deepseek",
apiKey: "sk-ds",
models: [{ id: "m4", modelId: "deepseek-chat", validated: true }],
},
],
}
test("a first provider: pick it, fill in the key, test, use it", async ({
page,
}) => {
const tested: string[] = []
await page.route("**/api/validate-model", async (route) => {
tested.push(route.request().postDataJSON().modelId)
await route.fulfill({ json: { valid: true, responseTime: 1400 } })
})
await openApp(page)
await openSettings(page)
const dialog = dialogOf(page)
await expect(dialog.getByText("Use your own API key")).toBeVisible()
await dialog.getByTestId("pick-provider-deepseek").click()
// The first suggested model is added, and the key field has the cursor
await expect(
dialog.getByRole("heading", { name: "DeepSeek", exact: true }),
).toBeVisible()
await expect(dialog.locator('input[title="deepseek-v4-pro"]')).toBeVisible()
await expect(dialog.locator("#api-key")).toBeFocused()
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
await expect(dialog.getByTestId("step-2-done")).toBeVisible()
const testButton = dialog.getByTestId("test-models")
await expect(testButton).toBeDisabled()
await expect(dialog.getByText("Fill in the API key first.")).toBeVisible()
await dialog.locator("#api-key").fill("sk-1234")
await expect(dialog.getByTestId("step-1-done")).toBeVisible()
await expect(testButton).toHaveText("Test deepseek-v4-pro")
await testButton.click()
// The visible panel, not the screen reader's copy
await expect(
dialog
.getByText("deepseek-v4-pro works")
.and(dialog.locator(':not([role="status"])')),
).toBeVisible()
await expect(dialog.getByTestId("step-3-done")).toBeVisible()
expect(tested).toEqual(["deepseek-v4-pro"])
// The next step: use it in the chat
await dialog
.getByRole("button", { name: "Use deepseek-v4-pro in the chat" })
.click()
await expect(dialog).toHaveCount(0)
await expect(page.getByTestId("model-selector")).toContainText(
"deepseek-v4-pro",
)
const config = await saved(page)
expect(config.selectedModelId).toBe(config.providers[0].models[0].id)
})
test("the provider picker searches, marks added ones, and Enter picks", async ({
page,
}) => {
await openApp(page, PROVIDERS)
await page.getByTestId("model-selector").click()
// The model picker opens the settings straight on the provider picker
await page.getByText("Add a provider…").click()
const dialog = dialogOf(page)
const search = dialog.getByRole("searchbox")
await expect(search).toBeFocused()
await expect(dialog.getByTestId("pick-provider-openai")).toContainText(
"Added",
)
await expect(dialog.getByTestId("pick-provider-bedrock")).toContainText(
"AWS credentials · Added",
)
await expect(
dialog.getByTestId("pick-provider-anthropic"),
).not.toContainText("Added")
await expect(dialog.locator('[data-testid^="pick-provider-"]')).toHaveCount(
24,
)
await search.fill("zzz")
await expect(dialog.getByText("No provider matches.")).toBeVisible()
await search.fill("kimi")
await expect(dialog.locator('[data-testid^="pick-provider-"]')).toHaveCount(
1,
)
await search.press("Enter")
await expect(
dialog.getByRole("heading", { name: "Kimi (Moonshot)", exact: true }),
).toBeVisible()
expect((await saved(page)).providers.at(-1).provider).toBe("kimi")
})
test("the provider list shows what needs attention", async ({ page }) => {
await openApp(page, PROVIDERS)
await openSettings(page)
const dialog = dialogOf(page)
await expect(dialog.getByTestId("provider-row-p1")).toContainText(
"1 failed",
)
await expect(dialog.getByTestId("provider-row-p2")).toContainText(
"Not set up yet",
)
await expect(dialog.getByTestId("provider-row-p3")).toContainText(
"1 working",
)
// The selected model's provider is the one in use
await expect(dialog.getByTestId("provider-row-p3")).toContainText("In use")
await expect(dialog.getByTestId("provider-row-p1")).not.toContainText(
"In use",
)
})
test("the model picker lists untested and failed models", async ({ page }) => {
await openApp(page, PROVIDERS)
await page.getByTestId("model-selector").click()
// By the model config's id: the server may offer a model of that name
const option = (id: string) =>
page.locator(`[role="option"][data-value="${id}"]`)
await expect(option("m3")).toContainText("Untested")
await expect(option("m2")).toContainText("Failed")
await expect(option("m1")).not.toContainText(/Untested|Failed/)
// An untested model can be picked
await option("m3").click()
await expect(page.getByTestId("model-selector")).toContainText("gpt-6-luna")
})
test("an old setting to hide untested models is ignored", async ({ page }) => {
await openApp(page, { ...PROVIDERS, showUnvalidatedModels: false })
await page.getByTestId("model-selector").click()
await expect(page.locator('[role="option"][data-value="m3"]')).toBeVisible()
})
test("a refused key opens the settings on that provider", async ({ page }) => {
await page.route("**/api/chat", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({
type: "provider",
code: "invalid_api_key",
message: "Incorrect API key",
}),
}),
)
await openApp(page, { ...PROVIDERS, selectedModelId: "m1" })
await sendMessage(page, "Draw a box")
await page
.getByRole("button", { name: "Open model settings" })
.click({ timeout: 15000 })
const dialog = dialogOf(page)
await expect(
dialog.getByRole("heading", { name: "OpenAI", exact: true }),
).toBeVisible()
// Back to the list from there
await dialog.locator("header button", { hasText: "Models" }).click()
await expect(dialog.getByTestId("provider-row-p1")).toBeVisible()
})
test("an access code error opens the general tab with the code field", async ({
page,
}) => {
await page.route("**/api/config", (route) =>
route.fulfill({
json: {
accessCodeRequired: true,
dailyRequestLimit: 0,
dailyTokenLimit: 0,
tpmLimit: 0,
},
}),
)
await page.route("**/api/chat", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({
error: "Invalid or missing access code. Please configure it in Settings.",
}),
}),
)
await openApp(page)
await sendMessage(page, "Draw a box")
const dialog = dialogOf(page)
await expect(dialog.locator("#access-code")).toBeVisible({ timeout: 15000 })
await expect(dialog.getByTestId("settings-tab-general")).toHaveAttribute(
"aria-current",
"page",
)
})
test("a refused key marks the key field until it changes", async ({ page }) => {
await page.route("**/api/validate-model", (route) =>
route.fulfill({
json: {
valid: false,
code: "invalid_api_key",
error: "Incorrect API key",
},
}),
)
await openApp(page, PROVIDERS)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p3").click()
await dialog.getByTestId("test-models").click()
const key = dialog.locator("#api-key")
await expect(key).toHaveAttribute("aria-invalid", "true")
await expect(
dialog.getByText(
"The provider rejected this API key. Check that the key above was copied in full. Incorrect API key",
),
).toBeVisible()
// The visible line, not the screen reader's copy
await expect(
dialog
.getByText("1 model failed the test")
.and(dialog.locator(':not([role="status"])')),
).toBeVisible()
await key.fill("sk-new")
await expect(key).not.toHaveAttribute("aria-invalid", "true")
})
test("Bedrock shows one way of signing in and empties the other", async ({
page,
}) => {
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "bedrock-key",
awsRegion: "us-east-1",
// Left by an older version: belongs to the access keys
awsSessionToken: "session-token",
models: [
{
id: "m1",
modelId: "amazon.nova-lite-v1:0",
validated: true,
},
],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
await expect(dialog.getByLabel("Bedrock API key")).toHaveValue(
"bedrock-key",
)
await expect(dialog.locator("#aws-access-key-id")).toHaveCount(0)
await dialog
.getByRole("button", { name: "Access keys", exact: true })
.click()
await expect(dialog.locator("#bedrock-api-key")).toHaveCount(0)
// The key that was tested is gone, and so is its test result
await expect
.poll(async () => (await saved(page)).providers[0].models[0].validated)
.toBeUndefined()
await dialog.locator("#aws-access-key-id").fill("AKIATEST")
await dialog.locator("#aws-secret-access-key").fill("secret")
await expect
.poll(async () => (await saved(page)).providers[0])
.toMatchObject({
apiKey: "",
awsAccessKeyId: "AKIATEST",
awsSecretAccessKey: "secret",
})
// Reopened, it shows the way that is filled in
await page.keyboard.press("Escape")
await openSettings(page)
await dialog.getByTestId("provider-row-b1").click()
await expect(
dialog.getByRole("button", { name: "Access keys", exact: true }),
).toHaveAttribute("aria-pressed", "true")
await dialog
.getByRole("button", { name: "Bedrock API key", exact: true })
.click()
await expect
.poll(async () => (await saved(page)).providers[0])
.toMatchObject({
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsSessionToken: "",
})
})
test("switching Bedrock's sign-in with nothing filled in keeps the test results", async ({
page,
}) => {
// Older configs may hold a result without the key that earned it
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "",
awsRegion: "us-east-1",
models: [
{
id: "m1",
modelId: "amazon.nova-lite-v1:0",
validated: true,
},
],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
await dialog
.getByRole("button", { name: "Access keys", exact: true })
.click()
await expect(dialog.locator("#aws-access-key-id")).toBeVisible()
await page.waitForTimeout(300)
expect((await saved(page)).providers[0].models[0].validated).toBe(true)
})
test("Use in the chat waits while an answer runs", async ({ page }) => {
// The chat request stays open until released
let release!: () => void
const released = new Promise<void>((r) => {
release = r
})
await page.route("**/api/chat", async (route) => {
await released
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
})
})
await page.route("**/api/validate-model", (route) =>
route.fulfill({ json: { valid: true, responseTime: 900 } }),
)
await openApp(page, PROVIDERS)
await sendMessage(page, "Draw a box")
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p3").click()
await dialog.getByTestId("test-models").click()
const use = dialog.getByRole("button", {
name: "Use deepseek-chat in the chat",
})
await expect(use).toBeDisabled()
release()
await expect(use).toBeEnabled({ timeout: 15000 })
})
test("more options open by themselves only when they matter", async ({
page,
}) => {
await openApp(page, {
version: 1,
providers: [
{ id: "a1", provider: "azure", apiKey: "k", models: [] },
{ id: "o1", provider: "openai", apiKey: "k", models: [] },
{
id: "o2",
provider: "openai",
apiKey: "k",
baseUrl: "https://proxy.example.com/v1",
models: [],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
const back = () =>
dialog.locator("header button", { hasText: "Models" }).click()
// Azure's default URL is only an example
await dialog.getByTestId("provider-row-a1").click()
await expect(dialog.locator("#base-url")).toBeVisible()
await back()
await dialog.getByTestId("provider-row-o1").click()
await expect(dialog.locator("#base-url")).toHaveCount(0)
await back()
await dialog.getByTestId("provider-row-o2").click()
await expect(dialog.locator("#base-url")).toHaveValue(
"https://proxy.example.com/v1",
)
})
test("the models tab keeps its page across tabs, not across openings", async ({
page,
}) => {
await openApp(page, PROVIDERS)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
await dialog.getByTestId("settings-tab-drawing").click()
await expect(dialog.locator("#max-output-tokens")).toBeVisible()
await dialog.getByTestId("settings-tab-models").click()
await expect(
dialog.getByRole("heading", { name: "OpenAI", exact: true }),
).toBeVisible()
await page.keyboard.press("Escape")
await openSettings(page)
await expect(dialog.getByTestId("provider-row-p1")).toBeVisible()
})
test("deleting a provider says what goes with it", async ({ page }) => {
await openApp(page, PROVIDERS)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p3").click()
await dialog
.getByRole("button", { name: "Delete DeepSeek and its model" })
.click()
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Delete" })
.click()
await expect(dialog.getByTestId("provider-row-p3")).toHaveCount(0)
await expect(dialog.getByTestId("provider-row-p1")).toBeVisible()
expect((await saved(page)).providers).toHaveLength(2)
})
test("the general and drawing tabs hold their settings", async ({ page }) => {
await openApp(page)
await openSettingsTab(page, "general")
const dialog = dialogOf(page)
for (const id of ["#language-select", "#send-shortcut-select"]) {
await expect(dialog.locator(id)).toBeVisible()
}
await expect(
dialog.getByRole("radiogroup", { name: "Theme" }),
).toBeVisible()
await dialog.getByTestId("settings-tab-drawing").click()
for (const id of [
"#custom-system-message",
"#vlm-validation",
"#max-output-tokens",
]) {
await expect(dialog.locator(id)).toBeVisible()
}
await expect(
dialog.getByRole("radiogroup", { name: "Diagram style" }),
).toBeVisible()
})
+830
View File
@@ -0,0 +1,830 @@
// What the settings review found: each test fails without its fix
import type { Locator, Page } from "@playwright/test"
import {
expect,
getIframe,
openSettings,
sendMessage,
test,
} from "./lib/fixtures"
const KEY = "next-ai-draw-io-model-configs"
async function openApp(page: Page, config?: object) {
if (config) {
await page.addInitScript(
([key, config]) => {
if (!sessionStorage.getItem("seeded")) {
localStorage.setItem(key, JSON.stringify(config))
sessionStorage.setItem("seeded", "1")
}
},
[KEY, config] as const,
)
}
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
}
const dialogOf = (page: Page) => page.getByRole("dialog", { name: "Settings" })
const saved = (page: Page) =>
page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? "{}"), KEY)
/** What another browser tab saving the config looks like to this one */
async function changeInAnotherTab(page: Page, change: (config: any) => void) {
await page.evaluate(
([key, body]) => {
const config = JSON.parse(localStorage.getItem(key) ?? "{}")
new Function("config", body)(config)
const value = JSON.stringify(config)
localStorage.setItem(key, value)
window.dispatchEvent(
new StorageEvent("storage", { key, newValue: value }),
)
},
[KEY, `(${change.toString()})(config)`] as const,
)
}
const back = (dialog: Locator) =>
dialog.locator("header button", { hasText: /^Models$/ }).click()
/** Hold the requests to an endpoint; each waits for its own release */
async function holdEach(
page: Page,
url: string,
answer: (n: number) => object,
) {
const releases: Array<() => void> = []
await page.route(url, async (route) => {
const n = releases.length
await new Promise<void>((r) => releases.push(r))
await route.fulfill({ json: answer(n) })
})
return releases
}
const TWO = {
version: 1,
providers: [
{
id: "p1",
provider: "glm",
apiKey: "k1",
models: [{ id: "a1", modelId: "glm-a" }],
},
{
id: "p2",
provider: "kimi",
apiKey: "k2",
models: [{ id: "b1", modelId: "kimi-b" }],
},
],
}
test("Add a provider… leaves the cursor in the provider search", async ({
page,
}) => {
await openApp(page)
await page.getByTestId("model-selector").click()
await page.getByText("Add a provider…").click()
const search = dialogOf(page).getByRole("searchbox")
await expect(search).toBeFocused()
// After the model picker has closed
await page.waitForTimeout(800)
await expect(search).toBeFocused()
await page.keyboard.type("kimi")
await expect(search).toHaveValue("kimi")
})
test("the model picker's footer is there while searching and reachable by Tab", async ({
page,
}) => {
await openApp(page)
await page.getByTestId("model-selector").click()
await page.keyboard.type("zzzz")
await expect(page.getByText("Add a provider…")).toBeVisible()
const add = page.getByRole("button", { name: "Add a provider…" })
for (let i = 0; i < 6; i++) {
if (await add.evaluate((el) => el === document.activeElement)) break
await page.keyboard.press("Tab")
}
await expect(add).toBeFocused()
await page.keyboard.press("Enter")
await expect(dialogOf(page).getByRole("searchbox")).toBeVisible()
})
test("an Enter that confirms an IME candidate picks no provider", async ({
page,
}) => {
await openApp(page)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByRole("button", { name: /See all/ }).click()
const search = dialog.getByRole("searchbox")
await search.fill("deep")
await search.evaluate((el) =>
el.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
isComposing: true,
}),
),
)
await page.waitForTimeout(300)
await expect(search).toBeVisible()
expect((await saved(page)).providers ?? []).toHaveLength(0)
})
test("the focus follows the page within the models tab", async ({ page }) => {
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").focus()
await page.keyboard.press("Enter")
await expect(
dialog.getByRole("heading", { name: "GLM (Zhipu)", exact: true }),
).toBeFocused()
await back(dialog)
await expect(
dialog.getByRole("heading", { name: "Models", exact: true }),
).toBeFocused()
})
test("long names stay inside the page", async ({ page }) => {
const long = `model-${"x".repeat(150)}`
await openApp(page, {
version: 1,
providers: [
{
id: "p1",
provider: "openai",
name: `My ${"provider ".repeat(10)}name`,
apiKey: "k",
models: [{ id: "m1", modelId: long }],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
const right = async (l: Locator) => {
const box = await l.boundingBox()
return box ? box.x + box.width : 0
}
const pageRight = await right(dialog)
expect(await right(dialog.getByTestId("test-models"))).toBeLessThanOrEqual(
pageRight,
)
expect(
await right(
dialog.getByRole("button", { name: /^Get all models from/ }),
),
).toBeLessThanOrEqual(pageRight)
})
test("Azure needs its resource URL before it counts as connected", async ({
page,
}) => {
await openApp(page, {
version: 1,
providers: [
{
id: "z1",
provider: "azure",
apiKey: "k",
baseUrl: "https://your-resource.openai.azure.com/openai",
models: [{ id: "m1", modelId: "gpt-6-astra" }],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await expect(dialog.getByTestId("provider-row-z1")).toContainText(
"Not set up yet",
)
await dialog.getByTestId("provider-row-z1").click()
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
await expect(dialog.getByTestId("test-models")).toBeDisabled()
await expect(
dialog.getByText(
"Fill in the API key and your Azure resource's base URL first.",
),
).toBeVisible()
await dialog
.locator("#base-url")
.fill("https://contoso.openai.azure.com/openai")
await expect(dialog.getByTestId("test-models")).toBeEnabled()
})
test("a refused key unchecks step 1, and a fix in another tab clears it", async ({
page,
}) => {
await page.route("**/api/validate-model", (route) =>
route.fulfill({
json: { valid: false, code: "invalid_api_key", error: "Bad key" },
}),
)
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
await dialog.getByTestId("test-models").click()
const key = dialog.locator("#api-key")
await expect(key).toHaveAttribute("aria-invalid", "true")
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
await changeInAnotherTab(page, (config) => {
config.providers[0].apiKey = "fixed-key"
config.providers[0].models[0].validated = undefined
config.providers[0].models[0].validationError = undefined
})
await expect(key).toHaveValue("fixed-key")
await expect(key).not.toHaveAttribute("aria-invalid", "true")
await expect(dialog.getByTestId("step-1-done")).toBeVisible()
await expect(dialog.getByText("1 model failed the test")).toHaveCount(0)
})
test("testing another provider keeps the first one's results", async ({
page,
}) => {
const releases = await holdEach(page, "**/api/validate-model", () => ({
valid: true,
responseTime: 1000,
}))
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
await dialog.getByTestId("test-models").click()
await expect.poll(() => releases.length).toBe(1)
await back(dialog)
await dialog.getByTestId("provider-row-p2").click()
await dialog.getByTestId("test-models").click()
await expect.poll(() => releases.length).toBe(2)
for (const release of releases) release()
await expect
.poll(async () =>
(await saved(page)).providers.map(
(p: any) => p.models[0].validated,
),
)
.toEqual([true, true])
})
test("an older test from before the tabs changed does not undo a newer one", async ({
page,
}) => {
// The first test fails, the second passes; the first answers last
const releases = await holdEach(page, "**/api/validate-model", (n) =>
n === 0
? { valid: false, error: "Old failure" }
: { valid: true, responseTime: 1000 },
)
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
await dialog.getByTestId("test-models").click()
await expect.poll(() => releases.length).toBe(1)
await dialog.getByTestId("settings-tab-general").click()
await dialog.getByTestId("settings-tab-models").click()
await dialog.getByTestId("test-models").click()
await expect.poll(() => releases.length).toBe(2)
releases[1]()
await expect(dialog.getByText("Works · 1.0 s")).toBeVisible()
releases[0]()
await page.waitForTimeout(500)
await expect(dialog.getByText("Works · 1.0 s")).toBeVisible()
expect((await saved(page)).providers[0].models[0].validated).toBe(true)
})
test("a model list fetched with a key another tab replaced is not offered", async ({
page,
}) => {
await page.route("**/api/provider-models", (route) =>
route.fulfill({ json: { models: [{ id: "model-of-old-key" }] } }),
)
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
await dialog.getByRole("button", { name: /^Get all models from/ }).click()
await expect(
page.getByRole("option", { name: "model-of-old-key" }),
).toBeVisible()
await page.keyboard.press("Escape")
await changeInAnotherTab(page, (config) => {
config.providers[0].apiKey = "another-key"
})
await expect(dialog.locator("#api-key")).toHaveValue("another-key")
// GLM has no suggested models: nothing to browse until fetched again
await expect(
dialog.getByRole("button", { name: "Browse models" }),
).toHaveCount(0)
})
test("a model list request keeps only its own provider busy", async ({
page,
}) => {
const releases = await holdEach(page, "**/api/provider-models", () => ({
models: [],
}))
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
const fetchButton = dialog.getByRole("button", {
name: /^Get all models from/,
})
await dialog.getByTestId("provider-row-p1").click()
await fetchButton.click()
await expect.poll(() => releases.length).toBe(1)
await expect(fetchButton).toBeDisabled()
await back(dialog)
await dialog.getByTestId("provider-row-p2").click()
await expect(fetchButton).toBeEnabled()
for (const release of releases) release()
})
test("a model id typed for one provider does not follow to the next", async ({
page,
}) => {
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
const input = dialog.getByPlaceholder("Or type another model ID")
await input.fill("half-typed")
await back(dialog)
await dialog.getByTestId("provider-row-p2").click()
await expect(input).toHaveValue("")
})
test("Bedrock shows the sign-in that is saved, also after another tab", async ({
page,
}) => {
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "",
awsAccessKeyId: "AKIAOLD",
awsSecretAccessKey: "old-secret",
awsRegion: "us-east-1",
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
await expect(dialog.locator("#aws-access-key-id")).toHaveValue("AKIAOLD")
// Another tab signs the provider in with an API key instead
await changeInAnotherTab(page, (config) => {
config.providers[0].apiKey = "bedrock-key"
config.providers[0].awsAccessKeyId = ""
config.providers[0].awsSecretAccessKey = ""
})
await expect(dialog.locator("#bedrock-api-key")).toHaveValue("bedrock-key")
await expect(dialog.locator("#aws-access-key-id")).toHaveCount(0)
})
test("a refused key opens the provider the request went to", async ({
page,
}) => {
// The request waits while another tab selects another provider's model
let release!: () => void
const released = new Promise<void>((r) => {
release = r
})
const sentWith: string[] = []
await page.route("**/api/chat", async (route) => {
sentWith.push(route.request().headers()["x-ai-provider"] ?? "")
await released
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({
type: "provider",
code: "invalid_api_key",
message: "Incorrect API key",
}),
})
})
await openApp(page, { ...TWO, selectedModelId: "a1" })
await sendMessage(page, "Draw a box")
// Sent to GLM; only then does the other tab pick Kimi's model
await expect.poll(() => sentWith).toEqual(["glm"])
await changeInAnotherTab(page, (config) => {
config.selectedModelId = "b1"
})
await expect(page.getByTestId("model-selector")).toContainText("kimi-b")
release()
await page
.getByRole("button", { name: "Open model settings" })
.click({ timeout: 15000 })
await expect(
dialogOf(page).getByRole("heading", {
name: "GLM (Zhipu)",
exact: true,
}),
).toBeVisible()
})
test("a local server's base URL is shown from the start", async ({ page }) => {
await openApp(page, {
version: 1,
providers: [{ id: "l1", provider: "ollama", apiKey: "", models: [] }],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-l1").click()
await expect(dialog.locator("#base-url")).toBeVisible()
})
test("refused access keys are named as such", async ({ page }) => {
await page.route("**/api/validate-model", (route) =>
route.fulfill({
json: { valid: false, code: "invalid_api_key", error: "Bad keys" },
}),
)
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "",
awsAccessKeyId: "AKIA1",
awsSecretAccessKey: "s",
awsRegion: "us-east-1",
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
await dialog.getByTestId("test-models").click()
await expect(
dialog.getByText(
"AWS rejected these access keys. Check that both were copied in full. Bad keys",
),
).toBeVisible()
for (const id of ["#aws-access-key-id", "#aws-secret-access-key"]) {
await expect(dialog.locator(id)).toHaveAttribute("aria-invalid", "true")
}
})
// Found by the review of the fixes above
test("a slow model list keeps its provider busy while another loads", async ({
page,
}) => {
const releases = await holdEach(page, "**/api/provider-models", () => ({
models: [{ id: "listed-model" }],
}))
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
const fetchButton = dialog.getByRole("button", {
name: /^Get all models from/,
})
await dialog.getByTestId("provider-row-p1").click()
await fetchButton.click()
await expect.poll(() => releases.length).toBe(1)
await back(dialog)
await dialog.getByTestId("provider-row-p2").click()
await fetchButton.click()
await expect.poll(() => releases.length).toBe(2)
releases[1]()
// B's list opens its picker; close it
await expect(
page.getByRole("option", { name: "listed-model" }),
).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("listbox")).toHaveCount(0)
await back(dialog)
await dialog.getByTestId("provider-row-p1").click()
// The first request is still out
await expect(fetchButton).toBeDisabled()
releases[0]()
// A's list arrives and opens its picker; afterwards A can fetch again
await expect(
page.getByRole("option", { name: "listed-model" }),
).toBeVisible()
await page.keyboard.press("Escape")
await expect(fetchButton).toBeEnabled()
})
test("refused keys stay marked for each provider", async ({ page }) => {
await page.route("**/api/validate-model", (route) =>
route.fulfill({
json: { valid: false, code: "invalid_api_key", error: "Bad key" },
}),
)
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
const key = dialog.locator("#api-key")
await dialog.getByTestId("provider-row-p1").click()
await dialog.getByTestId("test-models").click()
await expect(key).toHaveAttribute("aria-invalid", "true")
await back(dialog)
await dialog.getByTestId("provider-row-p2").click()
await dialog.getByTestId("test-models").click()
await expect(key).toHaveAttribute("aria-invalid", "true")
await back(dialog)
await dialog.getByTestId("provider-row-p1").click()
await expect(key).toHaveAttribute("aria-invalid", "true")
})
test("a model list that loads leaves a refused test's mark", async ({
page,
}) => {
// Some providers list their models without the key: a list proves nothing
await page.route("**/api/validate-model", (route) =>
route.fulfill({
json: { valid: false, code: "invalid_api_key", error: "Bad key" },
}),
)
await page.route("**/api/provider-models", (route) =>
route.fulfill({ json: { models: [{ id: "listed-model" }] } }),
)
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
const key = dialog.locator("#api-key")
await dialog.getByTestId("provider-row-p1").click()
await dialog.getByTestId("test-models").click()
await expect(key).toHaveAttribute("aria-invalid", "true")
await dialog.getByRole("button", { name: /^Get all models from/ }).click()
await expect(
page.getByRole("option", { name: "listed-model" }),
).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("listbox")).toHaveCount(0)
await expect(key).toHaveAttribute("aria-invalid", "true")
})
test("an empty model list says so and opens nothing", async ({ page }) => {
await page.route("**/api/provider-models", (route) =>
route.fulfill({ json: { models: [] } }),
)
// OpenAI has suggested models: they come back once the key changes
await openApp(page, {
version: 1,
providers: [{ id: "o1", provider: "openai", apiKey: "k", models: [] }],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-o1").click()
await dialog.getByRole("button", { name: /^Get all models from/ }).click()
await expect(
dialog.getByText("The provider returned no models."),
).toBeVisible()
await dialog.locator("#api-key").fill("k-new")
await expect(
dialog.getByRole("button", { name: "Browse models" }),
).toBeVisible()
await page.waitForTimeout(300)
// The picker did not open by itself
await expect(page.getByRole("listbox")).toHaveCount(0)
await expect(dialog.locator("#api-key")).toBeFocused()
})
test("emptying Bedrock's access keys to type new ones keeps their fields", async ({
page,
}) => {
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "",
awsAccessKeyId: "AKIAOLD",
awsSecretAccessKey: "old-secret",
awsRegion: "us-east-1",
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
await dialog.locator("#aws-secret-access-key").fill("")
await dialog.locator("#aws-access-key-id").fill("")
await expect(dialog.locator("#bedrock-api-key")).toHaveCount(0)
await dialog.locator("#aws-access-key-id").fill("AKIANEW")
await expect
.poll(async () => (await saved(page)).providers[0])
.toMatchObject({ apiKey: "", awsAccessKeyId: "AKIANEW" })
})
test("a provider added in another tab while sending is the one its error opens", async ({
page,
}) => {
const sentWith: string[] = []
await page.route("**/api/chat", async (route) => {
sentWith.push(route.request().headers()["x-ai-provider"] ?? "")
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({
type: "provider",
code: "invalid_api_key",
message: "Incorrect API key",
}),
})
})
await openApp(page, { ...TWO, selectedModelId: "a1" })
await page.getByTestId("chat-input").fill("Draw a box")
// Send, and right away another tab adds DeepSeek and picks its model,
// while this tab still prepares the request (it exports the diagram)
await page.evaluate((key) => {
const send = document.querySelector<HTMLButtonElement>(
'button[aria-label="Send"]',
)
send?.click()
const config = JSON.parse(localStorage.getItem(key) ?? "{}")
config.providers.push({
id: "p3",
provider: "deepseek",
apiKey: "k3",
models: [{ id: "c1", modelId: "deepseek-chat" }],
})
config.selectedModelId = "c1"
const value = JSON.stringify(config)
localStorage.setItem(key, value)
window.dispatchEvent(
new StorageEvent("storage", { key, newValue: value }),
)
}, KEY)
await expect.poll(() => sentWith).toEqual(["deepseek"])
await page
.getByRole("button", { name: "Open model settings" })
.click({ timeout: 15000 })
await expect(
dialogOf(page).getByRole("heading", { name: "DeepSeek", exact: true }),
).toBeVisible()
})
// After #957 merged
test("temporary Bedrock access keys can carry their session token", async ({
page,
}) => {
const bodies: any[] = []
await page.route("**/api/validate-model", async (route) => {
bodies.push(route.request().postDataJSON())
await route.fulfill({ json: { valid: true, responseTime: 900 } })
})
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "",
awsAccessKeyId: "ASIATEMP",
awsSecretAccessKey: "secret",
awsRegion: "us-east-1",
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
await dialog.getByLabel("AWS session token (optional)").fill("session-1")
await dialog.getByTestId("test-models").click()
await expect.poll(() => bodies.length).toBe(1)
expect(bodies[0]).toMatchObject({
awsAccessKeyId: "ASIATEMP",
awsSessionToken: "session-1",
})
expect((await saved(page)).providers[0].awsSessionToken).toBe("session-1")
})
test("Bedrock with both kinds of credentials offers to remove the access keys", async ({
page,
}) => {
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "bedrock-key",
awsAccessKeyId: "AKIAOLD",
awsSecretAccessKey: "old-secret",
awsSessionToken: "old-token",
awsRegion: "us-east-1",
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
const notice = dialog.getByText(/also keeps an access key pair/)
await expect(notice).toBeVisible()
await dialog.getByRole("button", { name: "Remove the access keys" }).click()
await expect(notice).toHaveCount(0)
await expect
.poll(async () => (await saved(page)).providers[0])
.toMatchObject({
apiKey: "bedrock-key",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsSessionToken: "",
})
await expect(dialog.locator("#bedrock-api-key")).toHaveValue("bedrock-key")
})
test("the test result goes to a live region that is there from the start", async ({
page,
}) => {
await page.route("**/api/validate-model", (route) =>
route.fulfill({ json: { valid: true, responseTime: 900 } }),
)
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
const result = dialog.getByTestId("test-result")
await expect(result).toHaveAttribute("role", "status")
await expect(result).toHaveText("")
await dialog.getByTestId("test-models").click()
await expect(result).toHaveText("glm-a works")
})
test("removing access keys kept behind an API key keeps the test results", async ({
page,
}) => {
await openApp(page, {
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
apiKey: "bedrock-key",
awsAccessKeyId: "AKIAOLD",
awsSecretAccessKey: "old-secret",
awsRegion: "us-east-1",
models: [
{
id: "m1",
modelId: "amazon.nova-lite-v1:0",
validated: true,
},
],
},
],
})
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-b1").click()
await dialog.getByRole("button", { name: "Remove the access keys" }).focus()
await page.keyboard.press("Enter")
await expect
.poll(async () => (await saved(page)).providers[0].awsAccessKeyId)
.toBe("")
// The API key was the one in use: its result still holds
expect((await saved(page)).providers[0].models[0].validated).toBe(true)
await expect(dialog.locator("#bedrock-api-key")).toBeFocused()
})
test("a passing test hands the focus to its next step, a failing one keeps it", async ({
page,
}) => {
let pass = false
await page.route("**/api/validate-model", (route) =>
route.fulfill({
json: pass
? { valid: true, responseTime: 900 }
: { valid: false, error: "Model not found" },
}),
)
await openApp(page, TWO)
await openSettings(page)
const dialog = dialogOf(page)
await dialog.getByTestId("provider-row-p1").click()
const testButton = dialog.getByTestId("test-models")
await testButton.focus()
await page.keyboard.press("Enter")
await expect(dialog.getByTestId("test-result")).toHaveText(
"1 model failed the test",
)
await expect(testButton).toBeFocused()
pass = true
await page.keyboard.press("Enter")
const use = dialog.getByRole("button", { name: "Use glm-a in the chat" })
await expect(use).toBeFocused()
const resultId = await dialog.getByTestId("test-result").getAttribute("id")
await expect(use).toHaveAttribute("aria-describedby", resultId ?? "")
})
+3 -3
View File
@@ -18,14 +18,14 @@ test.describe("Settings", () => {
}) })
test("language selection is available", async ({ page }) => { test("language selection is available", async ({ page }) => {
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
await expect(dialog.locator("#language-select")).toHaveText(/English/) await expect(dialog.locator("#language-select")).toHaveText(/English/)
}) })
test("max output tokens is editable and persists", async ({ page }) => { test("max output tokens is editable and persists", async ({ page }) => {
await openSettingsTab(page, "advanced") await openSettingsTab(page, "drawing")
const input = page.locator("#max-output-tokens") const input = page.locator("#max-output-tokens")
await expect(input).toBeVisible() await expect(input).toBeVisible()
@@ -45,7 +45,7 @@ test.describe("Settings", () => {
}) })
test("theme can be light, dark or follow the system", async ({ page }) => { test("theme can be light, dark or follow the system", async ({ page }) => {
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
const dialog = page.locator('[role="dialog"]') const dialog = page.locator('[role="dialog"]')
for (const name of ["Light", "Dark", "System"]) { for (const name of ["Light", "Dark", "System"]) {
+21 -15
View File
@@ -471,7 +471,7 @@ test.describe("Workspace", () => {
return { dark: w.Editor.isDarkMode(), mark: w.__naiMark } return { dark: w.Editor.isDarkMode(), mark: w.__naiMark }
}, mark) }, mark)
expect((await drawio(true)).dark).toBe(true) expect((await drawio(true)).dark).toBe(true)
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await dialog.getByRole("radio", { name: "Light" }).click() await dialog.getByRole("radio", { name: "Light" }).click()
await expect.poll(() => drawio()).toEqual({ dark: false, mark: 1 }) await expect.poll(() => drawio()).toEqual({ dark: false, mark: 1 })
@@ -494,7 +494,7 @@ test.describe("Workspace", () => {
.locator(".geDiagramContainer") .locator(".geDiagramContainer")
.evaluate((el) => getComputedStyle(el).backgroundColor) .evaluate((el) => getComputedStyle(el).backgroundColor)
const light = await table() const light = await table()
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await dialog.getByRole("radio", { name: "Dark" }).click() await dialog.getByRole("radio", { name: "Dark" }).click()
// draw.io's own dark table // draw.io's own dark table
@@ -511,7 +511,7 @@ test.describe("Workspace", () => {
page.locator('[data-testid="canvas-loading"]'), page.locator('[data-testid="canvas-loading"]'),
).toHaveCount(0, { timeout: 30000 }) ).toHaveCount(0, { timeout: 30000 })
await expect.poll(table).toBe("rgb(27, 29, 30)") await expect.poll(table).toBe("rgb(27, 29, 30)")
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
await page await page
.getByRole("dialog") .getByRole("dialog")
.getByRole("radio", { name: "Light" }) .getByRole("radio", { name: "Light" })
@@ -916,23 +916,29 @@ test.describe("Workspace", () => {
expect(send.y + send.height).toBeLessThanOrEqual(320) expect(send.y + send.height).toBeLessThanOrEqual(320)
}) })
test("settings put their sections on top in a narrow window", async ({ test("settings put their sections on top in the phone layout", async ({
page, page,
}) => { }) => {
await page.setViewportSize({ width: 800, height: 800 }) // Below 768 px the app shows the phone layout, with the canvas hidden
await openApp(page) await page.setViewportSize({ width: 720, height: 800 })
await page.goto("/", { waitUntil: "networkidle" })
await page.locator('[data-testid="settings-button"]').first().click() await page.locator('[data-testid="settings-button"]').first().click()
const models = page.locator('[data-testid="settings-tab-models"]') const models = page.locator('[data-testid="settings-tab-models"]')
const appearance = page.locator( const general = page.locator('[data-testid="settings-tab-general"]')
'[data-testid="settings-tab-appearance"]',
)
await expect(models).toBeVisible() await expect(models).toBeVisible()
// Once fonts and the dialog's opening are done // Once fonts and the dialog's opening are done
await expect await expect
.poll(async () => .poll(async () =>
Math.abs((await boxOf(models)).y - (await boxOf(appearance)).y), Math.abs((await boxOf(models)).y - (await boxOf(general)).y),
) )
.toBeLessThan(2) .toBeLessThan(2)
// From 768 px on they are beside the page, one under the other
await page.setViewportSize({ width: 900, height: 800 })
await expect
.poll(
async () => (await boxOf(general)).y - (await boxOf(models)).y,
)
.toBeGreaterThan(20)
}) })
test("New chat brings the start screen back", async ({ page }) => { test("New chat brings the start screen back", async ({ page }) => {
@@ -1206,7 +1212,7 @@ test.describe("Workspace", () => {
await sendMessage(page, "Create a flowchart") await sendMessage(page, "Create a flowchart")
await waitForComplete(page) await waitForComplete(page)
// Right after the answer: its auto-save may not have run yet // Right after the answer: its auto-save may not have run yet
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
await page.locator("#language-select").click() await page.locator("#language-select").click()
await page.getByRole("option", { name: "日本語", exact: true }).click() await page.getByRole("option", { name: "日本語", exact: true }).click()
await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 }) await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 })
@@ -1231,7 +1237,7 @@ test.describe("Workspace", () => {
}) })
await openApp(page) await openApp(page)
await sendMessage(page, "Create a flowchart") await sendMessage(page, "Create a flowchart")
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
await expect(page.locator("#language-select")).toBeDisabled() await expect(page.locator("#language-select")).toBeDisabled()
}) })
@@ -1634,7 +1640,7 @@ test.describe("Workspace", () => {
const tabs = drawioTabs(page) const tabs = drawioTabs(page)
await expect(tabs).toContainText("Sheet") await expect(tabs).toContainText("Sheet")
// Right away, before the chat is saved on its own // Right away, before the chat is saved on its own
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
await page.locator("#language-select").click() await page.locator("#language-select").click()
await page.getByRole("option", { name: "日本語", exact: true }).click() await page.getByRole("option", { name: "日本語", exact: true }).click()
await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 }) await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 })
@@ -2669,7 +2675,7 @@ test.describe("Edge cases", () => {
await openApp(page) await openApp(page)
await sendMessage(page, "Create a flowchart") await sendMessage(page, "Create a flowchart")
await waitForComplete(page) await waitForComplete(page)
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
await page.locator("#language-select").click() await page.locator("#language-select").click()
await page.getByRole("option", { name: "日本語", exact: true }).click() await page.getByRole("option", { name: "日本語", exact: true }).click()
await expect(page).toHaveURL(/\/ja/, { timeout: 15000 }) await expect(page).toHaveURL(/\/ja/, { timeout: 15000 })
@@ -4708,7 +4714,7 @@ test.describe("Edge cases", () => {
} }
}) })
await openApp(page) await openApp(page)
await openSettingsTab(page, "appearance") await openSettingsTab(page, "general")
await page.locator("#language-select").click() await page.locator("#language-select").click()
await page.getByRole("option", { name: "日本語", exact: true }).click() await page.getByRole("option", { name: "日本語", exact: true }).click()
await expect(page).toHaveURL(/\/ja/, { timeout: 15000 }) await expect(page).toHaveURL(/\/ja/, { timeout: 15000 })
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest"
import enDict from "@/lib/i18n/dictionaries/en.json"
import jaDict from "@/lib/i18n/dictionaries/ja.json"
import zhDict from "@/lib/i18n/dictionaries/zh.json"
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
import {
filterProviderGroups,
hasCredentials,
POPULAR_PROVIDERS,
PROVIDER_GROUPS,
PROVIDER_NOTE_KEYS,
providerStatus,
} from "@/lib/provider-setup"
import { PROVIDER_INFO, type ProviderConfig } from "@/lib/types/model-config"
import { useUiStore } from "@/stores/ui-store"
const provider = (p: Partial<ProviderConfig>): ProviderConfig => ({
id: "p",
provider: "openai",
apiKey: "",
models: [],
...p,
})
describe("provider groups", () => {
it("hold every provider exactly once", () => {
const grouped = PROVIDER_GROUPS.flatMap((g) => g.providers)
expect([...grouped].sort()).toEqual(Object.keys(PROVIDER_INFO).sort())
expect(new Set(grouped).size).toBe(grouped.length)
})
it("offer known providers as popular ones", () => {
for (const p of POPULAR_PROVIDERS) expect(PROVIDER_INFO[p]).toBeTruthy()
})
it("have their notes in every language", () => {
for (const dict of [enDict, zhDict, jaDict, zhHantDict]) {
const t = dict.modelConfig as Record<string, string>
for (const key of Object.values(PROVIDER_NOTE_KEYS)) {
expect(t[key as string]).toBeTruthy()
}
}
})
it("are searched by label or name, ignoring case and spaces", () => {
expect(filterProviderGroups(" ")).toBe(PROVIDER_GROUPS)
const kimi = filterProviderGroups("MOONSHOT")
expect(kimi.map((g) => g.providers)).toEqual([["kimi"]])
// By the provider's own name too
expect(
filterProviderGroups("vertexai").flatMap((g) => g.providers),
).toEqual(["vertexai"])
expect(filterProviderGroups("no such provider")).toEqual([])
})
})
describe("hasCredentials", () => {
it("asks each provider for what it signs in with", () => {
const cases: [Partial<ProviderConfig>, boolean][] = [
[{ provider: "openai" }, false],
[{ provider: "openai", apiKey: "k" }, true],
[{ provider: "ollama" }, true],
[{ provider: "edgeone" }, true],
[{ provider: "vertexai", apiKey: "k" }, false],
[{ provider: "vertexai", vertexApiKey: "k" }, true],
[{ provider: "bedrock", apiKey: "k" }, false],
[
{ provider: "bedrock", apiKey: "k", awsRegion: "us-east-1" },
true,
],
[
{
provider: "bedrock",
awsAccessKeyId: "a",
awsRegion: "us-east-1",
},
false,
],
[
{
provider: "bedrock",
awsAccessKeyId: "a",
awsSecretAccessKey: "s",
awsRegion: "us-east-1",
},
true,
],
]
for (const [p, expected] of cases) {
expect([p, hasCredentials(provider(p))]).toEqual([p, expected])
}
})
})
describe("providerStatus", () => {
const models = (...validated: (boolean | undefined)[]) =>
validated.map((v, i) => ({
id: `m${i}`,
modelId: `m${i}`,
validated: v,
}))
it("puts a failed model first", () => {
expect(
providerStatus(provider({ models: models(false, false, true) })),
).toEqual({ kind: "error", count: 2 })
// Even without credentials
expect(providerStatus(provider({ models: models(false) }))).toEqual({
kind: "error",
count: 1,
})
})
it("then a missing key or no model", () => {
expect(providerStatus(provider({ models: models(true) }))).toEqual({
kind: "incomplete",
})
expect(providerStatus(provider({ apiKey: "k" }))).toEqual({
kind: "incomplete",
})
})
it("then untested models, else all working", () => {
expect(
providerStatus(
provider({ apiKey: "k", models: models(true, undefined) }),
),
).toEqual({ kind: "untested", count: 1 })
expect(
providerStatus(
provider({ apiKey: "k", models: models(true, true) }),
),
).toEqual({ kind: "ok", count: 2 })
})
})
describe("openSettings", () => {
it("opens the models tab's list unless a page is given", () => {
const store = useUiStore.getState()
store.openSettings("models", { providerId: "p1" })
expect(useUiStore.getState().modelsPage).toEqual({ providerId: "p1" })
store.setSettingsOpen(false)
// Opened again some other way: back on the list, on the last tab
store.setSettingsTab("drawing")
store.openSettings()
expect(useUiStore.getState()).toMatchObject({
settingsOpen: true,
settingsTab: "drawing",
modelsPage: "list",
})
store.openSettings("models", "picker")
expect(useUiStore.getState().modelsPage).toBe("picker")
})
})
+42
View File
@@ -260,3 +260,45 @@ describe("useModelConfig across tabs", () => {
expect(result.current.config.providers).toHaveLength(2) expect(result.current.config.providers).toHaveLength(2)
}) })
}) })
describe("useModelConfig migration", () => {
const OLD = {
provider: "next-ai-draw-io-ai-provider",
baseUrl: "next-ai-draw-io-ai-base-url",
apiKey: "next-ai-draw-io-ai-api-key",
model: "next-ai-draw-io-ai-model",
}
it("moves an old keyless Ollama setup into the new format", async () => {
localStorage.setItem(OLD.provider, "ollama")
localStorage.setItem(OLD.baseUrl, "http://localhost:11434/api")
localStorage.setItem(OLD.apiKey, "")
localStorage.setItem(OLD.model, "llama3.2")
const { result } = await renderLoaded()
const [provider] = result.current.config.providers
expect(provider).toMatchObject({
provider: "ollama",
apiKey: "",
baseUrl: "http://localhost:11434/api",
models: [{ modelId: "llama3.2" }],
})
expect(result.current.selectedModelId).toBe(provider.models[0].id)
expect(localStorage.getItem(OLD.model)).toBeNull()
})
it("keeps an old Ollama without a URL on the server's Ollama", async () => {
localStorage.setItem(OLD.provider, "ollama")
localStorage.setItem(OLD.apiKey, "")
localStorage.setItem(OLD.model, "llama3.2")
const { result } = await renderLoaded()
// No cloud address: the request takes OLLAMA_BASE_URL or this machine
expect(result.current.config.providers[0].baseUrl).toBe("")
})
it("still needs a key for other providers", async () => {
localStorage.setItem(OLD.provider, "openai")
localStorage.setItem(OLD.model, "gpt-4o")
const { result } = await renderLoaded()
expect(result.current.config.providers).toEqual([])
})
})