mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 19:49:52 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffed9b6062 |
@@ -43,14 +43,8 @@ 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:
|
||||||
# Six jobs at once, each with a sixth of the tests and one worker: the
|
name: E2E Tests
|
||||||
# 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
|
||||||
|
|
||||||
@@ -82,7 +76,7 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
run: npm run test:e2e -- --shard=${{ matrix.shard }}/6
|
run: npm run test:e2e
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
@@ -90,6 +84,6 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report-${{ matrix.shard }}
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
@@ -1,98 +1,121 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# Next AI Draw.io
|
# Next AI Draw.io
|
||||||
|
|
||||||
**Draw and edit draw.io diagrams by chatting with an AI.**
|
<div align="center">
|
||||||
|
|
||||||
|
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
||||||
|
|
||||||
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
|
[](https://nextjs.org/)
|
||||||
|
[](https://react.dev/)
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[**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)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
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.
|
||||||
|
|
||||||
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`.
|
> 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!
|
||||||
|
|
||||||
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.
|
<p align="center">
|
||||||
|
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
||||||
|
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Highlights
|
> 🎁 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).
|
||||||
|
|
||||||
**Draw**
|
|
||||||
|
|
||||||
- Architecture diagrams, flowcharts, sequence diagrams and more from one sentence, with built-in AWS, Azure, GCP and Kubernetes icon libraries
|
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||||
- 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
|
|
||||||
|
|
||||||
**Use**
|
## Table of Contents
|
||||||
|
- [Next AI Draw.io](#next-ai-drawio)
|
||||||
- 24 model providers; enter your own API key in the browser and it stays on your machine
|
- [Table of Contents](#table-of-contents)
|
||||||
- Models that reason show their thinking
|
- [Examples](#examples)
|
||||||
- Dark mode; the interface is available in English, Simplified Chinese, Traditional Chinese and Japanese
|
- [Features](#features)
|
||||||
|
- [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>Transformer architecture with animated connectors</strong><br />
|
<strong>Animated transformer connectors</strong><br />
|
||||||
<p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
|
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||||
<img src="./public/animated_connectors.svg" alt="Transformer architecture with animated connectors" width="440" />
|
<img src="./public/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG architecture</strong><br />
|
<strong>RAG Technique Diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="./public/rag_prod.svg" alt="RAG architecture diagram" width="400" />
|
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Authentication with React and AWS</strong><br />
|
<strong>Authentication using React and AWS</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="./public/auth.svg" alt="Authentication architecture diagram" width="400" />
|
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Open Innovation model</strong><br />
|
<strong>Open Innovation</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="400" />
|
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
||||||
</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="200" />
|
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Use it
|
## Features
|
||||||
|
|
||||||
### Online demo
|
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
|
||||||
|
- **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
|
||||||
|
|
||||||
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.
|
## MCP Server
|
||||||
|
|
||||||
### Desktop app
|
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@@ -105,61 +128,165 @@ Through MCP (Model Context Protocol, the protocol AI agents use to call outside
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For Claude Code, one command does it:
|
### Claude Code CLI
|
||||||
|
|
||||||
```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 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:
|
Then ask Claude to create diagrams:
|
||||||
|
> "Create a flowchart showing user authentication with login, MFA, and session management"
|
||||||
|
|
||||||
- The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
|
The diagram appears in your browser in real-time!
|
||||||
- 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
|
|
||||||
|
|
||||||
See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
|
The MCP server includes most of the web app's drawing features:
|
||||||
|
|
||||||
## Self-host
|
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||||
|
- 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
|
||||||
|
|
||||||
### Run locally
|
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Try it Online
|
||||||
|
|
||||||
|
No installation needed! Try the app directly on our demo site:
|
||||||
|
|
||||||
|
[](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 # add your provider and API key, see "Models and providers" below
|
cp env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:6002](http://localhost:6002).
|
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.
|
||||||
|
|
||||||
### One-click deploy
|
## Deployment
|
||||||
|
|
||||||
| Platform | How |
|
### Deploy to EdgeOne Pages
|
||||||
| --- | --- |
|
|
||||||
| Tencent EdgeOne Pages | [](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 | [](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) |
|
|
||||||
|
|
||||||
### Models and providers
|
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
|
||||||
|
|
||||||
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 by this button:
|
||||||
|
|
||||||
**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.
|
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
**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).
|
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.
|
||||||
|
|
||||||
## Support
|
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
||||||
|
|
||||||
- Questions and ideas: open a [GitHub issue](https://github.com/DayuanJiang/next-ai-draw-io/issues) or email me[at]jiang.jp
|
### Deploy on Vercel
|
||||||
- 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
|
|
||||||
|
|
||||||
<div align="center">
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|
||||||
</div>
|
---
|
||||||
|
|||||||
@@ -66,6 +66,54 @@ 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">
|
||||||
@@ -261,7 +309,16 @@ 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(通过{" "}
|
||||||
@@ -286,6 +343,18 @@ 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
|
||||||
|
|||||||
@@ -74,6 +74,37 @@ 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">
|
||||||
@@ -274,7 +305,16 @@ 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>ByteDance Doubao</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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
@@ -299,6 +339,18 @@ 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
|
||||||
|
|||||||
@@ -74,6 +74,39 @@ 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">
|
||||||
@@ -286,7 +319,16 @@ 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>ByteDance Doubao</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"
|
||||||
|
>
|
||||||
|
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{" "}
|
||||||
@@ -313,6 +355,18 @@ 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 & Contact
|
Support & 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
|
||||||
|
|||||||
@@ -413,8 +413,6 @@ 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)
|
||||||
@@ -609,11 +607,6 @@ 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,
|
||||||
@@ -621,15 +614,14 @@ 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, providerId },
|
metadata: { openModelConfig: true },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
if (isAccessCodeError) openSettings("general")
|
if (isAccessCodeError) openSettings("advanced")
|
||||||
},
|
},
|
||||||
// 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
|
||||||
@@ -1208,9 +1200,6 @@ 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 = {
|
||||||
|
|||||||
@@ -466,8 +466,10 @@ 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
|
||||||
|
|||||||
@@ -615,16 +615,7 @@ export function MessageList() {
|
|||||||
| undefined
|
| undefined
|
||||||
)?.openModelConfig
|
)?.openModelConfig
|
||||||
}
|
}
|
||||||
onOpenModelConfig={() => {
|
onOpenModelConfig={() => openSettings("models")}
|
||||||
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 &&
|
||||||
|
|||||||
+931
-775
File diff suppressed because it is too large
Load Diff
@@ -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,9 +39,8 @@ 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)
|
||||||
@@ -93,21 +92,27 @@ 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. Every user model is listed; one not
|
// Separate server and user models
|
||||||
// tested yet, or that failed its test, says so.
|
|
||||||
const serverModels = useMemo(
|
const serverModels = useMemo(
|
||||||
() => models.filter((m) => m.source === "server"),
|
() => displayModels.filter((m) => m.source === "server"),
|
||||||
[models],
|
[displayModels],
|
||||||
)
|
)
|
||||||
const userModels = useMemo(
|
const userModels = useMemo(
|
||||||
() => models.filter((m) => m.source !== "server"),
|
() => displayModels.filter((m) => m.source !== "server"),
|
||||||
[models],
|
[displayModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Group each category separately
|
// Group each category separately
|
||||||
@@ -126,17 +131,6 @@ 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)
|
||||||
@@ -186,13 +180,7 @@ 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
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
if (next) toSettingsRef.current = false
|
|
||||||
setOpen(next)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent={tooltipContent}
|
tooltipContent={tooltipContent}
|
||||||
@@ -234,14 +222,7 @@ export function ModelSelector({
|
|||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
</ModelSelectorTrigger>
|
</ModelSelectorTrigger>
|
||||||
|
|
||||||
<ModelSelectorContent
|
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
@@ -249,7 +230,10 @@ 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>
|
||||||
{dict.modelConfig.noModelsFound}
|
{displayModels.length === 0 &&
|
||||||
|
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 */}
|
||||||
@@ -438,22 +422,13 @@ export function ModelSelector({
|
|||||||
{model.validated !==
|
{model.validated !==
|
||||||
true && (
|
true && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
title={
|
||||||
"ml-auto shrink-0 pl-2 text-xs",
|
dict
|
||||||
model.validated ===
|
.modelConfig
|
||||||
false
|
.unvalidatedModelWarning
|
||||||
? "text-destructive"
|
}
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{model.validated ===
|
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||||
false
|
|
||||||
? dict
|
|
||||||
.modelConfig
|
|
||||||
.modelFailed
|
|
||||||
: dict
|
|
||||||
.modelConfig
|
|
||||||
.modelUntested}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
@@ -466,46 +441,31 @@ export function ModelSelector({
|
|||||||
)}
|
)}
|
||||||
</ModelSelectorList>
|
</ModelSelectorList>
|
||||||
</div>
|
</div>
|
||||||
{/* Pinned footer: add a provider, configure models
|
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
||||||
(z-10 above list shadow). Buttons, outside the
|
<div className="relative z-10 shrink-0 border-t bg-background">
|
||||||
search: reachable with Tab, never filtered out */}
|
|
||||||
{(onAddProvider || onConfigure) && (
|
|
||||||
<div
|
|
||||||
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
|
||||||
// The search's Enter would pick the highlighted
|
|
||||||
// model instead of pressing the button
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{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 && (
|
{onConfigure && (
|
||||||
<button
|
<div className="px-3 py-2">
|
||||||
type="button"
|
<ModelSelectorItem
|
||||||
onClick={() =>
|
value="__configure_models__"
|
||||||
goToSettings(onConfigure)
|
onSelect={() => {
|
||||||
}
|
onConfigure()
|
||||||
className={footerButton}
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate">
|
<ModelSelectorName>
|
||||||
{dict.modelConfig.configureModels}
|
{dict.modelConfig.configureModels}
|
||||||
</span>
|
</ModelSelectorName>
|
||||||
</button>
|
</ModelSelectorItem>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||||
|
{showUnvalidatedModels
|
||||||
|
? dict.modelConfig.allModelsShown
|
||||||
|
: dict.modelConfig.onlyVerifiedShown}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelectorRoot>
|
</ModelSelectorRoot>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ChevronRight, Key, Link2, Tag } from "lucide-react"
|
import { Key, Link2, Tag } from "lucide-react"
|
||||||
import { type ReactNode, useState } from "react"
|
import type { ReactNode } 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,14 +13,12 @@ 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
|
||||||
@@ -32,9 +30,6 @@ 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"],
|
||||||
@@ -71,17 +66,6 @@ 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
|
||||||
@@ -96,10 +80,6 @@ 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]
|
||||||
@@ -107,29 +87,15 @@ 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 = (
|
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||||
|
if (provider === "edgeone") {
|
||||||
|
return <div className="space-y-5">{footer}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Display Name */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="provider-name"
|
htmlFor="provider-name"
|
||||||
@@ -147,314 +113,6 @@ export function ProviderCredentialsFields({
|
|||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
|
||||||
if (provider === "edgeone") {
|
|
||||||
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 (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{nameField}
|
|
||||||
|
|
||||||
{provider === "bedrock" ? (
|
{provider === "bedrock" ? (
|
||||||
<>
|
<>
|
||||||
@@ -508,7 +166,37 @@ export function ProviderCredentialsFields({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{regionField}
|
{/* AWS Region */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="aws-region"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{dict.modelConfig.awsRegion}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={awsRegion || ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(v) => onChange("awsRegion", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="aws-region"
|
||||||
|
className="h-9 font-mono text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={dict.modelConfig.selectRegion}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
{AWS_REGIONS.map(([region, label]) => (
|
||||||
|
<SelectItem key={region} value={region}>
|
||||||
|
{region} ({label})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : provider === "vertexai" ? (
|
) : provider === "vertexai" ? (
|
||||||
<>
|
<>
|
||||||
@@ -527,12 +215,104 @@ export function ProviderCredentialsFields({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vertexBaseUrlField}
|
{/* Base URL (optional) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="vertex-base-url"
|
||||||
|
className="text-xs font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{baseUrlLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="vertex-base-url"
|
||||||
|
value={baseUrl ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("baseUrl", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={dict.modelConfig.customEndpoint}
|
||||||
|
className="h-9 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{apiKeyField}
|
{/* API Key, with a link to where the provider issues keys */}
|
||||||
{baseUrlField}
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,18 @@ 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,
|
||||||
|
|||||||
+175
-232
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Github,
|
Github,
|
||||||
Info,
|
Info,
|
||||||
|
KeyRound,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
PenTool,
|
Palette,
|
||||||
Settings2,
|
SlidersHorizontal,
|
||||||
Sparkles,
|
|
||||||
Sun,
|
Sun,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -14,7 +14,6 @@ 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,
|
||||||
@@ -131,15 +130,7 @@ function Segmented<T extends string>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
function AppearanceTab() {
|
||||||
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()
|
||||||
@@ -148,10 +139,99 @@ function GeneralTab({ open }: { open: boolean }) {
|
|||||||
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 sendShortcut = useSettingsStore((s) => s.sendShortcut)
|
const minimalStyle = useSettingsStore((s) => s.minimalStyle)
|
||||||
const setSendShortcut = useSettingsStore((s) => s.setSendShortcut)
|
const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle)
|
||||||
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",
|
||||||
@@ -189,31 +269,6 @@ function GeneralTab({ 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)
|
||||||
@@ -265,90 +320,6 @@ function GeneralTab({ open }: { open: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
<SectionHeading>{t.sectionInterface}</SectionHeading>
|
|
||||||
<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}
|
|
||||||
htmlFor="language-select"
|
|
||||||
>
|
|
||||||
<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.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">
|
<div className="divide-y divide-border">
|
||||||
{accessCodeRequired && (
|
{accessCodeRequired && (
|
||||||
<Row
|
<Row
|
||||||
@@ -362,9 +333,7 @@ function GeneralTab({ open }: { open: boolean }) {
|
|||||||
id="access-code"
|
id="access-code"
|
||||||
type="password"
|
type="password"
|
||||||
value={accessCode}
|
value={accessCode}
|
||||||
onChange={(e) =>
|
onChange={(e) => setAccessCode(e.target.value)}
|
||||||
setAccessCode(e.target.value)
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -377,9 +346,7 @@ function GeneralTab({ open }: { open: boolean }) {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={saveAccessCode}
|
onClick={saveAccessCode}
|
||||||
disabled={
|
disabled={isVerifying || !accessCode.trim()}
|
||||||
isVerifying || !accessCode.trim()
|
|
||||||
}
|
|
||||||
className="h-9 rounded-lg px-4"
|
className="h-9 rounded-lg px-4"
|
||||||
>
|
>
|
||||||
{isVerifying ? "…" : dict.common.save}
|
{isVerifying ? "…" : dict.common.save}
|
||||||
@@ -392,85 +359,26 @@ function GeneralTab({ open }: { open: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
{isElectron && (
|
<Row label={t.sendShortcut} description={t.sendShortcutDescription}>
|
||||||
<Row
|
<Select
|
||||||
label={t.proxy}
|
value={settings.sendShortcut}
|
||||||
description={t.proxyDescription}
|
onValueChange={(value) =>
|
||||||
stacked
|
settings.setSendShortcut(value as SendShortcut)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2">
|
<SelectTrigger
|
||||||
<Input
|
id="send-shortcut-select"
|
||||||
id="http-proxy"
|
className="h-8 w-auto rounded-lg"
|
||||||
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}
|
<SelectValue />
|
||||||
</Button>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
</Row>
|
<SelectItem value="enter">{t.enterToSend}</SelectItem>
|
||||||
)}
|
<SelectItem value="ctrl-enter">
|
||||||
</div>
|
{t.ctrlEnterToSend}
|
||||||
</>
|
</SelectItem>
|
||||||
)}
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
||||||
@@ -500,6 +408,50 @@ function DrawingTab() {
|
|||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -513,7 +465,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">
|
<div className="space-y-5 pt-1">
|
||||||
<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>
|
||||||
@@ -582,20 +534,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: <Sparkles /> },
|
{ id: "models", label: t.tabModels, icon: <KeyRound /> },
|
||||||
{ id: "general", label: t.tabGeneral, icon: <Settings2 /> },
|
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> },
|
||||||
{ id: "drawing", label: t.tabDrawing, icon: <PenTool /> },
|
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> },
|
||||||
{ 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 md: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 lg:flex-row">
|
||||||
{/* On top below 768 px, where beside the page it would leave
|
{/* On top below 1024 px: beside the model settings' own
|
||||||
too little width */}
|
provider list it would leave too little width */}
|
||||||
<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">
|
<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">
|
||||||
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] md:block">
|
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block">
|
||||||
{t.title}
|
{t.title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{tabs.map((item) => (
|
{tabs.map((item) => (
|
||||||
@@ -605,12 +557,9 @@ 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",
|
||||||
current.id === item.id &&
|
tab === 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}
|
||||||
@@ -622,26 +571,20 @@ export function SettingsDialog() {
|
|||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
{t.description}
|
{t.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
{current.id === "models" ? (
|
{tab === "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">
|
||||||
<SettingsHeader>
|
<h2 className="mb-2 text-[15px] font-semibold">
|
||||||
<h2 className="font-semibold">
|
|
||||||
{current.label}
|
{current.label}
|
||||||
</h2>
|
</h2>
|
||||||
</SettingsHeader>
|
{tab === "appearance" && <AppearanceTab />}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin">
|
{tab === "advanced" && <AdvancedTab open={open} />}
|
||||||
{current.id === "general" && (
|
{tab === "about" && <AboutTab />}
|
||||||
<GeneralTab open={open} />
|
|
||||||
)}
|
|
||||||
{current.id === "drawing" && <DrawingTab />}
|
|
||||||
{current.id === "about" && <AboutTab />}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+182
-77
@@ -1,98 +1,107 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# Next AI Draw.io
|
# Next AI Draw.io
|
||||||
|
|
||||||
**和 AI 对话,画出并修改 draw.io 图表。**
|
<div align="center">
|
||||||
|
|
||||||
|
**AI驱动的图表创建工具 - 对话、绘制、可视化**
|
||||||
|
|
||||||
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
|
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
|
[](https://nextjs.org/)
|
||||||
|
[](https://react.dev/)
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[**在线体验**](https://next-ai-drawio.jiang.jp/) · [**桌面应用**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP 服务器**](#在-ai-代理里使用mcp)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||||
|
|
||||||
用一句话描述想要的图,AI 就在真正的 draw.io 画布上把它画出来。你可以像编辑任何 draw.io 文件一样手动修改,也可以选中几个图形、告诉 AI 要改什么。AI 的每次修改都是一个版本,可以对比、恢复或撤销;结果可以导出为 `.drawio`、`.png` 或 `.svg`。
|
> 注:感谢 <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 模型!
|
||||||
|
|
||||||
提供网页版、Windows / macOS / Linux 桌面版,以及供 Claude Code、Cursor、VS Code 等 AI 代理调用的 MCP 服务器。
|
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
|
||||||
|
|
||||||
## 亮点
|
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||||
|
|
||||||
**画**
|
## 目录
|
||||||
|
- [Next AI Draw.io](#next-ai-drawio)
|
||||||
- 一句话生成架构图、流程图、时序图等,内置 AWS、Azure、GCP、Kubernetes 等图标库
|
- [目录](#目录)
|
||||||
- 连接线可以带流动动画
|
- [示例](#示例)
|
||||||
- 上传截图或手绘图,让 AI 照着画;上传 PDF、Markdown、代码等文本文件,从内容生成图
|
- [功能特性](#功能特性)
|
||||||
|
- [MCP服务器](#mcp服务器)
|
||||||
**改**
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
|
- [快速开始](#快速开始)
|
||||||
- 用对话修改:改动流式呈现,刚改过的图形在画布上高亮
|
- [在线试用](#在线试用)
|
||||||
- 选中图形提问:在画布上选中几个图形,AI 只改这几个
|
- [桌面应用](#桌面应用)
|
||||||
- 版本与撤销:每次 AI 修改都是一张带缩略图的版本卡片,可以和画布对比、恢复、撤销;画布上按 Ctrl+Z 也能一步撤回 AI 的修改
|
- [使用Docker运行](#使用docker运行)
|
||||||
- 它就是普通的 draw.io:双击改名、拖动、调样式、多页,随时导出 `.drawio`、`.png`、`.svg`、`.drawio.svg`
|
- [安装](#安装)
|
||||||
|
- [部署](#部署)
|
||||||
**用**
|
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
||||||
|
- [部署到Vercel](#部署到vercel)
|
||||||
- 支持 24 家模型服务商,可以在浏览器里填自己的 API Key,Key 只保存在本地
|
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
||||||
- 支持推理的模型会显示思考过程
|
- [多提供商支持](#多提供商支持)
|
||||||
- 深色模式;界面有英文、简体中文、繁体中文、日文
|
- [工作原理](#工作原理)
|
||||||
|
- [支持与联系](#支持与联系)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
|
- [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 <strong>animated connector</strong> diagram of transformer's architecture.</p>
|
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="带动画连接线的 Transformer 架构" width="440" />
|
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG 架构图</strong><br />
|
<strong>RAG技术图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAG 架构图" width="400" />
|
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
||||||
</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 <strong>AWS</strong>. Use Serverless architecture.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="../../public/auth.svg" alt="认证架构图" width="400" />
|
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
||||||
</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="400" />
|
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
||||||
</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="200" />
|
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||||
</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)
|
||||||
|
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|
||||||
打开 [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/) 即可使用,无需安装。演示站有用量限制;点击聊天面板里的设置图标,填入自己的服务商和 API Key 就不受限制。Key 只保存在浏览器本地,不会上传到服务器。
|
## MCP服务器
|
||||||
|
|
||||||
### 桌面应用
|
通过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
|
||||||
{
|
{
|
||||||
@@ -105,61 +114,157 @@ https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Claude Code 用一行命令:
|
### Claude Code CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
|
然后让Claude创建图表:
|
||||||
|
> "创建一个展示用户认证流程的流程图,包含登录、MFA和会话管理"
|
||||||
|
|
||||||
- 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
|
图表会实时显示在浏览器中!
|
||||||
- 截图工具,AI 可以看一眼画好的图并自行修正
|
|
||||||
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
|
|
||||||
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
|
|
||||||
|
|
||||||
VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
|
MCP服务器包含网页版的大部分画图功能:
|
||||||
|
|
||||||
## 自己部署
|
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
|
||||||
|
- 截图工具,AI可以查看画好的图并自行修正
|
||||||
|
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
|
||||||
|
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
|
||||||
|
|
||||||
### 本地运行
|
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 在线试用
|
||||||
|
|
||||||
|
无需安装!直接在我们的演示站点试用:
|
||||||
|
|
||||||
|
[](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 # 填入服务商和 API Key,见下文"模型与服务商"
|
cp env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
|
||||||
|
|
||||||
|
2. 运行开发服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 [http://localhost:6002](http://localhost:6002)。
|
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。
|
||||||
|
|
||||||
### 一键部署
|
## 部署
|
||||||
|
|
||||||
| 平台 | 方式 |
|
### 部署到腾讯云EdgeOne Pages
|
||||||
| --- | --- |
|
|
||||||
| 腾讯云 EdgeOne Pages | [](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 | [](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)。
|
直接点击此按钮一键部署:
|
||||||
|
[](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
**选哪个模型**:这项任务要生成格式严格的长文本(draw.io XML),请选能力较强的模型,小模型容易画出错误的图。
|
查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。
|
||||||
|
|
||||||
**多模型与管理面板**:在 `AI_MODEL` 里用逗号列出多个模型 ID,或用 `AI_MODELS_CONFIG` 环境变量 / `ai-models.json` 文件配置多家服务商的模型,所有用户无需自带 Key 即可使用。设置 `ADMIN_PASSWORD` 后访问 `/admin`,可以在网页里管理模型、访问码、功能开关、可观测性和配额,见[管理面板指南](./admin-panel.md)。
|
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
||||||
|
|
||||||
## 支持
|
### 部署到Vercel
|
||||||
|
|
||||||
- 问题和建议:提交 [GitHub Issue](https://github.com/DayuanJiang/next-ai-draw-io/issues),或发邮件到 me[at]jiang.jp
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
- 常见问题:[FAQ](./FAQ.md)
|
|
||||||
- 如果这个项目对你有用,欢迎[赞助](https://github.com/sponsors/DayuanJiang),帮助我维持演示站点的运行
|
|
||||||
|
|
||||||
<div align="center">
|
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。
|
||||||
|
|
||||||
|
查看[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历史
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|
||||||
</div>
|
---
|
||||||
|
|||||||
@@ -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 标记为"需要重启")。
|
||||||
|
|
||||||
## 优先级
|
## 优先级
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
### 豆包 (字节跳动火山引擎)
|
### 豆包 (字节跳动火山引擎)
|
||||||
|
|
||||||
|
> **免费 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 # 或其他豆包模型
|
||||||
|
|||||||
@@ -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; 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).
|
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
||||||
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
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
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ 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
|
||||||
|
|||||||
+182
-78
@@ -1,98 +1,105 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# Next AI Draw.io
|
# Next AI Draw.io
|
||||||
|
|
||||||
**AI とチャットしながら draw.io のダイアグラムを描き、直す。**
|
<div align="center">
|
||||||
|
|
||||||
|
**AI搭載のダイアグラム作成ツール - チャット、描画、可視化**
|
||||||
|
|
||||||
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
[English](../../README.md) | [中文](../cn/README_CN.md) | 日本語
|
||||||
|
|
||||||
[](https://next-ai-drawio.jiang.jp/)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
|
[](https://nextjs.org/)
|
||||||
|
[](https://react.dev/)
|
||||||
[](https://github.com/sponsors/DayuanJiang)
|
[](https://github.com/sponsors/DayuanJiang)
|
||||||
|
|
||||||
[**オンラインデモ**](https://next-ai-drawio.jiang.jp/) · [**デスクトップアプリ**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP サーバー**](#ai-エージェントから使うmcp)
|
[](https://next-ai-drawio.jiang.jp/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||||
|
|
||||||
欲しいダイアグラムを一文で伝えると、AI が本物の draw.io キャンバスに描きます。普通の draw.io ファイルと同じように手で直すことも、図形をいくつか選んで AI に変更を頼むこともできます。AI の変更はすべてバージョンとして残り、比較・復元・取り消しができます。結果は `.drawio`、`.png`、`.svg` で書き出せます。
|
> 注:<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 モデルを導入しました!
|
||||||
|
|
||||||
Web アプリ、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 のアイコンライブラリを内蔵
|
- [機能](#機能)
|
||||||
- コネクタに流れるアニメーションを付けられる
|
- [MCPサーバー](#mcpサーバー)
|
||||||
- スクリーンショットや手描きの図をアップロードすると AI が描き直す。PDF、Markdown、コードなどのテキストファイルからも内容を図にできる
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
|
- [はじめに](#はじめに)
|
||||||
**直す**
|
- [オンラインで試す](#オンラインで試す)
|
||||||
|
- [デスクトップアプリケーション](#デスクトップアプリケーション)
|
||||||
- チャットで修正:変更はストリーミングでキャンバスに反映され、AI が変更した図形はハイライト表示
|
- [Dockerで実行](#dockerで実行)
|
||||||
- 選択して頼む:キャンバスで図形を選ぶと、AI はその図形だけを変更
|
- [インストール](#インストール)
|
||||||
- バージョンと取り消し:AI の変更ごとにサムネイル付きのバージョンカードが残り、キャンバスと比較・復元・取り消しができる。キャンバス上の Ctrl+Z でも AI の変更を一度で戻せる
|
- [デプロイ](#デプロイ)
|
||||||
- ただの draw.io ダイアグラム:ダブルクリックで名前を変え、ドラッグし、スタイルを変え、複数ページを使い、いつでも `.drawio`、`.png`、`.svg`、`.drawio.svg` に書き出せる
|
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
||||||
|
- [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 <strong>animated connector</strong> diagram of transformer's architecture.</p>
|
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付き Transformer アーキテクチャ" width="440" />
|
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>RAG アーキテクチャ</strong><br />
|
<strong>RAG技術ダイアグラム</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAG アーキテクチャ図" width="400" />
|
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
||||||
</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 <strong>AWS</strong>. Use Serverless architecture.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="400" />
|
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
||||||
</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="400" />
|
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
||||||
</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="200" />
|
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||||
</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)
|
||||||
|
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
|
||||||
[next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/) を開けばすぐ使えます。インストールは不要です。デモサイトには利用上限があります。チャットパネルの設定アイコンから自分のプロバイダーと API キーを入力すると上限なしで使えます。キーはブラウザ内にだけ保存され、サーバーには送られません。
|
## MCPサーバー
|
||||||
|
|
||||||
### デスクトップアプリ
|
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
|
||||||
{
|
{
|
||||||
@@ -105,61 +112,158 @@ MCP(Model Context Protocol、AI エージェントが外部ツールを呼び
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Claude Code ならコマンド一つです。
|
### Claude Code CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
あとは AI に「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを描いて」と頼めば、描かれていく様子がブラウザに表示されます。MCP サーバーには Web アプリの作図機能のほとんどが入っています。
|
Claudeにダイアグラムの作成を依頼:
|
||||||
|
> 「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを作成してください」
|
||||||
|
|
||||||
- 同じ作図ルールとアイコンライブラリ(AWS、Azure、GCP、Kubernetes など)
|
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||||
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
|
|
||||||
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
|
|
||||||
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
|
|
||||||
|
|
||||||
VS Code、Cursor などの設定は [MCP サーバーの README](../../packages/mcp-server/README.md) を参照してください。
|
MCPサーバーには、Webアプリの主な作図機能が含まれています:
|
||||||
|
|
||||||
## 自分でホストする
|
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
|
||||||
|
- スクリーンショットツール:AIが描画結果を確認して修正できます
|
||||||
|
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
|
||||||
|
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
|
||||||
|
|
||||||
### ローカルで動かす
|
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||||
|
|
||||||
|
## はじめに
|
||||||
|
|
||||||
|
### オンラインで試す
|
||||||
|
|
||||||
|
インストール不要!デモサイトで直接お試しください:
|
||||||
|
|
||||||
|
[](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 # プロバイダーと API キーを記入。下の「モデルとプロバイダー」を参照
|
cp env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
詳細な設定手順については[プロバイダー設定ガイド](./ai-providers.md)を参照してください。
|
||||||
|
|
||||||
|
2. 開発サーバーを起動:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
[http://localhost:6002](http://localhost:6002) を開きます。
|
3. ブラウザで[http://localhost:6002](http://localhost:6002)を開いてアプリケーションを確認。
|
||||||
|
|
||||||
### ワンクリックデプロイ
|
## デプロイ
|
||||||
|
|
||||||
| プラットフォーム | 方法 |
|
### EdgeOne Pagesへのデプロイ
|
||||||
| --- | --- |
|
|
||||||
| Tencent EdgeOne Pages | [](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 | [](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)にあります。
|
このボタンでデプロイ:
|
||||||
|
|
||||||
**どのモデルを選ぶか**:厳密な形式の長いテキスト(draw.io の XML)を生成するタスクなので、能力の高いモデルを選んでください。小さなモデルは壊れた図を出しがちです。
|
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
**複数モデルと管理パネル**:`AI_MODEL` にモデル ID をカンマ区切りで並べるか、`AI_MODELS_CONFIG` 環境変数または `ai-models.json` ファイルで複数プロバイダーのモデルを設定すると、全ユーザーが自分のキーなしで使えます。`ADMIN_PASSWORD` を設定して `/admin` を開くと、モデル、アクセスコード、機能の切り替え、オブザーバビリティ、クォータを Web 画面から管理できます。[管理パネルガイド](./admin-panel.md)を参照してください。
|
詳細は[Tencent EdgeOne Pagesドキュメント](https://pages.edgeone.ai/document/deployment-overview)をご覧ください。
|
||||||
|
|
||||||
## サポート
|
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
||||||
|
|
||||||
- 質問や提案:[GitHub Issue](https://github.com/DayuanJiang/next-ai-draw-io/issues) を立てるか、me[at]jiang.jp までメールしてください
|
### Vercelへのデプロイ
|
||||||
- よくある問題:[FAQ](./FAQ.md)
|
|
||||||
- このプロジェクトが役に立ったら、デモサイトの運営のために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください
|
|
||||||
|
|
||||||
<div align="center">
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
|
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。ローカルの`.env.local`ファイルと同様に、Vercelダッシュボードで**環境変数を設定**してください。
|
||||||
|
|
||||||
|
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。
|
||||||
|
|
||||||
|
### 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) をご覧ください。
|
||||||
|
|
||||||
|
## スター履歴
|
||||||
|
|
||||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||||
|
|
||||||
</div>
|
---
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## 設定できる項目
|
## 設定できる項目
|
||||||
|
|
||||||
1. **Models(モデル)** — プロバイダーの API キーとモデルリストを追加します(認証情報の入力欄はアプリ内のモデル設定と同じです)。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
||||||
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
||||||
|
|
||||||
## 優先順位
|
## 優先順位
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
### 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 モデル
|
||||||
|
|||||||
+14
-15
@@ -36,19 +36,15 @@ 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. A local Ollama needs no key.
|
// No old config to migrate
|
||||||
if (!oldProvider || !oldModel) return null
|
if (!oldProvider || !oldApiKey || !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)
|
||||||
@@ -165,9 +161,11 @@ 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,
|
||||||
@@ -310,6 +308,13 @@ 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)
|
||||||
@@ -436,7 +441,9 @@ 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,
|
||||||
@@ -466,8 +473,6 @@ 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: "",
|
||||||
@@ -481,7 +486,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
selectedModelId: "",
|
selectedModelId: "",
|
||||||
vertexApiKey: "",
|
vertexApiKey: "",
|
||||||
providerId: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window === "undefined") return empty
|
if (typeof window === "undefined") return empty
|
||||||
@@ -506,7 +510,6 @@ export function getSelectedAIConfig(): {
|
|||||||
awsSessionToken: "",
|
awsSessionToken: "",
|
||||||
selectedModelId: "",
|
selectedModelId: "",
|
||||||
vertexApiKey: "",
|
vertexApiKey: "",
|
||||||
providerId: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,9 +566,5 @@ 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 ?? "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,9 @@
|
|||||||
"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": "Max output tokens",
|
||||||
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
|
"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",
|
"tabModels": "Models & keys",
|
||||||
|
"tabAppearance": "Appearance",
|
||||||
|
"tabAdvanced": "Advanced",
|
||||||
"tabAbout": "About",
|
"tabAbout": "About",
|
||||||
"themeLight": "Light",
|
"themeLight": "Light",
|
||||||
"themeDarkMode": "Dark",
|
"themeDarkMode": "Dark",
|
||||||
@@ -129,11 +131,7 @@
|
|||||||
"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",
|
||||||
@@ -211,13 +209,14 @@
|
|||||||
"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 glad you're enjoying it; the limit keeps the demo fair for everyone.",
|
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"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.",
|
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||||
"messageTokenSelfHosted": null,
|
"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",
|
||||||
@@ -352,16 +351,24 @@
|
|||||||
},
|
},
|
||||||
"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.",
|
||||||
"validationError": "The test failed",
|
"selectProvider": "Select a provider or add a new one",
|
||||||
"addModelFirst": "Add a model first.",
|
"configureMultiple": "Configure multiple AI providers and switch between them easily",
|
||||||
|
"apiKeyStored": "API keys are stored locally in your browser",
|
||||||
|
"test": "Test",
|
||||||
|
"validationError": "Validation failed",
|
||||||
|
"addModelFirst": "Add at least one model to validate",
|
||||||
|
"providers": "Providers",
|
||||||
|
"addProviderHint": "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.",
|
||||||
@@ -369,12 +376,13 @@
|
|||||||
"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}",
|
||||||
@@ -385,11 +393,17 @@
|
|||||||
"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 the provider name...",
|
"typeProviderName": "Type 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",
|
||||||
@@ -397,88 +411,22 @@
|
|||||||
"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 Default",
|
||||||
"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",
|
||||||
"myProviders": "My providers",
|
"noProvidersTitle": "Using the model set up on this server",
|
||||||
"inUse": "In use",
|
"noProvidersHint": "To use other models, add a provider with your own API key below the list on the left."
|
||||||
"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",
|
||||||
@@ -664,7 +612,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 > Drawing.",
|
"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.",
|
||||||
"noChanges": "No visible changes",
|
"noChanges": "No visible changes",
|
||||||
"listSeparator": ", ",
|
"listSeparator": ", ",
|
||||||
"shapesAddedOne": "added 1 shape",
|
"shapesAddedOne": "added 1 shape",
|
||||||
|
|||||||
@@ -120,7 +120,9 @@
|
|||||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||||
"maxOutputTokens": "最大出力トークン数",
|
"maxOutputTokens": "最大出力トークン数",
|
||||||
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||||
"tabModels": "モデル",
|
"tabModels": "モデルとキー",
|
||||||
|
"tabAppearance": "外観",
|
||||||
|
"tabAdvanced": "詳細",
|
||||||
"tabAbout": "このアプリについて",
|
"tabAbout": "このアプリについて",
|
||||||
"themeLight": "ライト",
|
"themeLight": "ライト",
|
||||||
"themeDarkMode": "ダーク",
|
"themeDarkMode": "ダーク",
|
||||||
@@ -129,11 +131,7 @@
|
|||||||
"proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください",
|
"proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください",
|
||||||
"proxyFailed": "プロキシ設定を適用できませんでした",
|
"proxyFailed": "プロキシ設定を適用できませんでした",
|
||||||
"appVersion": "バージョン",
|
"appVersion": "バージョン",
|
||||||
"projectWebsite": "プロジェクトのサイト",
|
"projectWebsite": "プロジェクトのサイト"
|
||||||
"tabGeneral": "一般",
|
|
||||||
"tabDrawing": "描画",
|
|
||||||
"sectionInterface": "表示",
|
|
||||||
"sectionAccess": "アクセス"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "図をエクスポート",
|
"title": "図をエクスポート",
|
||||||
@@ -211,13 +209,14 @@
|
|||||||
"tpmLimit": "レート制限",
|
"tpmLimit": "レート制限",
|
||||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||||
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "今日のトークン利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||||
"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": "スポンサー",
|
||||||
@@ -315,8 +314,16 @@
|
|||||||
"deleteProvider": "プロバイダーを削除",
|
"deleteProvider": "プロバイダーを削除",
|
||||||
"deleteModel": "モデルを削除",
|
"deleteModel": "モデルを削除",
|
||||||
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
||||||
"validationError": "テストに失敗しました",
|
"selectProvider": "プロバイダーを選択または追加してください",
|
||||||
"addModelFirst": "先にモデルを追加してください。",
|
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
|
||||||
|
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
|
||||||
|
"test": "テスト",
|
||||||
|
"validationError": "検証に失敗しました",
|
||||||
|
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
|
||||||
|
"providers": "プロバイダー",
|
||||||
|
"addProviderHint": "プロバイダーはまだありません",
|
||||||
|
"verified": "検証済み",
|
||||||
|
"configuration": "設定",
|
||||||
"displayName": "表示名",
|
"displayName": "表示名",
|
||||||
"bedrockApiKey": "Bedrock API キー",
|
"bedrockApiKey": "Bedrock API キー",
|
||||||
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
|
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
|
||||||
@@ -330,6 +337,7 @@
|
|||||||
"baseUrl": "ベース URL",
|
"baseUrl": "ベース URL",
|
||||||
"optional": "(オプション)",
|
"optional": "(オプション)",
|
||||||
"getApiKey": "API キーを取得",
|
"getApiKey": "API キーを取得",
|
||||||
|
"fetchModels": "プロバイダーからモデル一覧を取得",
|
||||||
"noTools": "ツール呼び出し非対応",
|
"noTools": "ツール呼び出し非対応",
|
||||||
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
|
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
|
||||||
"requestUrl": "リクエスト先: {url}",
|
"requestUrl": "リクエスト先: {url}",
|
||||||
@@ -340,11 +348,17 @@
|
|||||||
"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": "削除",
|
||||||
@@ -352,88 +366,22 @@
|
|||||||
"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": "値を非表示",
|
||||||
"myProviders": "マイプロバイダー",
|
"noProvidersTitle": "このサーバーで設定されたモデルを使用中",
|
||||||
"inUse": "使用中",
|
"noProvidersHint": "ほかのモデルを使うには、左のリストの下でプロバイダーを追加し、自分の API キーを入力してください。"
|
||||||
"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": "保存したプロンプトはまだありません",
|
||||||
@@ -664,7 +612,7 @@
|
|||||||
"failed": "今回はうまくいきませんでした",
|
"failed": "今回はうまくいきませんでした",
|
||||||
"failedRetrying": "うまくいかなかったので再試行しています",
|
"failedRetrying": "うまくいかなかったので再試行しています",
|
||||||
"stopped": "停止しました",
|
"stopped": "停止しました",
|
||||||
"truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 描画 で最大出力トークン数を増やしてください。",
|
"truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 詳細 で最大出力トークン数を増やしてください。",
|
||||||
"noChanges": "目に見える変更はありません",
|
"noChanges": "目に見える変更はありません",
|
||||||
"listSeparator": "、",
|
"listSeparator": "、",
|
||||||
"shapesAddedOne": "図形を 1 個追加",
|
"shapesAddedOne": "図形を 1 個追加",
|
||||||
|
|||||||
@@ -120,7 +120,9 @@
|
|||||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||||
"maxOutputTokens": "最大輸出 token 數",
|
"maxOutputTokens": "最大輸出 token 數",
|
||||||
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||||
"tabModels": "模型",
|
"tabModels": "模型與金鑰",
|
||||||
|
"tabAppearance": "外觀",
|
||||||
|
"tabAdvanced": "進階",
|
||||||
"tabAbout": "關於",
|
"tabAbout": "關於",
|
||||||
"themeLight": "淺色",
|
"themeLight": "淺色",
|
||||||
"themeDarkMode": "深色",
|
"themeDarkMode": "深色",
|
||||||
@@ -129,11 +131,7 @@
|
|||||||
"proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭",
|
"proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭",
|
||||||
"proxyFailed": "代理設定沒有生效",
|
"proxyFailed": "代理設定沒有生效",
|
||||||
"appVersion": "版本",
|
"appVersion": "版本",
|
||||||
"projectWebsite": "專案網站",
|
"projectWebsite": "專案網站"
|
||||||
"tabGeneral": "一般",
|
|
||||||
"tabDrawing": "繪圖",
|
|
||||||
"sectionInterface": "介面",
|
|
||||||
"sectionAccess": "存取"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "匯出圖紙",
|
"title": "匯出圖紙",
|
||||||
@@ -211,14 +209,15 @@
|
|||||||
"tpmLimit": "速率限制",
|
"tpmLimit": "速率限制",
|
||||||
"tpmMessage": "請求過多。請稍等片刻。",
|
"tpmMessage": "請求過多。請稍等片刻。",
|
||||||
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
|
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
|
||||||
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "看來您今天的 token 用量已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||||
"messageTokenSelfHosted": null,
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
||||||
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API Key 以繼續使用服務。",
|
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
|
||||||
"reset": "您的限制將在明天重設。感謝您的理解。",
|
"reset": "您的限制將在明天重設。感謝您的理解。",
|
||||||
"configModel": "使用自己的 API Key",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
|
||||||
|
"configModel": "使用您的金鑰",
|
||||||
"selfHost": "自行託管",
|
"selfHost": "自行託管",
|
||||||
"sponsor": "贊助",
|
"sponsor": "贊助",
|
||||||
"learnMore": "了解更多 →",
|
"learnMore": "了解更多 →",
|
||||||
@@ -352,29 +351,38 @@
|
|||||||
},
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"configure": "配置",
|
"configure": "配置",
|
||||||
"addProvider": "新增服務商",
|
"addProvider": "新增提供商",
|
||||||
"addModel": "新增模型",
|
"addModel": "新增模型",
|
||||||
"modelId": "模型 ID",
|
"modelId": "模型 ID",
|
||||||
"modelLabel": "顯示名稱",
|
"modelLabel": "顯示名稱",
|
||||||
"streaming": "啟用串流輸出",
|
"streaming": "啟用串流輸出",
|
||||||
"deleteProvider": "刪除服務商",
|
"deleteProvider": "刪除提供商",
|
||||||
"deleteModel": "刪除模型",
|
"deleteModel": "刪除模型",
|
||||||
"noModels": "尚未配置模型。新增模型以開始使用。",
|
"noModels": "尚未配置模型。新增模型以開始使用。",
|
||||||
"validationError": "測試沒通過",
|
"selectProvider": "選擇一個提供商或新增",
|
||||||
"addModelFirst": "先新增一個模型。",
|
"configureMultiple": "配置多個 AI 提供商並輕鬆切換",
|
||||||
|
"apiKeyStored": "API 金鑰儲存在您的瀏覽器本機",
|
||||||
|
"test": "測試",
|
||||||
|
"validationError": "驗證失敗",
|
||||||
|
"addModelFirst": "請先新增至少一個模型以進行驗證",
|
||||||
|
"providers": "提供商",
|
||||||
|
"addProviderHint": "還沒有服務商",
|
||||||
|
"verified": "已驗證",
|
||||||
|
"configuration": "配置",
|
||||||
"displayName": "顯示名稱",
|
"displayName": "顯示名稱",
|
||||||
"bedrockApiKey": "Bedrock API Key",
|
"bedrockApiKey": "Bedrock API 金鑰",
|
||||||
"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 Key",
|
"apiKey": "API 金鑰",
|
||||||
"enterApiKey": "輸入 API Key",
|
"enterApiKey": "輸入您的 API 金鑰",
|
||||||
"enterSecretKey": "輸入 Secret Access Key",
|
"enterSecretKey": "輸入您的 Secret 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}",
|
||||||
@@ -385,11 +393,17 @@
|
|||||||
"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": "刪除",
|
||||||
@@ -397,88 +411,22 @@
|
|||||||
"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": "隱藏值",
|
||||||
"myProviders": "我的服務商",
|
"noProvidersTitle": "正在使用伺服器上設定的模型",
|
||||||
"inUse": "正在使用",
|
"noProvidersHint": "想用其他模型,可以在左側清單下方新增服務商並填寫自己的 API Key。"
|
||||||
"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": "管理員設定",
|
||||||
@@ -664,7 +612,7 @@
|
|||||||
"failed": "這次沒有成功",
|
"failed": "這次沒有成功",
|
||||||
"failedRetrying": "這次沒有成功,正在重試",
|
"failedRetrying": "這次沒有成功,正在重試",
|
||||||
"stopped": "已停止",
|
"stopped": "已停止",
|
||||||
"truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 繪圖 中調高最大輸出長度。",
|
"truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 進階 中調高最大輸出長度。",
|
||||||
"noChanges": "沒有可見的變化",
|
"noChanges": "沒有可見的變化",
|
||||||
"listSeparator": ",",
|
"listSeparator": ",",
|
||||||
"shapesAddedOne": "新增 1 個圖形",
|
"shapesAddedOne": "新增 1 個圖形",
|
||||||
|
|||||||
@@ -120,7 +120,9 @@
|
|||||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||||
"maxOutputTokens": "最大输出 token 数",
|
"maxOutputTokens": "最大输出 token 数",
|
||||||
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||||
"tabModels": "模型",
|
"tabModels": "模型和密钥",
|
||||||
|
"tabAppearance": "外观",
|
||||||
|
"tabAdvanced": "高级",
|
||||||
"tabAbout": "关于",
|
"tabAbout": "关于",
|
||||||
"themeLight": "浅色",
|
"themeLight": "浅色",
|
||||||
"themeDarkMode": "深色",
|
"themeDarkMode": "深色",
|
||||||
@@ -129,11 +131,7 @@
|
|||||||
"proxyInvalid": "代理地址必须以 http:// 或 https:// 开头",
|
"proxyInvalid": "代理地址必须以 http:// 或 https:// 开头",
|
||||||
"proxyFailed": "代理设置没有生效",
|
"proxyFailed": "代理设置没有生效",
|
||||||
"appVersion": "版本",
|
"appVersion": "版本",
|
||||||
"projectWebsite": "项目网站",
|
"projectWebsite": "项目网站"
|
||||||
"tabGeneral": "通用",
|
|
||||||
"tabDrawing": "绘图",
|
|
||||||
"sectionInterface": "界面",
|
|
||||||
"sectionAccess": "访问"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "导出图纸",
|
"title": "导出图纸",
|
||||||
@@ -211,14 +209,15 @@
|
|||||||
"tpmLimit": "速率限制",
|
"tpmLimit": "速率限制",
|
||||||
"tpmMessage": "请求过多。请稍等片刻。",
|
"tpmMessage": "请求过多。请稍等片刻。",
|
||||||
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
||||||
"messageApi": "看来您今天的体验次数已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
|
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "看来您今天的 token 用量已达上限。很高兴您玩得开心;为了让大家都能公平使用,演示站对用量做了限制。",
|
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||||
"messageTokenSelfHosted": null,
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(点击设置图标)或自托管项目来绕过这些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||||
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API Key 以继续使用服务。",
|
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
|
||||||
"reset": "您的限制将在明天重置。感谢您的理解。",
|
"reset": "您的限制将在明天重置。感谢您的理解。",
|
||||||
"configModel": "使用自己的 API Key",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
||||||
|
"configModel": "使用您的密钥",
|
||||||
"selfHost": "自托管",
|
"selfHost": "自托管",
|
||||||
"sponsor": "赞助",
|
"sponsor": "赞助",
|
||||||
"learnMore": "了解更多 →",
|
"learnMore": "了解更多 →",
|
||||||
@@ -352,29 +351,38 @@
|
|||||||
},
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"configure": "配置",
|
"configure": "配置",
|
||||||
"addProvider": "添加服务商",
|
"addProvider": "添加提供商",
|
||||||
"addModel": "添加模型",
|
"addModel": "添加模型",
|
||||||
"modelId": "模型 ID",
|
"modelId": "模型 ID",
|
||||||
"modelLabel": "显示名称",
|
"modelLabel": "显示名称",
|
||||||
"streaming": "启用流式输出",
|
"streaming": "启用流式输出",
|
||||||
"deleteProvider": "删除服务商",
|
"deleteProvider": "删除提供商",
|
||||||
"deleteModel": "删除模型",
|
"deleteModel": "删除模型",
|
||||||
"noModels": "尚未配置模型。添加模型以开始使用。",
|
"noModels": "尚未配置模型。添加模型以开始使用。",
|
||||||
"validationError": "测试没通过",
|
"selectProvider": "选择一个提供商或添加新的",
|
||||||
"addModelFirst": "先添加一个模型。",
|
"configureMultiple": "配置多个 AI 提供商并轻松切换",
|
||||||
|
"apiKeyStored": "API 密钥存储在您的浏览器本地",
|
||||||
|
"test": "测试",
|
||||||
|
"validationError": "验证失败",
|
||||||
|
"addModelFirst": "请先添加至少一个模型以进行验证",
|
||||||
|
"providers": "提供商",
|
||||||
|
"addProviderHint": "还没有服务商",
|
||||||
|
"verified": "已验证",
|
||||||
|
"configuration": "配置",
|
||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"bedrockApiKey": "Bedrock API Key",
|
"bedrockApiKey": "Bedrock API 密钥",
|
||||||
"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 Key",
|
"apiKey": "API 密钥",
|
||||||
"enterApiKey": "输入 API Key",
|
"enterApiKey": "输入您的 API 密钥",
|
||||||
"enterSecretKey": "输入 Secret Access Key",
|
"enterSecretKey": "输入您的 Secret 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}",
|
||||||
@@ -385,11 +393,17 @@
|
|||||||
"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": "删除",
|
||||||
@@ -397,88 +411,22 @@
|
|||||||
"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": "隐藏值",
|
||||||
"myProviders": "我的服务商",
|
"noProvidersTitle": "正在使用服务器上配置的模型",
|
||||||
"inUse": "正在使用",
|
"noProvidersHint": "想用其他模型,可以在左侧列表下方添加服务商并填写自己的 API Key。"
|
||||||
"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": "管理员设置",
|
||||||
@@ -664,7 +612,7 @@
|
|||||||
"failed": "这次没有成功",
|
"failed": "这次没有成功",
|
||||||
"failedRetrying": "这次没有成功,正在重试",
|
"failedRetrying": "这次没有成功,正在重试",
|
||||||
"stopped": "已停止",
|
"stopped": "已停止",
|
||||||
"truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 绘图 里调高最大输出长度。",
|
"truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 高级 里调高最大输出长度。",
|
||||||
"noChanges": "没有可见的变化",
|
"noChanges": "没有可见的变化",
|
||||||
"listSeparator": ",",
|
"listSeparator": ",",
|
||||||
"shapesAddedOne": "新增 1 个图形",
|
"shapesAddedOne": "新增 1 个图形",
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
// 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 }
|
|
||||||
}
|
|
||||||
@@ -60,9 +60,7 @@ 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
|
||||||
// Older versions saved here whether untested models were listed in the
|
showUnvalidatedModels?: boolean // Show models that haven't been validated
|
||||||
// model picker; they always are now
|
|
||||||
showUnvalidatedModels?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flattened model for dropdown display
|
// Flattened model for dropdown display
|
||||||
|
|||||||
@@ -216,20 +216,6 @@ 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>`,
|
||||||
@@ -282,20 +268,6 @@ 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,52 +149,3 @@ 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"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
+3
-12
@@ -1,8 +1,6 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
|
|
||||||
export type SettingsTab = "models" | "general" | "drawing" | "about"
|
export type SettingsTab = "models" | "appearance" | "advanced" | "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 {
|
||||||
@@ -14,7 +12,6 @@ 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
|
||||||
@@ -24,12 +21,9 @@ interface UiState {
|
|||||||
setHeroDismissed: (dismissed: boolean) => void
|
setHeroDismissed: (dismissed: boolean) => void
|
||||||
togglePanel: () => void
|
togglePanel: () => void
|
||||||
setMobileView: (view: MobileView) => void
|
setMobileView: (view: MobileView) => void
|
||||||
/** Opens on the given tab (else the last one); the models tab on its
|
openSettings: (tab?: SettingsTab) => void
|
||||||
* 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
|
||||||
@@ -42,7 +36,6 @@ 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,
|
||||||
@@ -50,15 +43,13 @@ 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, modelsPage = "list") =>
|
openSettings: (tab) =>
|
||||||
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 }),
|
||||||
|
|||||||
@@ -32,13 +32,10 @@ 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()
|
||||||
// A server model answered: settings opens on the models tab's list
|
// Settings opens on its models tab
|
||||||
const dialog = page.getByRole("dialog", { name: "Settings" })
|
const dialog = page.getByRole("dialog", { name: "Settings" })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(
|
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
|
||||||
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 ({
|
||||||
@@ -102,11 +99,8 @@ 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()
|
||||||
// A server model answered: settings opens on the models tab's list
|
// Settings opens on its models tab
|
||||||
const dialog = page.getByRole("dialog", { name: "Settings" })
|
const dialog = page.getByRole("dialog", { name: "Settings" })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(
|
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
|
||||||
dialog.getByRole("heading", { name: "Models", exact: true }),
|
|
||||||
).toBeVisible()
|
|
||||||
await expect(dialog.getByText("Use your own API key")).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
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")
|
||||||
|
|||||||
@@ -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, general, drawing, about */
|
/** Open settings on one of its pages: models, appearance, advanced, about */
|
||||||
export async function openSettingsTab(
|
export async function openSettingsTab(
|
||||||
page: Page,
|
page: Page,
|
||||||
tab: "models" | "general" | "drawing" | "about",
|
tab: "models" | "appearance" | "advanced" | "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()
|
||||||
|
|||||||
@@ -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.getByTestId("provider-row-p1").click()
|
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||||
await dialog.getByTestId("test-models").click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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.getByText("Works · 1.2 s")).toBeVisible()
|
await expect(dialog.locator('[title="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.getByTestId("provider-row-p1").click()
|
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
dialog.getByRole("link", { name: "Get API key" }),
|
dialog.getByRole("link", { name: "Get API key" }),
|
||||||
@@ -86,8 +86,6 @@ 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()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, type Locator, type Page, test } from "@playwright/test"
|
import { expect, 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,13 +14,6 @@ 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(
|
||||||
@@ -33,14 +26,10 @@ 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 showProvider(dialog, "p1")
|
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||||
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,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -62,7 +51,9 @@ 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 fetchButton(dialog).click()
|
await dialog
|
||||||
|
.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(
|
||||||
@@ -88,7 +79,9 @@ test("the model picker scrolls with the mouse wheel", async ({ page }) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const dialog = await openQwenSettings(page)
|
const dialog = await openQwenSettings(page)
|
||||||
await fetchButton(dialog).click()
|
await dialog
|
||||||
|
.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()
|
||||||
@@ -106,10 +99,12 @@ test("shows a hint when the provider rejects the key", async ({ page }) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const dialog = await openQwenSettings(page)
|
const dialog = await openQwenSettings(page)
|
||||||
await fetchButton(dialog).click()
|
await dialog
|
||||||
|
.getByRole("button", { name: "Fetch models from the provider" })
|
||||||
|
.click()
|
||||||
await expect(
|
await expect(
|
||||||
dialog.getByText(
|
dialog.getByText(
|
||||||
"The provider rejected this API key. Check that the key above was copied in full. Incorrect API key",
|
"The provider rejected the API key. Check it in model settings. Incorrect API key",
|
||||||
),
|
),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -128,10 +123,12 @@ 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 fetchButton(dialog).click()
|
await dialog
|
||||||
|
.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 showProvider(dialog, "p2")
|
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||||
await expect(error).toHaveCount(0)
|
await expect(error).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -193,8 +190,10 @@ 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 fetchButton(dialog).click()
|
await dialog
|
||||||
await showProvider(dialog, "p2")
|
.getByRole("button", { name: "Fetch models from the provider" })
|
||||||
|
.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)
|
||||||
@@ -208,11 +207,13 @@ 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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||||
await showProvider(dialog, "p2")
|
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||||
release()
|
release()
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
await expect(dialog.getByText(/failed the test/)).toHaveCount(0)
|
await expect(dialog.getByText(/model\(s\) failed validation/)).toHaveCount(
|
||||||
|
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 ({
|
||||||
@@ -223,13 +224,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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.getByText("Works · 1.0 s")).toHaveCount(0)
|
await expect(dialog.locator('[title="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 ({
|
||||||
@@ -239,7 +240,9 @@ 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 fetchButton(dialog).click()
|
await dialog
|
||||||
|
.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()
|
||||||
@@ -253,7 +256,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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"
|
||||||
@@ -267,9 +270,11 @@ 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.getByText("Works · 1.0 s")).toHaveCount(0)
|
await expect(dialog.locator('[title="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(testButton(dialog)).toBeEnabled()
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: "Test", exact: true }),
|
||||||
|
).toBeEnabled()
|
||||||
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -284,17 +289,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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.getByText("Works · 1.0 s")).toHaveCount(1)
|
await expect(dialog.locator('[title="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 ({
|
||||||
@@ -311,19 +316,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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.getByText("Works · 9.0 s")).toHaveCount(0)
|
await expect(dialog.locator('[title="9.0 s"]')).toHaveCount(0)
|
||||||
releases[1]()
|
releases[1]()
|
||||||
await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(1)
|
await expect(dialog.locator('[title="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 ({
|
||||||
@@ -334,9 +339,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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 showProvider(dialog, "p2")
|
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||||
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) ?? "{}")
|
||||||
@@ -349,7 +354,7 @@ test("no spinner stays after another tab's change while elsewhere", async ({
|
|||||||
})
|
})
|
||||||
release()
|
release()
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
await showProvider(dialog, "p1")
|
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||||
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -368,7 +373,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
release()
|
release()
|
||||||
@@ -388,11 +393,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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-general"]').click()
|
await page.locator('[data-testid="settings-tab-advanced"]').click()
|
||||||
await page.locator('[data-testid="settings-tab-models"]').click()
|
await page.locator('[data-testid="settings-tab-models"]').click()
|
||||||
await showProvider(dialog, "p1")
|
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||||
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)
|
||||||
@@ -410,11 +415,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 testButton(dialog).click()
|
await dialog.getByRole("button", { name: "Test", exact: true }).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-general"]').click()
|
await page.locator('[data-testid="settings-tab-advanced"]').click()
|
||||||
await page.locator('[data-testid="settings-tab-models"]').click()
|
await page.locator('[data-testid="settings-tab-models"]').click()
|
||||||
await showProvider(dialog, "p1")
|
await dialog.getByText("Qwen (Alibaba)").first().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()
|
||||||
@@ -438,14 +443,9 @@ 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 showProvider(dialog, "p1")
|
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||||
await testButton(dialog).click()
|
await dialog.getByRole("button", { name: "测试", exact: true }).click()
|
||||||
// The visible line, not the screen reader's copy
|
await expect(dialog.getByText("1 个模型验证失败")).toBeVisible()
|
||||||
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,10 +471,11 @@ 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 showProvider(dialog, "p1")
|
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||||
// Nothing to test yet: the button says what is missing
|
await dialog.getByRole("button", { name: "测试", exact: true }).click()
|
||||||
await expect(testButton(dialog)).toBeDisabled()
|
await expect(
|
||||||
await expect(dialog.getByText("先添加一个模型。")).toBeVisible()
|
dialog.getByText("请先添加至少一个模型以进行验证"),
|
||||||
|
).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 ({
|
||||||
@@ -491,8 +492,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 showProvider(dialog, "p1")
|
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||||
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()
|
||||||
@@ -533,8 +534,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 showProvider(dialog, "b1")
|
await dialog.getByText("Amazon Bedrock").first().click()
|
||||||
const test = testButton(dialog)
|
const test = dialog.getByRole("button", { name: "Test", exact: true })
|
||||||
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()
|
||||||
|
|||||||
@@ -1,504 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
@@ -1,830 +0,0 @@
|
|||||||
// 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 ?? "")
|
|
||||||
})
|
|
||||||
@@ -18,14 +18,14 @@ test.describe("Settings", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("language selection is available", async ({ page }) => {
|
test("language selection is available", async ({ page }) => {
|
||||||
await openSettingsTab(page, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
|
|
||||||
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, "drawing")
|
await openSettingsTab(page, "advanced")
|
||||||
|
|
||||||
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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
|
|
||||||
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"]) {
|
||||||
|
|||||||
+15
-21
@@ -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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog")
|
.getByRole("dialog")
|
||||||
.getByRole("radio", { name: "Light" })
|
.getByRole("radio", { name: "Light" })
|
||||||
@@ -916,29 +916,23 @@ 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 the phone layout", async ({
|
test("settings put their sections on top in a narrow window", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Below 768 px the app shows the phone layout, with the canvas hidden
|
await page.setViewportSize({ width: 800, height: 800 })
|
||||||
await page.setViewportSize({ width: 720, height: 800 })
|
await openApp(page)
|
||||||
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 general = page.locator('[data-testid="settings-tab-general"]')
|
const appearance = page.locator(
|
||||||
|
'[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(general)).y),
|
Math.abs((await boxOf(models)).y - (await boxOf(appearance)).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 }) => {
|
||||||
@@ -1212,7 +1206,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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
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 })
|
||||||
@@ -1237,7 +1231,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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
await expect(page.locator("#language-select")).toBeDisabled()
|
await expect(page.locator("#language-select")).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1640,7 +1634,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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
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 })
|
||||||
@@ -2675,7 +2669,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, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
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 })
|
||||||
@@ -4714,7 +4708,7 @@ test.describe("Edge cases", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
await openApp(page)
|
await openApp(page)
|
||||||
await openSettingsTab(page, "general")
|
await openSettingsTab(page, "appearance")
|
||||||
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 })
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -260,45 +260,3 @@ 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([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
Reference in New Issue
Block a user