mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13371e8092 | ||
|
|
55a32af76a | ||
|
|
75ffc32a49 | ||
|
|
65625ba212 | ||
|
|
b2cadfa5bf | ||
|
|
717f7e9445 | ||
|
|
e1c083dae0 | ||
|
|
f811242568 | ||
|
|
e0a196dded | ||
|
|
841efcfea2 | ||
|
|
25a1059648 | ||
|
|
932fd03a7c | ||
|
|
b0e95a5ef4 | ||
|
|
31f4641178 | ||
|
|
2aef9d180c | ||
|
|
9c31481dd7 | ||
|
|
1ef9f3ea3d | ||
|
|
5b27d30194 | ||
|
|
2ba700e61c | ||
|
|
99ced88e5b | ||
|
|
60105ddb51 | ||
|
|
4c7b2cc0c5 | ||
|
|
07f3e3c2a3 | ||
|
|
8ab837679e | ||
|
|
cb066a4825 | ||
|
|
77be43d996 | ||
|
|
c774a39c33 | ||
|
|
3d1a020b35 |
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
@@ -4,14 +4,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
name: Publish MCP Server
|
||||
|
||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||
# (no token, no OTP). Triggers when packages/mcp-server or the web app code
|
||||
# bundled into the canvas shell (scripts/build-shell.mjs) changes on main;
|
||||
# skips silently if the package.json version is already on npm — so a
|
||||
# release is just "bump the version in a PR and merge".
|
||||
on:
|
||||
@@ -10,6 +11,16 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- "packages/mcp-server/**"
|
||||
# The canvas shell is bundled from these with the root's lockfile
|
||||
- "components/**"
|
||||
- "contexts/**"
|
||||
- "hooks/**"
|
||||
- "lib/**"
|
||||
- "stores/**"
|
||||
- "app/globals.css"
|
||||
- "package-lock.json"
|
||||
# The package's build scripts import the root's scripts/drawio-zip.mjs
|
||||
- "scripts/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -35,7 +46,9 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
packages/mcp-server/package-lock.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Trusted publishing requires npm >= 11.5.1
|
||||
@@ -54,6 +67,13 @@ jobs:
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# The canvas shell (scripts/build-shell.mjs) is bundled from the web
|
||||
# app's components with the root's esbuild, Tailwind and React
|
||||
- name: Install repository dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
working-directory: .
|
||||
run: npm ci
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm ci
|
||||
@@ -62,6 +82,9 @@ jobs:
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
# The build downloads the pinned draw.war from GitHub releases
|
||||
# (scripts/fetch-drawio.mjs) and bundles a trimmed draw.io into
|
||||
# dist/drawio; check-package confirms it is in the tarball
|
||||
- name: Build and check package contents
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm run build && npm run check-package
|
||||
|
||||
+101
-7
@@ -6,6 +6,13 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-and-unit:
|
||||
name: Lint & Unit Tests
|
||||
@@ -16,32 +23,119 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run lint
|
||||
run: npm run check
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test -- --run
|
||||
|
||||
# The MCP server package ships its own vitest because its DOM polyfill
|
||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||
# for the Next.js app. Install + run its tests separately so CI catches
|
||||
# multi-page mxfile regressions.
|
||||
# The MCP server package: its own vitest (its DOM polyfill, linkedom, needs
|
||||
# `environment: node`, while the root vitest uses jsdom for the Next.js
|
||||
# app), the npm package as it is built, the draw.io file list, and the
|
||||
# canvas shell in a browser.
|
||||
mcp-shell:
|
||||
name: MCP Server & Shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# HEAD^1 is the base branch for the version bump reminder below
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
packages/mcp-server/package-lock.json
|
||||
|
||||
# The shell is bundled from the web app's components with the root's
|
||||
# esbuild, Tailwind and React; Playwright comes from the root too
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install MCP server dependencies
|
||||
run: npm --prefix packages/mcp-server ci
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Install Playwright deps (cached)
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
# Tests run from src/, so check the built npm package separately
|
||||
# Tests run from src/, so check the built npm package separately. The
|
||||
# build downloads the pinned draw.war and bundles a trimmed draw.io.
|
||||
- name: Build MCP server and check package contents
|
||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||
|
||||
# The trimmed draw.io is cut from this list; the guard drives the full
|
||||
# copy (public/drawio) in Chromium and fails when the editor requests a
|
||||
# file the list does not have
|
||||
- name: Download the full draw.io
|
||||
run: node scripts/fetch-drawio.mjs
|
||||
|
||||
- name: Check the MCP draw.io file list
|
||||
run: npm --prefix packages/mcp-server run check-drawio
|
||||
|
||||
# The canvas shell in a browser, from the package as npm installs it:
|
||||
# the packed tarball goes into an empty directory, its dist/index.js is
|
||||
# started over stdio and headless Chromium opens the URL start_session
|
||||
# returns (packages/mcp-server/tests/e2e)
|
||||
- name: Run the MCP shell E2E tests on the packed tarball
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/mcp-pack" "$RUNNER_TEMP/mcp-install"
|
||||
npm pack ./packages/mcp-server --pack-destination "$RUNNER_TEMP/mcp-pack"
|
||||
npm --prefix "$RUNNER_TEMP/mcp-install" install "$RUNNER_TEMP"/mcp-pack/*.tgz
|
||||
MCP_SERVER_ENTRY="$RUNNER_TEMP/mcp-install/node_modules/@next-ai-drawio/mcp-server/dist/index.js" \
|
||||
npm --prefix packages/mcp-server run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
# The shell bundles the web app's canvas and the root's dependencies,
|
||||
# so a change under these paths (the ones publish-mcp.yml triggers on)
|
||||
# reaches users with the next MCP release, and publish-mcp.yml only
|
||||
# publishes a new version number
|
||||
- name: Remind to bump the MCP version when bundled code changed
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
changed=$(git diff --name-only HEAD^1 HEAD -- components contexts hooks lib stores app/globals.css package-lock.json scripts packages/mcp-server)
|
||||
base_version=$(git show HEAD^1:packages/mcp-server/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")
|
||||
head_version=$(node -p "require('./packages/mcp-server/package.json').version")
|
||||
if [ -n "$changed" ] && [ "$base_version" = "$head_version" ]; then
|
||||
echo "::warning::Code bundled into the MCP server changed without a version bump in packages/mcp-server/package.json (still ${head_version}). Bump it if the change should be published."
|
||||
fi
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-mcp-shell
|
||||
path: packages/mcp-server/test-results/
|
||||
retention-days: 7
|
||||
|
||||
e2e:
|
||||
# Six jobs at once, each with a sixth of the tests and one worker: the
|
||||
# same conditions per test, a fraction of the time
|
||||
@@ -57,7 +151,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -16,6 +16,8 @@ packages/*/dist
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
packages/*/playwright-report/
|
||||
packages/*/test-results/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -38,7 +38,7 @@ Available as a web app, a desktop app for Windows, macOS and Linux, and an MCP s
|
||||
|
||||
**Use**
|
||||
|
||||
- 24 model providers; enter your own API key in the browser and it stays on your machine
|
||||
- 25 model providers, including an OpenAI-compatible preset for any other URL; enter your own API key in the browser and it stays on your machine
|
||||
- Models that reason show their thinking
|
||||
- Dark mode; the interface is available in English, Simplified Chinese, Traditional Chinese and Japanese
|
||||
|
||||
@@ -92,6 +92,8 @@ Download the Windows, macOS or Linux installer from the [Releases page](https://
|
||||
|
||||
### In your AI agent (MCP)
|
||||
|
||||
https://github.com/user-attachments/assets/6f53a67b-ab21-4113-9fee-7fb32f9de678
|
||||
|
||||
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
|
||||
@@ -114,9 +116,12 @@ 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:
|
||||
|
||||
- The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||
- The preview is the same canvas as the web app: the AI's changes are outlined, one Ctrl+Z takes them back, and the AI can read the shapes you select
|
||||
- The draw.io editor ships inside the package, so after install the preview works offline (`DRAWIO_BASE_URL` points it at an external draw.io instead)
|
||||
- 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
|
||||
- Ask the AI to undo, and keep your own drawing rules in `~/.next-ai-drawio/instructions.md`
|
||||
|
||||
See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
|
||||
|
||||
@@ -146,7 +151,7 @@ Open [http://localhost:6002](http://localhost:6002).
|
||||
|
||||
### Models and providers
|
||||
|
||||
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).
|
||||
25 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, Atlas Cloud, and an OpenAI-compatible preset for any other URL (vLLM, LM Studio, a gateway). The environment variables and notes for each are in the [provider configuration guide](./docs/en/ai-providers.md).
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 - Next AI Draw.io",
|
||||
description:
|
||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||
}
|
||||
|
||||
export default function AboutCN() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
编辑器
|
||||
</Link>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
关于
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label="在GitHub上查看"
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="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">
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
使用自己的 API Key
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
您也可以使用自己的 API
|
||||
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
您的 Key
|
||||
仅保存在浏览器本地,不会被存储在服务器上。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
功能特性
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM驱动的图表创建</strong>
|
||||
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
||||
</li>
|
||||
<li>
|
||||
<strong>基于图像的图表复制</strong>
|
||||
:上传现有图表或图像,让AI自动复制和增强
|
||||
</li>
|
||||
<li>
|
||||
<strong>版本与撤销</strong>
|
||||
:AI
|
||||
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
|
||||
Ctrl+Z 也能撤回
|
||||
</li>
|
||||
<li>
|
||||
<strong>交互式聊天界面</strong>
|
||||
:与AI实时对话来完善您的图表
|
||||
</li>
|
||||
<li>
|
||||
<strong>AWS架构图支持</strong>
|
||||
:专门支持生成AWS架构图
|
||||
</li>
|
||||
<li>
|
||||
<strong>动画连接器</strong>
|
||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
示例
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
以下是一些示例提示词及其生成的图表:
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ResNet50模型架构动画
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50模型架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG技术图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
React和AWS认证流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="认证架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
敏捷Scrum流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="敏捷Scrum流程图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
开放式创新
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="开放式创新图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
工作原理
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>:用于前端框架和路由
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):用于流式AI响应和多提供商支持
|
||||
</li>
|
||||
<li>
|
||||
<strong>draw.io</strong>
|
||||
:内置在应用里的编辑器,应用可以直接控制它
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
多提供商支持
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>字节跳动豆包</li>
|
||||
<li>AWS Bedrock(默认)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI兼容API(通过{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
支持与联系
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
如果您觉得这个项目有用,请考虑{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
赞助
|
||||
</a>{" "}
|
||||
来帮助托管在线演示站点!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
如需支持或咨询,请在{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
GitHub仓库
|
||||
</a>{" "}
|
||||
上提交issue或联系:me[at]jiang.jp
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
打开编辑器
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io - 开源AI驱动的图表生成器
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "概要 - Next AI Draw.io",
|
||||
description:
|
||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||
keywords: [
|
||||
"AIダイアグラム",
|
||||
"draw.io",
|
||||
"AWSアーキテクチャ",
|
||||
"GCPダイアグラム",
|
||||
"Azureダイアグラム",
|
||||
"LLM",
|
||||
],
|
||||
}
|
||||
|
||||
export default function AboutJA() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
エディタ
|
||||
</Link>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
概要
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label="GitHubで見る"
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI搭載のダイアグラム作成ツール -
|
||||
チャット、描画、可視化
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="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">
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
自分のAPIキーを使用
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
機能
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM搭載のダイアグラム作成</strong>
|
||||
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
||||
</li>
|
||||
<li>
|
||||
<strong>画像ベースのダイアグラム複製</strong>
|
||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
</li>
|
||||
<li>
|
||||
<strong>バージョンと取り消し</strong>
|
||||
:AI
|
||||
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
|
||||
Ctrl+Z を押しても元に戻せます
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
インタラクティブなチャットインターフェース
|
||||
</strong>
|
||||
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
AWSアーキテクチャダイアグラムサポート
|
||||
</strong>
|
||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
例
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ResNet50モデルアーキテクチャアニメーション
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50モデルアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG技術ダイアグラム
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAGアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ReactとAWSによる認証
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="認証アーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
アジャイルスクラムプロセス
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="アジャイルスクラム図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
オープンイノベーション
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="オープンイノベーション図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
仕組み
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
本アプリケーションは以下の技術を使用しています:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>
|
||||
:フロントエンドフレームワークとルーティング
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>draw.io</strong>
|
||||
:アプリに同梱されたエディター。アプリから直接操作できます
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
マルチプロバイダーサポート
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>ByteDance Doubao</li>
|
||||
<li>AWS Bedrock(デフォルト)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||
経由)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注:<code>claude-sonnet-4-5</code>
|
||||
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
スポンサー
|
||||
</a>{" "}
|
||||
をご検討ください!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
サポートやお問い合わせについては、{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
GitHubリポジトリ
|
||||
</a>{" "}
|
||||
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
エディタを開く
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io -
|
||||
オープンソースAI搭載ダイアグラムジェネレーター
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+491
-194
@@ -1,23 +1,420 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import type { ReactNode } from "react"
|
||||
import { GitHubIcon } from "@/components/icons/github"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import type { Locale } from "@/lib/i18n/config"
|
||||
import { hasLocale } from "@/lib/i18n/dictionaries"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About - Next AI Draw.io",
|
||||
description:
|
||||
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||
keywords: [
|
||||
"AI diagram",
|
||||
"draw.io",
|
||||
"AWS architecture",
|
||||
"GCP diagram",
|
||||
"Azure diagram",
|
||||
"LLM",
|
||||
],
|
||||
type ExampleId = "resnet50" | "rag" | "auth" | "scrum" | "inno"
|
||||
|
||||
type AboutTexts = {
|
||||
title: string
|
||||
description: string
|
||||
keywords: string[]
|
||||
nav: { editor: string; about: string; github: string }
|
||||
tagline: string
|
||||
byok: { title: string; body: string; note: string }
|
||||
intro: string
|
||||
// Punctuation differs between Latin and CJK text
|
||||
colon: string
|
||||
parenOpen: string
|
||||
parenClose: string
|
||||
featuresHeading: string
|
||||
features: { title: string; text: string }[]
|
||||
examplesHeading: string
|
||||
examplesIntro: string
|
||||
examples: Record<ExampleId, { title: string; alt: string }>
|
||||
howHeading: string
|
||||
howIntro: string
|
||||
tech: { nextjs: string; aiSdk: string; drawio: string }
|
||||
howOutro: string
|
||||
providersHeading: string
|
||||
doubao: string
|
||||
bedrock: string
|
||||
// Text around the OPENAI_BASE_URL code span
|
||||
openai: { before: string; after: string }
|
||||
// Text around the claude-sonnet-4-5 code span
|
||||
modelNote: { before: string; after: string }
|
||||
supportHeading: string
|
||||
// Text around the sponsor link
|
||||
sponsor: { before: string; link: string; after: string }
|
||||
// Text around the GitHub repository link
|
||||
contact: { before: string; link: string; after: string }
|
||||
openEditor: string
|
||||
footer: string
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
const texts: Record<"en" | "zh" | "ja", AboutTexts> = {
|
||||
en: {
|
||||
title: "About - Next AI Draw.io",
|
||||
description:
|
||||
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||
keywords: [
|
||||
"AI diagram",
|
||||
"draw.io",
|
||||
"AWS architecture",
|
||||
"GCP diagram",
|
||||
"Azure diagram",
|
||||
"LLM",
|
||||
],
|
||||
nav: { editor: "Editor", about: "About", github: "View on GitHub" },
|
||||
tagline: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize",
|
||||
byok: {
|
||||
title: "Bring Your Own API Key",
|
||||
body: "You can also use your own API key with any supported provider. Click the Settings icon in the chat panel to configure your provider and API key.",
|
||||
note: "Your key is stored locally in your browser and is never stored on the server.",
|
||||
},
|
||||
intro: "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.",
|
||||
colon: ": ",
|
||||
parenOpen: " (",
|
||||
parenClose: ")",
|
||||
featuresHeading: "Features",
|
||||
features: [
|
||||
{
|
||||
title: "LLM-Powered Diagram Creation",
|
||||
text: "Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands",
|
||||
},
|
||||
{
|
||||
title: "Image-Based Diagram Replication",
|
||||
text: "Upload existing diagrams or images and have the AI replicate and enhance them automatically",
|
||||
},
|
||||
{
|
||||
title: "Versions and Undo",
|
||||
text: "Every AI change becomes a version in the chat that you can compare, restore or undo; Ctrl+Z on the canvas takes it back too",
|
||||
},
|
||||
{
|
||||
title: "Interactive Chat Interface",
|
||||
text: "Communicate with AI to refine your diagrams in real-time",
|
||||
},
|
||||
{
|
||||
title: "AWS Architecture Diagram Support",
|
||||
text: "Specialized support for generating AWS architecture diagrams",
|
||||
},
|
||||
{
|
||||
title: "Animated Connectors",
|
||||
text: "Create dynamic and animated connectors between diagram elements for better visualization",
|
||||
},
|
||||
],
|
||||
examplesHeading: "Examples",
|
||||
examplesIntro:
|
||||
"Here are some example prompts and their generated diagrams:",
|
||||
examples: {
|
||||
resnet50: {
|
||||
title: "Animated ResNet50 Model Architecture",
|
||||
alt: "Architecture diagram for ResNet50 model",
|
||||
},
|
||||
rag: {
|
||||
title: "RAG Technique Diagram",
|
||||
alt: "RAG Architecture Diagram",
|
||||
},
|
||||
auth: {
|
||||
title: "Authentication using React and AWS",
|
||||
alt: "Authentication Architecture Diagram",
|
||||
},
|
||||
scrum: { title: "Agile Scrum Process", alt: "Agile Scrum Diagram" },
|
||||
inno: { title: "Open Innovation", alt: "Open Innovation Diagram" },
|
||||
},
|
||||
howHeading: "How It Works",
|
||||
howIntro: "The application uses the following technologies:",
|
||||
tech: {
|
||||
nextjs: "For the frontend framework and routing",
|
||||
aiSdk: "For streaming AI responses and multi-provider support",
|
||||
drawio: "The editor, bundled with the app so it can be driven directly",
|
||||
},
|
||||
howOutro:
|
||||
"Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.",
|
||||
providersHeading: "Multi-Provider Support",
|
||||
doubao: "ByteDance Doubao",
|
||||
bedrock: "AWS Bedrock (default)",
|
||||
openai: { before: "OpenAI / OpenAI-compatible APIs (via ", after: ")" },
|
||||
modelNote: {
|
||||
before: "Note that ",
|
||||
after: " has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.",
|
||||
},
|
||||
supportHeading: "Support & Contact",
|
||||
sponsor: {
|
||||
before: "If you find this project useful, please consider",
|
||||
link: "sponsoring",
|
||||
after: "to help host the live demo site!",
|
||||
},
|
||||
contact: {
|
||||
before: "For support or inquiries, please open an issue on the",
|
||||
link: "GitHub repository",
|
||||
after: "or contact: me[at]jiang.jp",
|
||||
},
|
||||
openEditor: "Open Editor",
|
||||
footer: "Next AI Draw.io - Open Source AI-Powered Diagram Generator",
|
||||
},
|
||||
zh: {
|
||||
title: "关于 - Next AI Draw.io",
|
||||
description:
|
||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||
keywords: [
|
||||
"AI图表",
|
||||
"draw.io",
|
||||
"AWS架构",
|
||||
"GCP图表",
|
||||
"Azure图表",
|
||||
"LLM",
|
||||
],
|
||||
nav: { editor: "编辑器", about: "关于", github: "在GitHub上查看" },
|
||||
tagline: "AI驱动的图表创建工具 - 对话、绘制、可视化",
|
||||
byok: {
|
||||
title: "使用自己的 API Key",
|
||||
body: "您也可以使用自己的 API Key,支持多种服务商。点击聊天面板中的设置图标即可配置。",
|
||||
note: "您的 Key 仅保存在浏览器本地,不会被存储在服务器上。",
|
||||
},
|
||||
intro: "一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。",
|
||||
colon: ":",
|
||||
parenOpen: "(",
|
||||
parenClose: ")",
|
||||
featuresHeading: "功能特性",
|
||||
features: [
|
||||
{
|
||||
title: "LLM驱动的图表创建",
|
||||
text: "利用大语言模型通过自然语言命令直接创建和操作draw.io图表",
|
||||
},
|
||||
{
|
||||
title: "基于图像的图表复制",
|
||||
text: "上传现有图表或图像,让AI自动复制和增强",
|
||||
},
|
||||
{
|
||||
title: "版本与撤销",
|
||||
text: "AI 的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按 Ctrl+Z 也能撤回",
|
||||
},
|
||||
{
|
||||
title: "交互式聊天界面",
|
||||
text: "与AI实时对话来完善您的图表",
|
||||
},
|
||||
{ title: "AWS架构图支持", text: "专门支持生成AWS架构图" },
|
||||
{
|
||||
title: "动画连接器",
|
||||
text: "在图表元素之间创建动态动画连接器,实现更好的可视化效果",
|
||||
},
|
||||
],
|
||||
examplesHeading: "示例",
|
||||
examplesIntro: "以下是一些示例提示词及其生成的图表:",
|
||||
examples: {
|
||||
resnet50: {
|
||||
title: "ResNet50模型架构动画",
|
||||
alt: "ResNet50模型架构图",
|
||||
},
|
||||
rag: { title: "RAG技术图", alt: "RAG架构图" },
|
||||
auth: { title: "React和AWS认证流程", alt: "认证架构图" },
|
||||
scrum: { title: "敏捷Scrum流程", alt: "敏捷Scrum流程图" },
|
||||
inno: { title: "开放式创新", alt: "开放式创新图" },
|
||||
},
|
||||
howHeading: "工作原理",
|
||||
howIntro: "本应用使用以下技术:",
|
||||
tech: {
|
||||
nextjs: "用于前端框架和路由",
|
||||
aiSdk: "用于流式AI响应和多提供商支持",
|
||||
drawio: "内置在应用里的编辑器,应用可以直接控制它",
|
||||
},
|
||||
howOutro:
|
||||
"图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。",
|
||||
providersHeading: "多提供商支持",
|
||||
doubao: "字节跳动豆包",
|
||||
bedrock: "AWS Bedrock(默认)",
|
||||
openai: { before: "OpenAI / OpenAI兼容API(通过 ", after: ")" },
|
||||
modelNote: {
|
||||
before: "注意:",
|
||||
after: " 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。",
|
||||
},
|
||||
supportHeading: "支持与联系",
|
||||
sponsor: {
|
||||
before: "如果您觉得这个项目有用,请考虑",
|
||||
link: "赞助",
|
||||
after: "来帮助托管在线演示站点!",
|
||||
},
|
||||
contact: {
|
||||
before: "如需支持或咨询,请在",
|
||||
link: "GitHub仓库",
|
||||
after: "上提交issue或联系:me[at]jiang.jp",
|
||||
},
|
||||
openEditor: "打开编辑器",
|
||||
footer: "Next AI Draw.io - 开源AI驱动的图表生成器",
|
||||
},
|
||||
ja: {
|
||||
title: "概要 - Next AI Draw.io",
|
||||
description:
|
||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||
keywords: [
|
||||
"AIダイアグラム",
|
||||
"draw.io",
|
||||
"AWSアーキテクチャ",
|
||||
"GCPダイアグラム",
|
||||
"Azureダイアグラム",
|
||||
"LLM",
|
||||
],
|
||||
nav: { editor: "エディタ", about: "概要", github: "GitHubで見る" },
|
||||
tagline: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化",
|
||||
byok: {
|
||||
title: "自分のAPIキーを使用",
|
||||
body: "お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。",
|
||||
note: "キーはブラウザのローカルに保存され、サーバーには保存されません。",
|
||||
},
|
||||
intro: "AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。",
|
||||
colon: ":",
|
||||
parenOpen: "(",
|
||||
parenClose: ")",
|
||||
featuresHeading: "機能",
|
||||
features: [
|
||||
{
|
||||
title: "LLM搭載のダイアグラム作成",
|
||||
text: "大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作",
|
||||
},
|
||||
{
|
||||
title: "画像ベースのダイアグラム複製",
|
||||
text: "既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化",
|
||||
},
|
||||
{
|
||||
title: "バージョンと取り消し",
|
||||
text: "AI の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで Ctrl+Z を押しても元に戻せます",
|
||||
},
|
||||
{
|
||||
title: "インタラクティブなチャットインターフェース",
|
||||
text: "AIとリアルタイムでコミュニケーションしてダイアグラムを改善",
|
||||
},
|
||||
{
|
||||
title: "AWSアーキテクチャダイアグラムサポート",
|
||||
text: "AWSアーキテクチャダイアグラムの生成を専門的にサポート",
|
||||
},
|
||||
{
|
||||
title: "アニメーションコネクタ",
|
||||
text: "より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成",
|
||||
},
|
||||
],
|
||||
examplesHeading: "例",
|
||||
examplesIntro:
|
||||
"以下はいくつかのプロンプト例と生成されたダイアグラムです:",
|
||||
examples: {
|
||||
resnet50: {
|
||||
title: "ResNet50モデルアーキテクチャアニメーション",
|
||||
alt: "ResNet50モデルアーキテクチャ図",
|
||||
},
|
||||
rag: { title: "RAG技術ダイアグラム", alt: "RAGアーキテクチャ図" },
|
||||
auth: {
|
||||
title: "ReactとAWSによる認証",
|
||||
alt: "認証アーキテクチャ図",
|
||||
},
|
||||
scrum: {
|
||||
title: "アジャイルスクラムプロセス",
|
||||
alt: "アジャイルスクラム図",
|
||||
},
|
||||
inno: {
|
||||
title: "オープンイノベーション",
|
||||
alt: "オープンイノベーション図",
|
||||
},
|
||||
},
|
||||
howHeading: "仕組み",
|
||||
howIntro: "本アプリケーションは以下の技術を使用しています:",
|
||||
tech: {
|
||||
nextjs: "フロントエンドフレームワークとルーティング",
|
||||
aiSdk: "ストリーミングAIレスポンスとマルチプロバイダーサポート",
|
||||
drawio: "アプリに同梱されたエディター。アプリから直接操作できます",
|
||||
},
|
||||
howOutro:
|
||||
"ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。",
|
||||
providersHeading: "マルチプロバイダーサポート",
|
||||
doubao: "ByteDance Doubao",
|
||||
bedrock: "AWS Bedrock(デフォルト)",
|
||||
openai: { before: "OpenAI / OpenAI互換API(", after: "経由)" },
|
||||
modelNote: {
|
||||
before: "注:",
|
||||
after: "はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。",
|
||||
},
|
||||
supportHeading: "サポート&お問い合わせ",
|
||||
sponsor: {
|
||||
before: "このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために",
|
||||
link: "スポンサー",
|
||||
after: "をご検討ください!",
|
||||
},
|
||||
contact: {
|
||||
before: "サポートやお問い合わせについては、",
|
||||
link: "GitHubリポジトリ",
|
||||
after: "でissueを開くか、ご連絡ください:me[at]jiang.jp",
|
||||
},
|
||||
openEditor: "エディタを開く",
|
||||
footer: "Next AI Draw.io - オープンソースAI搭載ダイアグラムジェネレーター",
|
||||
},
|
||||
}
|
||||
|
||||
// The example prompts stay in English in every language
|
||||
const examples: { id: ExampleId; src: string; prompt: ReactNode }[] = [
|
||||
{
|
||||
id: "resnet50",
|
||||
src: "/resnet50.svg",
|
||||
prompt: (
|
||||
<>
|
||||
Give me an <strong>animated</strong> architecture diagram of the
|
||||
ResNet50 model.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rag",
|
||||
src: "/rag_prod.svg",
|
||||
prompt: (
|
||||
<>
|
||||
Generate a RAG architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use connected diagram for
|
||||
data ingestion
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
src: "/auth.svg",
|
||||
prompt: (
|
||||
<>
|
||||
Generate authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless architecture.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scrum",
|
||||
src: "/agile_scrum.svg",
|
||||
prompt: (
|
||||
<>
|
||||
Generate agile scrum workflow diagram for software development
|
||||
team.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "inno",
|
||||
src: "/inno.svg",
|
||||
prompt: (
|
||||
<>
|
||||
Create visualization of Henry Chesbrough's Open Innovation
|
||||
model.
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Traditional Chinese has no About text of its own and shows the Simplified one
|
||||
function textsFor(rawLang: string): AboutTexts {
|
||||
const lang: Locale = hasLocale(rawLang) ? rawLang : "en"
|
||||
return texts[lang === "zh-Hant" ? "zh" : lang]
|
||||
}
|
||||
|
||||
type Props = { params: Promise<{ lang: string }> }
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { lang } = await params
|
||||
const t = textsFor(lang)
|
||||
return { title: t.title, description: t.description, keywords: t.keywords }
|
||||
}
|
||||
|
||||
export default async function About({ params }: Props) {
|
||||
const { lang } = await params
|
||||
const t = textsFor(lang)
|
||||
const [hero, ...grid] = examples
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
@@ -25,32 +422,32 @@ export default function About() {
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
href={`/${lang}`}
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
href={`/${lang}`}
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Editor
|
||||
{t.nav.editor}
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
href={`/${lang}/about`}
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
About
|
||||
{t.nav.about}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label="View on GitHub"
|
||||
aria-label={t.nav.github}
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
<GitHubIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -66,8 +463,7 @@ export default function About() {
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||
Visualize
|
||||
{t.tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -77,92 +473,53 @@ export default function About() {
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
Bring Your Own API Key
|
||||
{t.byok.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
You can also use your own API key with any
|
||||
supported provider. Click the Settings icon
|
||||
in the chat panel to configure your provider
|
||||
and API key.
|
||||
{t.byok.body}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
Your key is stored locally in your browser
|
||||
and is never stored on the server.
|
||||
{t.byok.note}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-gray-700">{t.intro}</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Features
|
||||
{t.featuresHeading}
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM-Powered Diagram Creation</strong>:
|
||||
Leverage Large Language Models to create and
|
||||
manipulate draw.io diagrams directly through natural
|
||||
language commands
|
||||
</li>
|
||||
<li>
|
||||
<strong>Image-Based Diagram Replication</strong>:
|
||||
Upload existing diagrams or images and have the AI
|
||||
replicate and enhance them automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Versions and Undo</strong>: Every AI change
|
||||
becomes a version in the chat that you can compare,
|
||||
restore or undo; Ctrl+Z on the canvas takes it back
|
||||
too
|
||||
</li>
|
||||
<li>
|
||||
<strong>Interactive Chat Interface</strong>:
|
||||
Communicate with AI to refine your diagrams in
|
||||
real-time
|
||||
</li>
|
||||
<li>
|
||||
<strong>AWS Architecture Diagram Support</strong>:
|
||||
Specialized support for generating AWS architecture
|
||||
diagrams
|
||||
</li>
|
||||
<li>
|
||||
<strong>Animated Connectors</strong>: Create dynamic
|
||||
and animated connectors between diagram elements for
|
||||
better visualization
|
||||
</li>
|
||||
{t.features.map((feature) => (
|
||||
<li key={feature.title}>
|
||||
<strong>{feature.title}</strong>
|
||||
{t.colon}
|
||||
{feature.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Examples
|
||||
{t.examplesHeading}
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Here are some example prompts and their generated
|
||||
diagrams:
|
||||
</p>
|
||||
<p className="text-gray-700 mb-6">{t.examplesIntro}</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
{/* First example on its own */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Animated ResNet50 Model Architecture
|
||||
{t.examples[hero.id].title}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
<strong>Prompt:</strong> {hero.prompt}
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="Architecture diagram for ResNet50 model"
|
||||
src={hero.src}
|
||||
alt={t.examples[hero.id].alt}
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
@@ -172,125 +529,67 @@ export default function About() {
|
||||
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG Technique Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
{grid.map((example) => (
|
||||
<div key={example.id} className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{t.examples[example.id].title}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong>{" "}
|
||||
{example.prompt}
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src={example.src}
|
||||
alt={t.examples[example.id].alt}
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Authentication using React and AWS
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="Authentication Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Agile Scrum Process
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="Agile Scrum Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Open Innovation
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="Open Innovation Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
How It Works
|
||||
{t.howHeading}
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
The application uses the following technologies:
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4">{t.howIntro}</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>: For the frontend framework
|
||||
and routing
|
||||
<strong>Next.js</strong>
|
||||
{t.colon}
|
||||
{t.tech.nextjs}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>): For streaming AI responses
|
||||
and multi-provider support
|
||||
<strong>Vercel AI SDK</strong>
|
||||
{t.parenOpen}
|
||||
<code>ai</code> + <code>@ai-sdk/*</code>
|
||||
{t.parenClose}
|
||||
{t.colon}
|
||||
{t.tech.aiSdk}
|
||||
</li>
|
||||
<li>
|
||||
<strong>draw.io</strong>: The editor, bundled with
|
||||
the app so it can be driven directly
|
||||
<strong>draw.io</strong>
|
||||
{t.colon}
|
||||
{t.tech.drawio}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Diagrams are represented as XML that can be rendered in
|
||||
draw.io. The AI processes your commands and generates or
|
||||
modifies this XML accordingly.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">{t.howOutro}</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Multi-Provider Support
|
||||
{t.providersHeading}
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>ByteDance Doubao</li>
|
||||
<li>AWS Bedrock (default)</li>
|
||||
<li>{t.doubao}</li>
|
||||
<li>{t.bedrock}</li>
|
||||
<li>
|
||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
{t.openai.before}
|
||||
<code>OPENAI_BASE_URL</code>
|
||||
{t.openai.after}
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
@@ -303,48 +602,47 @@ export default function About() {
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||
draw.io diagrams with AWS logos, so if you want to
|
||||
create AWS architecture diagrams, this is the best
|
||||
choice.
|
||||
{t.modelNote.before}
|
||||
<code>claude-sonnet-4-5</code>
|
||||
{t.modelNote.after}
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Support & Contact
|
||||
{t.supportHeading}
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
If you find this project useful, please consider{" "}
|
||||
{t.sponsor.before}{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
sponsoring
|
||||
{t.sponsor.link}
|
||||
</a>{" "}
|
||||
to help host the live demo site!
|
||||
{t.sponsor.after}
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
For support or inquiries, please open an issue on the{" "}
|
||||
{t.contact.before}{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
{t.contact.link}
|
||||
</a>{" "}
|
||||
or contact: me[at]jiang.jp
|
||||
{t.contact.after}
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
href={`/${lang}`}
|
||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Open Editor
|
||||
{t.openEditor}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
@@ -354,8 +652,7 @@ export default function About() {
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io - Open Source AI-Powered Diagram
|
||||
Generator
|
||||
{t.footer}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MaskedSecret as SecretValue } from "@/lib/admin/auth"
|
||||
import type { AdminProviderInput as AdminProvider } from "@/lib/admin/providers"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
@@ -5,7 +7,10 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────
|
||||
|
||||
export type SecretValue = { isSet: true; hint: string }
|
||||
// The same shapes the admin API validates and returns. Secret fields of a
|
||||
// provider hold either a masked marker (unchanged) or a plaintext string
|
||||
// (new value).
|
||||
export type { AdminProvider, SecretValue }
|
||||
|
||||
export function isSecretValue(v: unknown): v is SecretValue {
|
||||
return typeof v === "object" && v !== null && "isSet" in v
|
||||
@@ -24,22 +29,6 @@ export function savedTextOf(state: SettingState | undefined): string {
|
||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||
}
|
||||
|
||||
// Admin provider in client state. Secret fields hold either a masked
|
||||
// marker (unchanged) or a plaintext string (new value).
|
||||
export interface AdminProvider {
|
||||
id: string
|
||||
provider: ProviderName
|
||||
name?: string
|
||||
apiKey?: string | SecretValue
|
||||
baseUrl?: string
|
||||
awsAccessKeyId?: string | SecretValue
|
||||
awsSecretAccessKey?: string | SecretValue
|
||||
awsRegion?: string
|
||||
vertexApiKey?: string | SecretValue
|
||||
models: string[]
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||
export interface EnvProvider {
|
||||
name: string
|
||||
|
||||
@@ -33,14 +33,9 @@ import {
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
generateId,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
} from "@/lib/types/model-config"
|
||||
import { generateId } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
@@ -72,8 +67,10 @@ function ProviderDetail({
|
||||
Record<string, { ok: boolean; message: string }>
|
||||
>({})
|
||||
|
||||
const info = PROVIDER_INFO[provider.provider]
|
||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||
const info = PROVIDERS[provider.provider]
|
||||
// Only providers that cannot list their models have a list here; for
|
||||
// the others the admin types the model id
|
||||
const suggestions = (info.models ?? []).filter(
|
||||
(m) => !provider.models.includes(m),
|
||||
)
|
||||
|
||||
@@ -500,7 +497,7 @@ export function ModelsSection({
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name || PROVIDER_INFO[p.provider].label}
|
||||
{p.name || PROVIDERS[p.provider].label}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
@@ -526,13 +523,13 @@ export function ModelsSection({
|
||||
{dict.modelConfig.addProvider}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||
{(Object.keys(PROVIDERS) as ProviderName[]).map(
|
||||
(p) => {
|
||||
// Global-credential providers already in
|
||||
// the env config can't be added here —
|
||||
// panel credentials would override theirs
|
||||
const envBlocked =
|
||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||
!!PROVIDERS[p].fixedCredentials &&
|
||||
envProviders.some(
|
||||
(e) => e.provider === p,
|
||||
)
|
||||
@@ -544,7 +541,7 @@ export function ModelsSection({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo provider={p} />
|
||||
{PROVIDER_INFO[p].label}
|
||||
{PROVIDERS[p].label}
|
||||
{envBlocked && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
|
||||
@@ -3,13 +3,6 @@ import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||
@@ -206,26 +199,6 @@ export function SettingField({
|
||||
)
|
||||
break
|
||||
}
|
||||
case "enum":
|
||||
control = (
|
||||
<Select
|
||||
value={currentValue || undefined}
|
||||
disabled={disabled}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||
<SelectValue placeholder={dict.admin.notSet} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{def.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
break
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="w-full max-w-md">
|
||||
|
||||
+17
-17
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next"
|
||||
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
|
||||
import { notFound } from "next/navigation"
|
||||
import Script from "next/script"
|
||||
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||
import { AppToaster } from "@/components/workspace/app-toaster"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
@@ -40,6 +41,13 @@ export async function generateStaticParams() {
|
||||
return i18n.locales.map((locale) => ({ lang: locale }))
|
||||
}
|
||||
|
||||
const OG_LOCALE: Record<Locale, string> = {
|
||||
en: "en_US",
|
||||
zh: "zh_CN",
|
||||
ja: "ja_JP",
|
||||
"zh-Hant": "zh_HK",
|
||||
}
|
||||
|
||||
// Generate metadata per locale
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -47,22 +55,19 @@ export async function generateMetadata({
|
||||
params: Promise<{ lang: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { lang: rawLang } = await params
|
||||
const lang = (
|
||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
||||
) as Locale
|
||||
const lang: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
|
||||
|
||||
// Default to English metadata
|
||||
const titles: Record<Locale, string> = {
|
||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||
zh: "Next AI Draw.io - AI 驱动的图表生成器",
|
||||
ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
|
||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
||||
}
|
||||
|
||||
const descriptions: Record<Locale, string> = {
|
||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
||||
zh: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
|
||||
ja: "AI で AWS アーキテクチャ図、フローチャート、技術図を作成。draw.io と AI アシスタントを統合した無料オンラインツールで、プロ品質の図を簡単に作成できます。",
|
||||
"zh-Hant":
|
||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||
}
|
||||
@@ -91,14 +96,7 @@ export async function generateMetadata({
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
locale:
|
||||
lang === "zh"
|
||||
? "zh_CN"
|
||||
: lang === "zh-Hant"
|
||||
? "zh_HK"
|
||||
: lang === "ja"
|
||||
? "ja_JP"
|
||||
: "en_US",
|
||||
locale: OG_LOCALE[lang],
|
||||
images: [
|
||||
{
|
||||
url: "/architecture.png",
|
||||
@@ -184,7 +182,9 @@ export default async function RootLayout({
|
||||
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
<DictionaryProvider dictionary={dictionary}>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
<LocaleProvider locale={validLang}>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
</LocaleProvider>
|
||||
</DictionaryProvider>
|
||||
<AppToaster />
|
||||
</body>
|
||||
|
||||
@@ -53,10 +53,6 @@ function validateValue(def: SettingDef, value: string): string | null {
|
||||
return value === "true" || value === "false"
|
||||
? null
|
||||
: 'Must be "true" or "false"'
|
||||
case "enum":
|
||||
return def.options?.includes(value)
|
||||
? null
|
||||
: `Must be one of: ${def.options?.join(", ")}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProviderSchema,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
mergeSecrets,
|
||||
} from "@/lib/admin/providers"
|
||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||
import { testModelConnection } from "@/lib/test-model-connection"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
|
||||
sameEndpoint && stored ? [stored] : [],
|
||||
)
|
||||
|
||||
// Without a URL of its own, chat sends the entry's key to the server's
|
||||
// <P>_BASE_URL: test that endpoint, not another one. It is the server's
|
||||
// own, which chat uses without the checks for a URL a user typed.
|
||||
// The admin's own request supplies the EdgeOne endpoint, cookies and
|
||||
// access code, as a chat request would.
|
||||
const serverUrl = globalBaseUrl(resolved.provider)
|
||||
return validateModel(
|
||||
new Request(new URL("/api/validate-model", req.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Checked again there, in place of an access code
|
||||
"x-admin-password": req.headers.get("x-admin-password") || "",
|
||||
// The EdgeOne function checks the access code and Pages
|
||||
// cookies, and its URL is built from the page's origin
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
cookie: req.headers.get("cookie") || "",
|
||||
...(req.headers.get("origin") && {
|
||||
origin: req.headers.get("origin") as string,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
// Without a URL of its own, chat sends the entry's key to
|
||||
// the server's <P>_BASE_URL: test that endpoint, not
|
||||
// another one. It is the server's own, which chat uses
|
||||
// without the checks for a URL a user typed.
|
||||
baseUrl: resolved.baseUrl || serverUrl,
|
||||
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
awsRegion: resolved.awsRegion,
|
||||
vertexApiKey: resolved.vertexApiKey,
|
||||
}),
|
||||
}),
|
||||
return testModelConnection(
|
||||
req,
|
||||
{
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl || serverUrl,
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
awsRegion: resolved.awsRegion,
|
||||
vertexApiKey: resolved.vertexApiKey,
|
||||
},
|
||||
!resolved.baseUrl && !!serverUrl,
|
||||
)
|
||||
}
|
||||
|
||||
+95
-333
@@ -1,34 +1,33 @@
|
||||
import {
|
||||
APICallError,
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
InvalidToolInputError,
|
||||
isFileUIPart,
|
||||
isTextUIPart,
|
||||
pruneMessages,
|
||||
safeValidateUIMessages,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import {
|
||||
CACHE_POINT,
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
getServerProvider,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsPromptCaching,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
dropInvalidToolCalls,
|
||||
fixToolInputJson,
|
||||
formatSelectionContext,
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
} from "@/lib/chat-helpers"
|
||||
import type { ChatUIMessage } from "@/lib/chat-message"
|
||||
import { chatTools } from "@/lib/chat-tools"
|
||||
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
resolveMaxOutputTokens,
|
||||
withOutputTokenLimitFallback,
|
||||
} from "@/lib/output-token-limit"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import {
|
||||
type FlattenedServerModel,
|
||||
findServerModelById,
|
||||
@@ -56,50 +56,11 @@ import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import {
|
||||
getShapeLibrary,
|
||||
SHAPE_LIBRARY_LIST,
|
||||
} from "@/packages/mcp-server/src/shape-library.ts"
|
||||
import {
|
||||
STYLE_CLASS_EXAMPLE,
|
||||
SWIMLANE_EXAMPLE,
|
||||
} from "@/packages/mcp-server/src/xml-examples.ts"
|
||||
|
||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
const toolCallId = `cached-${Date.now()}`
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.write({ type: "start" })
|
||||
writer.write({
|
||||
type: "tool-input-start",
|
||||
toolCallId,
|
||||
toolName: "display_diagram",
|
||||
})
|
||||
writer.write({
|
||||
type: "tool-input-delta",
|
||||
toolCallId,
|
||||
inputTextDelta: xml,
|
||||
})
|
||||
writer.write({
|
||||
type: "tool-input-available",
|
||||
toolCallId,
|
||||
toolName: "display_diagram",
|
||||
input: { xml },
|
||||
})
|
||||
writer.write({ type: "finish" })
|
||||
},
|
||||
})
|
||||
|
||||
return createUIMessageStreamResponse({ stream })
|
||||
}
|
||||
|
||||
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||
const modelStreamResponses = new WeakSet<Response>()
|
||||
// A Request's signal follows the client's disconnect only while the Request
|
||||
@@ -111,6 +72,21 @@ const requestOfResponse = new WeakMap<Response, Request>()
|
||||
// Inner handler function
|
||||
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
||||
|
||||
// Message structure for DEBUG_LLM_PAYLOAD logs: roles, part types, tool names
|
||||
// and input keys, without the diagram XML or image data
|
||||
const describeMessages = (msgs: any[]) =>
|
||||
msgs.map((m) => ({
|
||||
role: m.role,
|
||||
parts: (m.parts ?? m.content ?? []).map(
|
||||
(p: any) =>
|
||||
p.type +
|
||||
(p.toolName ? `:${p.toolName}` : "") +
|
||||
(p.input && typeof p.input === "object"
|
||||
? `(${Object.keys(p.input).join(",")})`
|
||||
: ""),
|
||||
),
|
||||
}))
|
||||
|
||||
async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
@@ -119,7 +95,19 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
if (accessDenied) return accessDenied
|
||||
|
||||
const body = await req.json()
|
||||
const { messages, xml, previousXml, sessionId } = body
|
||||
// The messages in the SDK's UI shape. The route writes its own system
|
||||
// messages, so none may come from the client.
|
||||
const validated = await safeValidateUIMessages<ChatUIMessage>({
|
||||
messages: body.messages,
|
||||
})
|
||||
if (!validated.success) {
|
||||
return Response.json(
|
||||
{ error: validated.error.message },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const messages = validated.data.filter((m) => m.role !== "system")
|
||||
const { xml, previousXml, sessionId } = body
|
||||
const customSystemMessage =
|
||||
typeof body.customSystemMessage === "string"
|
||||
? body.customSystemMessage.slice(0, 5000)
|
||||
@@ -138,9 +126,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((m: any) => m.role === "user")
|
||||
const userInputText =
|
||||
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
.find((m) => m.role === "user")
|
||||
const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || ""
|
||||
|
||||
// Update Langfuse trace with input, session, and user
|
||||
setTraceInput({
|
||||
@@ -156,23 +143,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
}
|
||||
// === FILE VALIDATION END ===
|
||||
|
||||
// === CACHE CHECK START ===
|
||||
const isFirstMessage = messages.length === 1
|
||||
const isEmptyDiagram = !xml || !hasCells(xml)
|
||||
|
||||
if (isFirstMessage && isEmptyDiagram) {
|
||||
const lastMessage = messages[0]
|
||||
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
|
||||
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
|
||||
|
||||
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
||||
|
||||
if (cached) {
|
||||
return createCachedStreamResponse(cached.xml)
|
||||
}
|
||||
}
|
||||
// === CACHE CHECK END ===
|
||||
|
||||
// Read client AI provider overrides from headers
|
||||
const provider = req.headers.get("x-ai-provider")
|
||||
let baseUrl = req.headers.get("x-ai-base-url")
|
||||
@@ -203,14 +173,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// A server model's provider comes from its config: for one set up in
|
||||
// the admin panel the header holds the provider name's slug. Without
|
||||
// either, the server's own AI_PROVIDER.
|
||||
const isEdgeOne =
|
||||
(serverModelConfig.provider || provider || getServerProvider()) ===
|
||||
"edgeone"
|
||||
|
||||
// EdgeOne is this deployment's own function, whatever URL the request
|
||||
// names: another host would get the user's EdgeOne cookies, and the
|
||||
// quota counts it. Absolute, as the SDK needs.
|
||||
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
|
||||
const requestedProvider =
|
||||
serverModelConfig.provider || provider || getServerProvider()
|
||||
// A provider without credentials (EdgeOne) is this deployment's own
|
||||
// function, whatever URL the request names: another host would get the
|
||||
// user's EdgeOne cookies, and the quota counts it. Absolute, as the SDK
|
||||
// needs.
|
||||
const onSiteFunction =
|
||||
!!requestedProvider &&
|
||||
Object.hasOwn(PROVIDERS, requestedProvider) &&
|
||||
PROVIDERS[requestedProvider as ProviderName].credential === "none"
|
||||
if (onSiteFunction) baseUrl = edgeOneEndpoint(req)
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
@@ -225,12 +198,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
const clientOverrides = {
|
||||
// Server model provider takes precedence over client header; EdgeOne
|
||||
// named only in AI_PROVIDER is named here, for its own base URL
|
||||
// Server model provider takes precedence over client header; the
|
||||
// site's function named only in AI_PROVIDER is named here, for its
|
||||
// own base URL
|
||||
provider:
|
||||
serverModelConfig.provider ||
|
||||
provider ||
|
||||
(isEdgeOne ? "edgeone" : null),
|
||||
(onSiteFunction ? requestedProvider : null),
|
||||
baseUrl,
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
// A server model runs the model it was configured with, whatever the header says
|
||||
@@ -246,7 +220,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||
// which the EdgeOne function checks too
|
||||
...(isEdgeOne && {
|
||||
...(onSiteFunction && {
|
||||
headers: {
|
||||
...(cookieHeader && { cookie: cookieHeader }),
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
@@ -348,16 +322,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
|
||||
)
|
||||
|
||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||
// System prompt, with the model name filled in and the style section chosen
|
||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||
const finalSystemMessage = customSystemMessage
|
||||
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
||||
: systemMessage
|
||||
|
||||
// Extract file parts (images) from the last user message
|
||||
const fileParts =
|
||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||
[]
|
||||
const fileParts = lastUserMessage?.parts.filter(isFileUIPart) ?? []
|
||||
|
||||
// Note: we used to pre-emptively reject images for models we guessed were
|
||||
// text-only (by name matching). That heuristic misfired on newer models
|
||||
@@ -370,98 +342,37 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
${userInputText}
|
||||
"""`
|
||||
|
||||
// Convert UIMessages to ModelMessages and add system message. A tool
|
||||
// call that never got its result (the user stopped while it ran) is
|
||||
// left out: the SDK would refuse this and every later request of the
|
||||
// chat (MissingToolResultsError)
|
||||
const modelMessages = await convertToModelMessages(messages, {
|
||||
ignoreIncompleteToolCalls: true,
|
||||
// Convert UIMessages to ModelMessages. A tool call that never got its
|
||||
// result (the user stopped while it ran) is left out: the SDK would
|
||||
// refuse this and every later request of the chat
|
||||
// (MissingToolResultsError). Messages left without content (a notice
|
||||
// the chat shows is a data part, which the model never sees) are
|
||||
// dropped: Bedrock rejects empty content.
|
||||
const modelMessages = pruneMessages({
|
||||
messages: await convertToModelMessages(messages, {
|
||||
ignoreIncompleteToolCalls: true,
|
||||
}),
|
||||
})
|
||||
|
||||
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
||||
if (DEBUG_LLM_PAYLOAD) {
|
||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||
messages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] Message ${idx} role:`,
|
||||
msg.role,
|
||||
"parts count:",
|
||||
msg.parts?.length,
|
||||
)
|
||||
if (msg.parts) {
|
||||
msg.parts.forEach((part: any, partIdx: number) => {
|
||||
if (
|
||||
part.type === "tool-invocation" ||
|
||||
part.type === "tool-result"
|
||||
) {
|
||||
console.log(`[route.ts] Part ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputKeys:
|
||||
part.input && typeof part.input === "object"
|
||||
? Object.keys(part.input)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
console.log(
|
||||
"[route.ts] incoming",
|
||||
JSON.stringify(describeMessages(messages)),
|
||||
)
|
||||
}
|
||||
|
||||
// Replace historical tool call XML with placeholders to reduce tokens
|
||||
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
|
||||
const enableHistoryReplace =
|
||||
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
|
||||
const placeholderMessages = enableHistoryReplace
|
||||
? replaceHistoricalToolInputs(modelMessages)
|
||||
: modelMessages
|
||||
|
||||
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
||||
let enhancedMessages = placeholderMessages.filter(
|
||||
(msg: any) =>
|
||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
||||
)
|
||||
|
||||
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
||||
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
||||
// JSON object, and every provider rejects a tool result whose call is gone.
|
||||
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
||||
let enhancedMessages = dropInvalidToolCalls(modelMessages)
|
||||
|
||||
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
||||
if (DEBUG_LLM_PAYLOAD) {
|
||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] ModelMsg ${idx} role:`,
|
||||
msg.role,
|
||||
"content count:",
|
||||
msg.content?.length,
|
||||
)
|
||||
if (msg.content) {
|
||||
msg.content.forEach((part: any, partIdx: number) => {
|
||||
if (
|
||||
part.type === "tool-call" ||
|
||||
part.type === "tool-result"
|
||||
) {
|
||||
console.log(`[route.ts] Content ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputValue:
|
||||
part.input === undefined
|
||||
? "undefined"
|
||||
: part.input === null
|
||||
? "null"
|
||||
: "object",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
console.log(
|
||||
"[route.ts] to model",
|
||||
JSON.stringify(describeMessages(enhancedMessages)),
|
||||
)
|
||||
}
|
||||
|
||||
// Update the last message with user input only (XML moved to separate cached system message)
|
||||
@@ -513,7 +424,7 @@ ${userInputText}
|
||||
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
||||
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
|
||||
const isCustomOpenAIEndpoint =
|
||||
resolvedProvider === "openai" &&
|
||||
PROVIDERS[resolvedProvider].sdk === "openai" &&
|
||||
!!(
|
||||
baseUrl ||
|
||||
process.env.OPENAI_BASE_URL ||
|
||||
@@ -521,7 +432,7 @@ ${userInputText}
|
||||
process.env[serverModelConfig.baseUrlEnv])
|
||||
)
|
||||
const isSingleSystemProvider =
|
||||
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
||||
!!PROVIDERS[resolvedProvider].singleSystem || isCustomOpenAIEndpoint
|
||||
|
||||
const selectionContext = formatSelectionContext(body.selectedCells)
|
||||
// The model reads the diagram in the compact notation it writes
|
||||
@@ -566,13 +477,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
|
||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||
|
||||
// Set by onAbort, which records the finished steps' tokens itself
|
||||
let stopped = false
|
||||
const result = streamText({
|
||||
model,
|
||||
// The system messages carry cache points, so they go in messages.
|
||||
// A client's own system messages have string content and were
|
||||
// dropped by the empty-content filter above.
|
||||
// The system messages carry cache points, so they go in messages
|
||||
// (the client's own system messages were dropped above)
|
||||
allowSystemInMessages: true,
|
||||
abortSignal: req.signal,
|
||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||
@@ -619,166 +527,31 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
},
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
sessionId: validSessionId,
|
||||
userId,
|
||||
}),
|
||||
// Langfuse telemetry (undefined when not configured)
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
sessionId: validSessionId,
|
||||
userId,
|
||||
}),
|
||||
onFinish: ({ text, totalUsage }) => {
|
||||
// The quota counts each step as it ends: a later step that fails or
|
||||
// is stopped has still used these tokens (onFinish runs only for a
|
||||
// completed answer). Awaited, so the write is done before the host
|
||||
// may freeze the function after the answer ends.
|
||||
onStepFinish: async ({ usage }) => {
|
||||
if (countsQuota) {
|
||||
await recordTokenUsage(userId, usage.totalTokens ?? 0)
|
||||
}
|
||||
},
|
||||
onFinish: ({ text }) => {
|
||||
// AI SDK 6 telemetry auto-reports token usage on its spans
|
||||
setTraceOutput(text)
|
||||
|
||||
// Record token usage for server-side quota tracking (if enabled)
|
||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||
// inputTokens already includes cache reads and writes in AI SDK 6
|
||||
if (countsQuota && totalUsage && !stopped) {
|
||||
const totalTokens =
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0)
|
||||
recordTokenUsage(userId, totalTokens)
|
||||
}
|
||||
},
|
||||
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
||||
onError: ({ error }) => {
|
||||
console.error(error) // what AI SDK does without an onError
|
||||
endTrace()
|
||||
},
|
||||
onAbort: ({ steps }) => {
|
||||
stopped = true
|
||||
endTrace()
|
||||
// Stopped (or disconnected) after some steps finished: their
|
||||
// tokens were used, or stopping every request after a costly
|
||||
// first step would get around the token limits
|
||||
if (countsQuota) {
|
||||
const tokens = steps.reduce(
|
||||
(sum, step) =>
|
||||
sum +
|
||||
(step.usage.inputTokens || 0) +
|
||||
(step.usage.outputTokens || 0),
|
||||
0,
|
||||
)
|
||||
if (tokens > 0) recordTokenUsage(userId, tokens)
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
|
||||
|
||||
VALIDATION RULES (XML will be rejected if violated):
|
||||
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. All mxCell elements must be siblings - never nested
|
||||
4. Every mxCell needs a unique id (start from "2")
|
||||
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
|
||||
6. Escape special chars in values: < > & "
|
||||
|
||||
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
||||
|
||||
Example (generate ONLY this - no wrapper tags):
|
||||
${SWIMLANE_EXAMPLE}
|
||||
|
||||
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use <br> for a line break, never \\n; a literal < or > is written &lt; or &gt;.
|
||||
${STYLE_CLASS_EXAMPLE}
|
||||
|
||||
Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe("XML string to be displayed on draw.io"),
|
||||
}),
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
||||
|
||||
Operations:
|
||||
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
||||
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||
|
||||
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
|
||||
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||
|
||||
Example - Add a rectangle:
|
||||
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||
|
||||
Example - Delete container (children & edges auto-deleted):
|
||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
||||
inputSchema: z.object({
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
operation: z
|
||||
.enum(["update", "add", "delete"])
|
||||
.describe(
|
||||
"Operation to perform: add, update, or delete",
|
||||
),
|
||||
cell_id: z
|
||||
.string()
|
||||
.describe(
|
||||
"The id of the mxCell. Must match the id attribute in new_xml.",
|
||||
),
|
||||
new_xml: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Complete mxCell XML element (required for update/add)",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe("Array of operations to apply"),
|
||||
}),
|
||||
},
|
||||
append_diagram: {
|
||||
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
||||
|
||||
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
|
||||
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe(
|
||||
"Continuation XML fragment to append (NO wrapper tags)",
|
||||
),
|
||||
}),
|
||||
},
|
||||
get_shape_library: {
|
||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||
|
||||
Available libraries:
|
||||
${SHAPE_LIBRARY_LIST}
|
||||
|
||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
inputSchema: z.object({
|
||||
library: z
|
||||
.string()
|
||||
.describe(
|
||||
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
||||
),
|
||||
}),
|
||||
execute: async ({ library }) => {
|
||||
// Only known library names reach the file system
|
||||
const result = await getShapeLibrary(
|
||||
library,
|
||||
path.join(process.cwd(), "docs/shape-libraries"),
|
||||
)
|
||||
return result.ok ? result.text : result.error
|
||||
},
|
||||
},
|
||||
},
|
||||
onAbort: () => endTrace(),
|
||||
tools: chatTools,
|
||||
...(process.env.TEMPERATURE !== undefined && {
|
||||
temperature: parseFloat(process.env.TEMPERATURE),
|
||||
}),
|
||||
@@ -790,17 +563,6 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||
// user's own, and EdgeOne's text is our function's explanation.
|
||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
const usage = (part as any).totalUsage
|
||||
// AI SDK 6 provides totalTokens directly
|
||||
return {
|
||||
totalTokens: usage?.totalTokens ?? 0,
|
||||
finishReason: (part as any).finishReason,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
modelStreamResponses.add(response)
|
||||
requestOfResponse.set(response, req)
|
||||
@@ -840,7 +602,7 @@ async function safeHandler(req: Request): Promise<Response> {
|
||||
} catch (error) {
|
||||
response = handleError(error)
|
||||
}
|
||||
// Early returns, cache hits and errors never reach streamText's callbacks,
|
||||
// Early returns and errors never reach streamText's callbacks,
|
||||
// so their Langfuse trace has to be ended here
|
||||
if (!modelStreamResponses.has(response)) endTrace()
|
||||
return response
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
# Draw.io XML Schema Guide
|
||||
|
||||
This guide explains the structure of draw.io (diagrams.net) XML files to help you understand and create diagrams programmatically.
|
||||
|
||||
## Basic Structure
|
||||
|
||||
A draw.io XML file has the following hierarchy:
|
||||
|
||||
```xml
|
||||
<mxfile>
|
||||
<diagram>
|
||||
<mxGraphModel>
|
||||
<root>
|
||||
<mxCell /> <!-- Cells that make up the diagram -->
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
```
|
||||
|
||||
## Root Element: `<mxfile>`
|
||||
|
||||
The root element of a draw.io file.
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `host`: The application that created the file (e.g., "app.diagrams.net")
|
||||
- `modified`: Last modification timestamp
|
||||
- `agent`: Browser / user agent information
|
||||
- `version`: Version of the application
|
||||
- `type`: File type (usually "device" or "google")
|
||||
|
||||
**Example:**
|
||||
|
||||
```xml
|
||||
<mxfile host="app.diagrams.net" modified="2023-07-14T10:20:30.123Z" agent="Mozilla/5.0" version="21.5.2" type="device">
|
||||
```
|
||||
|
||||
## Diagram Element: `<diagram>`
|
||||
|
||||
Each page in your draw.io document is represented by a `<diagram>` element.
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `id`: Unique identifier for the diagram
|
||||
- `name`: The name of the diagram / page
|
||||
|
||||
**Example:**
|
||||
|
||||
```xml
|
||||
<diagram id="pWHN0msd4Ud1ZK5cD-Hr" name="Page-1">
|
||||
```
|
||||
|
||||
## Graph Model: `<mxGraphModel>`
|
||||
|
||||
Contains the actual diagram data.
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `dx`: Grid size in x-direction (usually 1)
|
||||
- `dy`: Grid size in y-direction (usually 1)
|
||||
- `grid`: Whether grid is enabled (0 or 1)
|
||||
- `gridSize`: Grid cell size (usually 10)
|
||||
- `guides`: Whether guides are enabled (0 or 1)
|
||||
- `tooltips`: Whether tooltips are enabled (0 or 1)
|
||||
- `connect`: Whether connections are enabled (0 or 1)
|
||||
- `arrows`: Whether arrows are enabled (0 or 1)
|
||||
- `fold`: Whether folding is enabled (0 or 1)
|
||||
- `page`: Whether page view is enabled (0 or 1)
|
||||
- `pageScale`: Scale of the page (usually 1)
|
||||
- `pageWidth`: Width of the page (e.g., 850)
|
||||
- `pageHeight`: Height of the page (e.g., 1100)
|
||||
- `math`: Whether math typesetting is enabled (0 or 1)
|
||||
- `shadow`: Whether shadows are enabled (0 or 1)
|
||||
|
||||
**Example:**
|
||||
|
||||
```xml
|
||||
<mxGraphModel dx="1" dy="1" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
```
|
||||
|
||||
## Root Cell Container: `<root>`
|
||||
|
||||
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
|
||||
|
||||
**Internal structure (auto-generated):**
|
||||
|
||||
```xml
|
||||
<root>
|
||||
<mxCell id="0"/> <!-- Auto-added -->
|
||||
<mxCell id="1" parent="0"/> <!-- Auto-added -->
|
||||
<!-- Your mxCell elements go here (start from id="2") -->
|
||||
</root>
|
||||
```
|
||||
|
||||
## Cell Elements: `<mxCell>`
|
||||
|
||||
The basic building block of diagrams. Cells represent shapes, connectors, text, etc.
|
||||
|
||||
**Attributes for all cells:**
|
||||
|
||||
- `id`: Unique identifier for the cell
|
||||
- `parent`: ID of the parent cell (typically "1" for most cells)
|
||||
- `value`: Text content of the cell
|
||||
- `style`: Styling information (see Style section below)
|
||||
|
||||
**Attributes for shapes (vertices):**
|
||||
|
||||
- `vertex`: Set to "1" for shapes
|
||||
- `connectable`: Whether the shape can be connected (0 or 1)
|
||||
|
||||
**Attributes for connectors (edges):**
|
||||
|
||||
- `edge`: Set to "1" for connectors
|
||||
- `source`: ID of the source cell
|
||||
- `target`: ID of the target cell
|
||||
|
||||
**Example (Rectangle shape):**
|
||||
|
||||
```xml
|
||||
<mxCell id="2" value="Hello World" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
**Example (Connector):**
|
||||
|
||||
```xml
|
||||
<mxCell id="3" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="400" y="430" as="sourcePoint"/>
|
||||
<mxPoint x="450" y="380" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
## Geometry: `<mxGeometry>`
|
||||
|
||||
Defines the position and dimensions of cells.
|
||||
|
||||
**Attributes for shapes:**
|
||||
|
||||
- `x`: The x-coordinate of the **top-left** point of the shape.
|
||||
- `y`: The y-coordinate of the **top-left** point of the shape.
|
||||
- `width`: The width of the shape.
|
||||
- `height`: The height of the shape.
|
||||
- `as`: Specifies the role of this geometry within its parent cell. Typically set to `"geometry"` for the main shape definition.
|
||||
|
||||
**Attributes for connectors:**
|
||||
|
||||
- `relative`: Set to "1" for relative geometry
|
||||
- `as`: Set to "geometry"
|
||||
|
||||
**Example for shapes:**
|
||||
|
||||
```xml
|
||||
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
|
||||
```
|
||||
|
||||
**Example for connectors:**
|
||||
|
||||
```xml
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="400" y="430" as="sourcePoint"/>
|
||||
<mxPoint x="450" y="380" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
```
|
||||
|
||||
## Cell Style Reference
|
||||
|
||||
Styles are specified as semicolon-separated `key=value` pairs in the `style` attribute of `<mxCell>` elements.
|
||||
|
||||
### Shape-specific Styles
|
||||
|
||||
- Rectangle: `shape=rectangle`
|
||||
- Ellipse: `shape=ellipse`
|
||||
- Triangle: `shape=triangle`
|
||||
- Rhombus: `shape=rhombus`
|
||||
- Hexagon: `shape=hexagon`
|
||||
- Cloud: `shape=cloud`
|
||||
- Actor: `shape=actor`
|
||||
- Cylinder: `shape=cylinder`
|
||||
- Document: `shape=document`
|
||||
- Note: `shape=note`
|
||||
- Card: `shape=card`
|
||||
- Parallelogram: `shape=parallelogram`
|
||||
|
||||
### Connector Styles
|
||||
|
||||
- `endArrow=classic`: Arrow type at the end (classic, open, oval, diamond, block)
|
||||
- `startArrow=none`: Arrow type at the start (none, classic, open, oval, diamond)
|
||||
- `curved=1`: Curved connector (0 or 1)
|
||||
- `edgeStyle=orthogonalEdgeStyle`: Connector routing style
|
||||
- `elbow=vertical`: Elbow direction (vertical, horizontal)
|
||||
- `jumpStyle=arc`: Jump style for line crossing (arc, gap)
|
||||
- `jumpSize=10`: Size of the jump
|
||||
|
||||
## Special Cells
|
||||
|
||||
Draw.io files contain two special cells that are always present:
|
||||
|
||||
1. **Root Cell** (id = "0"): The parent of all cells
|
||||
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
|
||||
|
||||
## Tips for Creating Draw.io XML
|
||||
|
||||
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
|
||||
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
|
||||
3. Assign unique and sequential IDs to all cells
|
||||
4. Define parent relationships correctly (use parent="1" for top-level shapes)
|
||||
5. Use `mxGeometry` elements to position shapes
|
||||
6. For connectors, specify `source` and `target` attributes
|
||||
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Grouping Elements
|
||||
|
||||
To group elements, create a parent cell and set other cells' `parent` attribute to its ID:
|
||||
|
||||
```xml
|
||||
<!-- Group container -->
|
||||
<mxCell id="10" value="Group" style="group" vertex="1" connectable="0" parent="1">
|
||||
<mxGeometry x="200" y="200" width="200" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Elements inside the group -->
|
||||
<mxCell id="11" value="Element 1" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
|
||||
<mxGeometry width="90" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="12" value="Element 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
|
||||
<mxGeometry x="110" width="90" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
### Swimlanes
|
||||
|
||||
Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**
|
||||
|
||||
```xml
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<!-- Swimlane 1 -->
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="300" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Swimlane 2 -->
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="300" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Step inside lane1 (parent="lane1") -->
|
||||
<mxCell id="step1" value="Send Request" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Step inside lane2 (parent="lane2") -->
|
||||
<mxCell id="step2" value="Process" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
```
|
||||
|
||||
### Tables
|
||||
|
||||
Tables use multiple cells with parent-child relationships:
|
||||
|
||||
```xml
|
||||
<mxCell id="30" value="Table" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="200" width="180" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="31" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[0,0.5,1,0.5];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="30">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Attributes
|
||||
|
||||
Draw.io allows adding custom attributes to cells:
|
||||
|
||||
```xml
|
||||
<mxCell id="40" value="Custom Element" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="200" width="120" height="60" as="geometry"/>
|
||||
<Object label="Custom Label" customAttr="value" />
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
These custom attributes can store additional metadata or be used by plugins and custom behaviors.
|
||||
|
||||
### User-defined Styles
|
||||
|
||||
You can define custom styles for cells by combining various style attributes:
|
||||
|
||||
```xml
|
||||
<mxCell id="50" value="Custom Styled Cell"
|
||||
style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#f8cecc;strokeColor=#b85450;strokeWidth=2;fontSize=14;fontStyle=1"
|
||||
vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="200" width="120" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
```
|
||||
|
||||
### Layers
|
||||
|
||||
You can create multiple layers in a diagram to organize complex diagrams:
|
||||
|
||||
```xml
|
||||
<!-- Default layer (always present) -->
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
<!-- Additional custom layer -->
|
||||
<mxCell id="60" value="Layer 2" style="locked=0;group=" parent="0"/>
|
||||
|
||||
<!-- Elements in Layer 2 -->
|
||||
<mxCell id="61" value="Element in Layer 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="60">
|
||||
<mxGeometry x="200" y="300" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
```
|
||||
@@ -3,8 +3,5 @@ import { NextResponse } from "next/server"
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
accessCodeRequired: !!process.env.ACCESS_CODE_LIST,
|
||||
dailyRequestLimit: Number(process.env.DAILY_REQUEST_LIMIT) || 0,
|
||||
dailyTokenLimit: Number(process.env.DAILY_TOKEN_LIMIT) || 0,
|
||||
tpmLimit: Number(process.env.TPM_LIMIT) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
@@ -37,75 +36,24 @@ export async function POST(req: Request) {
|
||||
const userId = getUserIdFromRequest(req)
|
||||
|
||||
try {
|
||||
// Find the most recent chat trace for this session to attach the score to
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
// Score the session's latest chat trace; without one, a standalone
|
||||
// feedback trace carries the score
|
||||
await scoreLatestTrace(langfuse, {
|
||||
sessionId,
|
||||
limit: 1,
|
||||
name: "user-feedback",
|
||||
value: feedback === "good" ? 1 : 0,
|
||||
comment: `User gave ${feedback} feedback`,
|
||||
fallbackTrace: {
|
||||
name: "user-feedback",
|
||||
userId,
|
||||
input: { messageId, feedback },
|
||||
metadata: {
|
||||
source: "feedback-button",
|
||||
note: "standalone - no chat trace found",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const traces = tracesResponse.data || []
|
||||
const latestTrace = traces[0]
|
||||
|
||||
if (!latestTrace) {
|
||||
// No trace found for this session - create a standalone feedback trace
|
||||
const traceId = randomUUID()
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "trace-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "user-feedback",
|
||||
sessionId,
|
||||
userId,
|
||||
input: { messageId, feedback },
|
||||
metadata: {
|
||||
source: "feedback-button",
|
||||
note: "standalone - no chat trace found",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId,
|
||||
name: "user-feedback",
|
||||
value: feedback === "good" ? 1 : 0,
|
||||
comment: `User gave ${feedback} feedback`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// Attach score to the existing chat trace
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: latestTrace.id,
|
||||
name: "user-feedback",
|
||||
value: feedback === "good" ? 1 : 0,
|
||||
comment: `User gave ${feedback} feedback`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json({ success: true, logged: true })
|
||||
} catch (error) {
|
||||
console.error("Langfuse feedback error:", error)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||
|
||||
const saveSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
// Find the most recent chat trace for this session to attach the save flag
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
// Flag the session's latest chat trace as saved; a session without a
|
||||
// trace (no chat yet) is skipped
|
||||
const logged = await scoreLatestTrace(langfuse, {
|
||||
sessionId,
|
||||
limit: 1,
|
||||
name: "diagram-saved",
|
||||
value: 1,
|
||||
comment: `User saved diagram as ${filename}.${format}`,
|
||||
})
|
||||
|
||||
const traces = tracesResponse.data || []
|
||||
const latestTrace = traces[0]
|
||||
|
||||
if (latestTrace) {
|
||||
// Add a score to the existing trace to flag that user saved
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: latestTrace.id,
|
||||
name: "diagram-saved",
|
||||
value: 1,
|
||||
comment: `User saved diagram as ${filename}.${format}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
// If no trace found, skip logging (user hasn't chatted yet)
|
||||
|
||||
return Response.json({ success: true, logged: !!latestTrace })
|
||||
return Response.json({ success: true, logged })
|
||||
} catch (error) {
|
||||
console.error("Langfuse save error:", error)
|
||||
return Response.json(
|
||||
|
||||
@@ -6,23 +6,16 @@ import {
|
||||
listProviderModels,
|
||||
ModelListError,
|
||||
} from "@/lib/provider-models"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import {
|
||||
allowPrivateUrls,
|
||||
isPrivateUrl,
|
||||
RedirectRefusedError,
|
||||
redirectGuardedFetch,
|
||||
} from "@/lib/ssrf-protection"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
// Public lists need no key
|
||||
const NO_KEY_NEEDED = new Set<ProviderName>([
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
])
|
||||
|
||||
/**
|
||||
* The models a provider offers, for the "Fetch models" button in model
|
||||
* settings. Answers { models: null } for providers that cannot list them,
|
||||
@@ -47,7 +40,8 @@ export async function POST(req: Request) {
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
||||
}
|
||||
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
||||
// Public and local lists need no key
|
||||
if (!apiKey && !PROVIDERS[provider].listNeedsNoKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
|
||||
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
const models = await loadFlattenedServerModels()
|
||||
return NextResponse.json({
|
||||
models,
|
||||
hasConfig: models.length > 0,
|
||||
})
|
||||
return NextResponse.json({ models })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* API endpoint for VLM-based diagram validation.
|
||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||
* Accepts a PNG image and answers with the validation result as JSON.
|
||||
*/
|
||||
|
||||
import { Output, streamText } from "ai"
|
||||
import { generateText, Output } from "ai"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { getValidationModel } from "@/lib/ai-providers"
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isQuotaEnabled,
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import { getTelemetryConfig } from "@/lib/langfuse"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||
import {
|
||||
@@ -35,13 +36,6 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||
suggestions: [],
|
||||
}
|
||||
|
||||
/** A fixed result in the text format useObject reads */
|
||||
function createStreamingResponse(result: ValidationResult): Response {
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
@@ -53,7 +47,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
// Check if VLM validation is enabled (default: true)
|
||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||
if (!enableValidation) {
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
return Response.json(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
const body: ValidateDiagramRequest = await req.json()
|
||||
@@ -123,7 +117,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
error,
|
||||
)
|
||||
// Return valid if no vision model is configured
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
return Response.json(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||
@@ -133,8 +127,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||
) || 10000
|
||||
|
||||
// Stream the VLM response for useObject consumption
|
||||
const result = streamText({
|
||||
const { output, totalUsage } = await generateText({
|
||||
model,
|
||||
output: Output.object({ schema: ValidationResultSchema }),
|
||||
system: VALIDATION_SYSTEM_PROMPT,
|
||||
@@ -154,24 +147,25 @@ export async function POST(req: Request): Promise<Response> {
|
||||
},
|
||||
],
|
||||
maxOutputTokens: 1024,
|
||||
abortSignal: AbortSignal.timeout(timeout),
|
||||
onFinish: ({ output, totalUsage }) => {
|
||||
if (countsQuota && totalUsage) {
|
||||
recordTokenUsage(
|
||||
userId,
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0),
|
||||
)
|
||||
}
|
||||
if (sessionId && output) {
|
||||
console.log(
|
||||
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
// Ends with the request: the user pressed Stop, or left
|
||||
abortSignal: req.signal,
|
||||
timeout,
|
||||
// Its usage and cost show in Langfuse next to the chat's
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
functionId: "validate-diagram",
|
||||
sessionId,
|
||||
userId,
|
||||
}),
|
||||
})
|
||||
|
||||
return result.toTextStreamResponse()
|
||||
if (countsQuota) {
|
||||
await recordTokenUsage(userId, totalUsage.totalTokens ?? 0)
|
||||
}
|
||||
if (sessionId) {
|
||||
console.log(
|
||||
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||
)
|
||||
}
|
||||
return Response.json(output)
|
||||
} catch (error) {
|
||||
// Log with session context if available
|
||||
const errorMessage =
|
||||
@@ -179,6 +173,6 @@ export async function POST(req: Request): Promise<Response> {
|
||||
console.error("[validate-diagram] Error:", errorMessage)
|
||||
|
||||
// On error, return valid to not block the user
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
return Response.json(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-233
@@ -1,249 +1,29 @@
|
||||
import { streamText, tool } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
globalBaseUrl,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
type TestModelRequest,
|
||||
testModelConnection,
|
||||
} from "@/lib/test-model-connection"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
interface ValidateRequest {
|
||||
provider: ProviderName
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
modelId: string
|
||||
// AWS Bedrock specific
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
awsSessionToken?: string
|
||||
// Vertex AI specific
|
||||
vertexApiKey?: string // Express Mode API key
|
||||
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
||||
serverBaseUrl?: boolean
|
||||
}
|
||||
|
||||
const TEST_TIMEOUT_MS = 15_000
|
||||
|
||||
// Drawing works through tool calls, so the test asks for one
|
||||
const PING_TOOL = tool({
|
||||
description: "Report that the connection works.",
|
||||
inputSchema: z.object({}),
|
||||
})
|
||||
|
||||
const NO_TOOL_CALL_WARNING =
|
||||
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
||||
|
||||
// The Settings dialog's Test button: a model on the user's own credentials
|
||||
export async function POST(req: Request) {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Lets the server send requests to arbitrary URLs, so require the access
|
||||
// code, or the admin password (the admin panel's Test button)
|
||||
// Lets the server send requests to arbitrary URLs, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError && checkAdminAuth(req)) return accessError
|
||||
if (accessError) return accessError
|
||||
|
||||
let body: TestModelRequest
|
||||
try {
|
||||
const body: ValidateRequest = await req.json()
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
modelId,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
awsSessionToken,
|
||||
// Note: Express Mode only needs vertexApiKey
|
||||
vertexApiKey,
|
||||
} = body
|
||||
|
||||
if (!provider || !modelId) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Provider and model ID are required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// EdgeOne is this site's own function, as in the chat; the admin
|
||||
// panel's Test sends no URL, and a relative one cannot be fetched
|
||||
const baseUrl =
|
||||
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
||||
// The admin panel's Test of an entry without a URL sends the
|
||||
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
||||
// user chose, so no private-address or redirect rules
|
||||
const serverUrl =
|
||||
body.serverBaseUrl === true &&
|
||||
!!baseUrl &&
|
||||
baseUrl === globalBaseUrl(provider) &&
|
||||
!checkAdminAuth(req)
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (
|
||||
baseUrl &&
|
||||
!serverUrl &&
|
||||
!allowPrivateUrls() &&
|
||||
(await isPrivateUrl(baseUrl))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials based on provider
|
||||
if (provider === "bedrock") {
|
||||
if (
|
||||
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
|
||||
!awsRegion
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider === "vertexai") {
|
||||
if (!vertexApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "Vertex AI API key is required for Express Mode",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// The Test button checks the user's own provider. On the server's
|
||||
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||
if (
|
||||
usesServerCredentials(provider, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
vertexApiKey,
|
||||
})
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// On the deployment's own endpoints a Test runs a model as a chat
|
||||
// does, so with the quota on it counts as a chat request (an
|
||||
// admin's Test of the server's URL does not)
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!serverUrl &&
|
||||
userId !== "anonymous" &&
|
||||
(await usesServerEndpoint(
|
||||
provider,
|
||||
normalizeBaseUrl(body.baseUrl ?? ""),
|
||||
apiKey,
|
||||
))
|
||||
) {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: quotaCheck.error },
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The same model the chat would use. A client base URL makes it
|
||||
// refuse redirects to internal hosts.
|
||||
const { model } = getAIModel({
|
||||
provider,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
trustedBaseUrl: serverUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
// Temporary AWS credentials need it, as in the chat
|
||||
awsSessionToken,
|
||||
vertexApiKey,
|
||||
// EdgeOne checks the Pages cookies and the access code
|
||||
...(provider === "edgeone" && {
|
||||
headers: {
|
||||
cookie: req.headers.get("cookie") || "",
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Streaming, like the chat (some models only stream). Stop at the
|
||||
// first tool call; a reasoning model that runs out of tokens first
|
||||
// proves the connection but not tool support.
|
||||
const startTime = Date.now()
|
||||
const result = streamText({
|
||||
model,
|
||||
prompt: "Call the ping tool.",
|
||||
tools: { ping: PING_TOOL },
|
||||
maxOutputTokens: 1024,
|
||||
maxRetries: 0,
|
||||
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
})
|
||||
let calledTool = false
|
||||
let finishReason: string | undefined
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
// The timeout ends the stream with an abort part, not an error
|
||||
if (part.type === "abort") {
|
||||
const timeout = new Error(
|
||||
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
||||
)
|
||||
timeout.name = "TimeoutError"
|
||||
throw timeout
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
calledTool = true
|
||||
break
|
||||
}
|
||||
if (part.type === "finish") finishReason = part.finishReason
|
||||
}
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
...(!calledTool &&
|
||||
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[validate-model] Error:", error)
|
||||
|
||||
const { code, message } = classifyLLMError(error)
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ valid: false, code, error: message },
|
||||
{ status: 200 }, // Return 200 so client can read error message
|
||||
{ valid: false, error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// A URL in the request is one the user typed, never the server's own
|
||||
return testModelConnection(req, body, false)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.0 KiB |
+9
-22
@@ -98,9 +98,7 @@
|
||||
--shadow-pop:
|
||||
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
|
||||
|
||||
--duration-normal: 200ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -241,13 +239,6 @@
|
||||
.shadow-pop {
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
/* Older names kept for components that still use them */
|
||||
.shadow-soft {
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.shadow-dialog {
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -284,17 +275,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes check-pop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
/* A highlight sweeps across "Thinking..." text (Shimmer) */
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 100% center;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
to {
|
||||
background-position: 0% center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,15 +294,15 @@
|
||||
.animate-panel-in {
|
||||
animation: panelIn 0.32s var(--ease-out) both;
|
||||
}
|
||||
.animate-check-pop {
|
||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
||||
.animate-shimmer {
|
||||
animation: shimmer var(--shimmer-duration, 2s) linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-in,
|
||||
.animate-message-in,
|
||||
.animate-panel-in,
|
||||
.animate-check-pop {
|
||||
.animate-shimmer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"**",
|
||||
"!public",
|
||||
"!packages/mcp-server/src/preview",
|
||||
"!packages/mcp-server/shell/index.html",
|
||||
"!lib/model-catalog.json"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Cloud } from "lucide-react"
|
||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
)
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||
|
||||
export const ModelSelectorList = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorListProps) => {
|
||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
if (!listElement) return
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||
// Show shadow if there is more content below
|
||||
// Using a small threshold to handle fractional pixel rendering
|
||||
setShowShadow(
|
||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
// Initial check
|
||||
checkScroll()
|
||||
|
||||
// Event listeners
|
||||
listElement.addEventListener("scroll", checkScroll)
|
||||
window.addEventListener("resize", checkScroll)
|
||||
|
||||
// Observe content changes (e.g. async loading of items)
|
||||
const observer = new MutationObserver(checkScroll)
|
||||
observer.observe(listElement, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
listElement.removeEventListener("scroll", checkScroll)
|
||||
window.removeEventListener("resize", checkScroll)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||
showShadow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => {
|
||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||
if (provider === "amazon-bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSectionHeaderProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ModelSelectorSectionHeader = ({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
}: ModelSelectorSectionHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -1,186 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Shimmer } from "./shimmer"
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
duration: number | undefined
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext)
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
export type ReasoningProps = {
|
||||
className?: string
|
||||
isStreaming?: boolean
|
||||
open?: boolean
|
||||
/** Open while streaming, then folds once about a second after it ends */
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
duration?: number
|
||||
getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
children: string
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000
|
||||
const MS_IN_S = 1000
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
})
|
||||
export function Reasoning({
|
||||
className,
|
||||
isStreaming = false,
|
||||
defaultOpen = true,
|
||||
getThinkingMessage,
|
||||
children,
|
||||
}: ReasoningProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||
const [duration, setDuration] = useState<number>()
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||
setStartTime(null)
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration])
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen)
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [isStreaming, startTime])
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
// Auto-close once when streaming ends, after a short delay so the user
|
||||
// sees the content
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
}
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={setIsOpen}
|
||||
open={isOpen}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{children}</div>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
)
|
||||
|
||||
Reasoning.displayName = "Reasoning"
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger"
|
||||
ReasoningContent.displayName = "ReasoningContent"
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 text-sm data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
|
||||
<div className="whitespace-pre-wrap">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react"
|
||||
import { type CSSProperties, type ElementType, memo, useMemo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TextShimmerProps = {
|
||||
@@ -25,39 +18,29 @@ const ShimmerComponent = ({
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements,
|
||||
)
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread],
|
||||
)
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
<Component
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"animate-shimmer relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className,
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
"--shimmer-duration": `${duration}s`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type React from "react"
|
||||
import { Button, type buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface ButtonWithTooltipProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
tooltipContent: string
|
||||
children: React.ReactNode
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function ButtonWithTooltip({
|
||||
tooltipContent,
|
||||
children,
|
||||
...buttonProps
|
||||
}: ButtonWithTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button {...buttonProps}>{children}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-wrap">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
||||
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||
import { useLocale } from "@/components/canvas/locale-context"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
||||
import {
|
||||
DRAWIO_CSS,
|
||||
type DrawioSource,
|
||||
getDrawioSrc,
|
||||
webDrawioSource,
|
||||
} from "@/lib/drawio/drawio-config"
|
||||
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
@@ -13,28 +16,42 @@ import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
/** Size of the canvas area, for overlays that stay inside it */
|
||||
export interface CanvasSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
|
||||
* the "ask AI" button laid over it (only when the page can drive draw.io
|
||||
* directly, not with an external cross-origin draw.io).
|
||||
* an overlay laid over it (the web app's "ask AI" button; only when the
|
||||
* page can drive draw.io directly, not with an external cross-origin
|
||||
* draw.io).
|
||||
*/
|
||||
export function CanvasStage({
|
||||
className,
|
||||
style,
|
||||
hideOverlays = false,
|
||||
overlay,
|
||||
drawioSource,
|
||||
waitForSavedLocale = false,
|
||||
}: {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
/** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */
|
||||
/** Hide the overlay (e.g. while the start screen covers the canvas) */
|
||||
hideOverlays?: boolean
|
||||
/** Rendered over the canvas once draw.io is ready */
|
||||
overlay?: (size: CanvasSize) => React.ReactNode
|
||||
/** Where the editor comes from; none: the web app's own copy (the MCP
|
||||
* shell passes what its server configured) */
|
||||
drawioSource?: DrawioSource
|
||||
/** Web app: the page is about to switch to the language picked last
|
||||
* time (useSavedLocale in the workspace); do not load draw.io in the
|
||||
* URL's language meanwhile */
|
||||
waitForSavedLocale?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const params = useParams<{ lang: string }>()
|
||||
const lang = (
|
||||
i18n.locales.includes(params.lang as Locale)
|
||||
? params.lang
|
||||
: i18n.defaultLocale
|
||||
) as Locale
|
||||
const lang = useLocale()
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramAutoSave,
|
||||
@@ -43,7 +60,6 @@ export function CanvasStage({
|
||||
resetDrawioReady,
|
||||
isDrawioReady,
|
||||
} = useDiagram()
|
||||
const engine = useChatEngine()
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
||||
// Canvas width when the chat panel starts sliding in or out
|
||||
@@ -53,20 +69,24 @@ export function CanvasStage({
|
||||
// null until draw.io tells us whether we can drive it directly
|
||||
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
const [size, setSize] = useState<CanvasSize>({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
// The page is about to switch to the language picked last time
|
||||
// (useSavedLocale): load draw.io once, in that language
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||
if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) {
|
||||
return
|
||||
// Load draw.io once, in the language the page will have
|
||||
if (waitForSavedLocale) {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||
if (
|
||||
saved &&
|
||||
saved !== lang &&
|
||||
i18n.locales.includes(saved as Locale)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
||||
// The theme class is set before the page renders; the settings store
|
||||
// may not have read it yet
|
||||
const dark = document.documentElement.classList.contains("dark")
|
||||
setSrc(getDrawioSrc({ lang, isElectron, dark }))
|
||||
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), dark))
|
||||
}, [lang])
|
||||
|
||||
// A new iframe starts over: draw.io reports load and access again
|
||||
@@ -82,8 +102,7 @@ export function CanvasStage({
|
||||
if (editorAccess !== false || !src) return
|
||||
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
||||
if (startedDark === isDark) return
|
||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
||||
setSrc(getDrawioSrc({ lang, isElectron, dark: isDark }))
|
||||
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), isDark))
|
||||
}, [isDark, editorAccess, lang, src])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -136,8 +155,7 @@ export function CanvasStage({
|
||||
key={src}
|
||||
ref={drawioRef}
|
||||
src={src}
|
||||
configCss={DRAWIO_CSS}
|
||||
editorCss={DRAWIO_CSS}
|
||||
css={DRAWIO_CSS}
|
||||
dark={isDark}
|
||||
onLoad={onDrawioLoad}
|
||||
onAutoSave={handleDiagramAutoSave}
|
||||
@@ -159,13 +177,7 @@ export function CanvasStage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOverlays && (
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
hidden={engine.isBusy}
|
||||
/>
|
||||
)}
|
||||
{showOverlays && overlay?.(size)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { VersionThumb } from "@/components/canvas/version-card"
|
||||
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,11 +13,8 @@ import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { describeChanges } from "@/lib/version-text"
|
||||
import { describeChangeSummary } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { VersionThumb } from "./tool-activity"
|
||||
|
||||
function Sheet({
|
||||
label,
|
||||
@@ -40,12 +39,11 @@ function Sheet({
|
||||
export function CompareDialog() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const { versions, isBusy, restore } = useVersionsContext()
|
||||
const { getVersionSvg, chartXML } = useDiagram()
|
||||
const versionId = useUiStore((s) => s.compareVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const closeCompare = useUiStore((s) => s.closeCompare)
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
||||
|
||||
const index = versions.findIndex((v) => v.id === versionId)
|
||||
@@ -82,7 +80,7 @@ export function CompareDialog() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [versionId, engine.isBusy])
|
||||
}, [versionId, isBusy])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -98,7 +96,7 @@ export function CompareDialog() {
|
||||
: same
|
||||
? t.isOnCanvas
|
||||
: changes
|
||||
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChanges(changes, t)}`
|
||||
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChangeSummary(changes, t)}`
|
||||
: formatMessage(t.differsElsewhere, {
|
||||
n: version.number,
|
||||
})}
|
||||
@@ -139,10 +137,10 @@ export function CompareDialog() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!version || same || engine.isBusy}
|
||||
disabled={!version || same || isBusy}
|
||||
onClick={() => {
|
||||
if (!version) return
|
||||
engine.restoreVersion(version.id)
|
||||
restore(version.id)
|
||||
closeCompare()
|
||||
}}
|
||||
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
||||
@@ -30,10 +30,11 @@ export interface DrawioFrameHandle {
|
||||
|
||||
interface DrawioFrameProps {
|
||||
src: string
|
||||
/** CSS sent with the configure message (works cross-origin too) */
|
||||
configCss: string
|
||||
/** CSS injected once the editor is reachable directly (same origin) */
|
||||
editorCss: string
|
||||
/**
|
||||
* Theme CSS: sent with the configure message (works cross-origin too)
|
||||
* and, same origin, injected as a <style> so it updates without a reload
|
||||
*/
|
||||
css: string
|
||||
/** draw.io's dark mode, switched in place when the editor is reachable */
|
||||
dark: boolean
|
||||
onLoad?: () => void
|
||||
@@ -52,25 +53,14 @@ const STYLE_ID = "next-ai-drawio-theme"
|
||||
*/
|
||||
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||
function DrawioFrame(
|
||||
{
|
||||
src,
|
||||
configCss,
|
||||
editorCss,
|
||||
dark,
|
||||
onLoad,
|
||||
onAutoSave,
|
||||
onExport,
|
||||
onEditorAccess,
|
||||
},
|
||||
{ src, css, dark, onLoad, onAutoSave, onExport, onEditorAccess },
|
||||
ref,
|
||||
) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const editorUiRef = useRef<any>(null)
|
||||
const attachedRef = useRef(false)
|
||||
const configCssRef = useRef(configCss)
|
||||
configCssRef.current = configCss
|
||||
const editorCssRef = useRef(editorCss)
|
||||
editorCssRef.current = editorCss
|
||||
const cssRef = useRef(css)
|
||||
cssRef.current = css
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
// Handlers change on every render; the message listener reads refs
|
||||
@@ -111,7 +101,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||
style.id = STYLE_ID
|
||||
doc.head.appendChild(style)
|
||||
}
|
||||
style.textContent = editorCssRef.current
|
||||
style.textContent = cssRef.current
|
||||
} catch {
|
||||
// Cross-origin: the configure message already applied it
|
||||
}
|
||||
@@ -119,7 +109,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeCss()
|
||||
}, [editorCss])
|
||||
}, [css])
|
||||
|
||||
// Same-origin only: draw.io's own dark mode, without a reload
|
||||
const applyDarkMode = () => {
|
||||
@@ -209,7 +199,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||
send("configure", {
|
||||
config: {
|
||||
...DRAWIO_CONFIG,
|
||||
css: configCssRef.current,
|
||||
css: cssRef.current,
|
||||
},
|
||||
})
|
||||
break
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
import type { Locale } from "@/lib/i18n/config"
|
||||
|
||||
const LocaleContext = createContext<Locale | null>(null)
|
||||
|
||||
/** The page's language; the web layout feeds it from its route param */
|
||||
export function LocaleProvider({
|
||||
locale,
|
||||
children,
|
||||
}: {
|
||||
locale: Locale
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<LocaleContext.Provider value={locale}>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
const locale = useContext(LocaleContext)
|
||||
if (!locale) {
|
||||
throw new Error("useLocale must be used within a LocaleProvider")
|
||||
}
|
||||
return locale
|
||||
}
|
||||
@@ -10,25 +10,25 @@ const GAP = 10
|
||||
/**
|
||||
* "Ask AI to change this" next to the selected shapes. Clicking it moves
|
||||
* focus to the chat input, where the selection already shows as a chip.
|
||||
* Hidden while an answer is changing the canvas.
|
||||
*/
|
||||
export function SelectionAsk({
|
||||
width,
|
||||
height,
|
||||
hidden,
|
||||
}: {
|
||||
/** Size of the canvas area, for keeping the button inside it */
|
||||
width: number
|
||||
height: number
|
||||
hidden: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const rect = useCanvasStore((s) => s.selectionRect)
|
||||
const selection = useCanvasStore((s) => s.selection)
|
||||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||||
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
||||
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||
|
||||
if (hidden || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||
if (isBusy || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||
return null
|
||||
}
|
||||
// Selection scrolled out of view
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Redo2, RotateCcw, Undo2 } from "lucide-react"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import {
|
||||
useVersionsContext,
|
||||
type VersionItem,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||
export function VersionThumb({
|
||||
svg,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
/** undefined or null: still being made; "": none could be made */
|
||||
svg: string | null | undefined
|
||||
dimmed?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const t = useDictionary().versions
|
||||
if (svg) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={svg}
|
||||
alt=""
|
||||
className={cn(
|
||||
"object-contain transition-opacity",
|
||||
dimmed && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (svg === "") {
|
||||
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||
}
|
||||
return (
|
||||
<Shimmer as="span" className="text-xs">
|
||||
{t.rendering}
|
||||
</Shimmer>
|
||||
)
|
||||
}
|
||||
|
||||
interface VersionCardProps extends React.ComponentProps<"div"> {
|
||||
version: VersionItem
|
||||
/** The newest version shows its picture; older ones fold into one row */
|
||||
isLatest: boolean
|
||||
/** What happened ("Drew the diagram"); an undone change says so instead */
|
||||
label: string
|
||||
/** Goes after the summary text (the chat adds its "Show XML" link) */
|
||||
afterSummary?: React.ReactNode
|
||||
/** Panel below the card (the chat shows the AI's XML there) */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for one diagram version: picture, number, label, what changed, and
|
||||
* the action (undo or redo on the latest, restore on older ones). The
|
||||
* picture opens Compare. Other attributes go on the card's element.
|
||||
*/
|
||||
export function VersionCard({
|
||||
version,
|
||||
isLatest,
|
||||
label,
|
||||
afterSummary,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: VersionCardProps) {
|
||||
const t = useDictionary().versions
|
||||
const source = useVersionsContext()
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
|
||||
const isUndone = source.undoneId === version.id
|
||||
const isOnCanvas = source.onCanvasId === version.id
|
||||
const title = isUndone ? t.undone : label
|
||||
const summary = version.fromScratch
|
||||
? describeTotals(version.summary, t)
|
||||
: describeChangeSummary(version.summary, t)
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||
isOnCanvas
|
||||
? "bg-marker-soft text-marker-ink"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
v{version.number}
|
||||
</span>
|
||||
)
|
||||
const actionClass =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
const restoreButton = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={source.isBusy || isOnCanvas}
|
||||
onClick={() => source.restore(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-restore"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{formatMessage(t.restoreVersion, { n: version.number })}
|
||||
</button>
|
||||
)
|
||||
const summaryLine = (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary}
|
||||
{afterSummary}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isLatest) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
className,
|
||||
)}
|
||||
data-testid="version-card"
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
||||
aria-label={t.compare}
|
||||
title={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
className="max-h-8 max-w-[52px]"
|
||||
/>
|
||||
</button>
|
||||
{badge}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{summaryLine}
|
||||
</div>
|
||||
{restoreButton}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Undo and redo belong to the newest change while it is (or was) on
|
||||
// the canvas; after an older version was restored, this one is
|
||||
// restored too
|
||||
const canUndo = source.canUndo || source.canRedo
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
isOnCanvas && "border-foreground/15",
|
||||
className,
|
||||
)}
|
||||
data-testid="version-card"
|
||||
{...rest}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
||||
aria-label={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
dimmed={isUndone}
|
||||
className="max-h-20 max-w-[88%]"
|
||||
/>
|
||||
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
||||
{t.compare}
|
||||
</span>
|
||||
</button>
|
||||
{/* Title row with the action; the summary runs under both */}
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
||||
{badge}
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{canUndo ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={source.isBusy}
|
||||
onClick={() =>
|
||||
source.canRedo ? source.redo() : source.undo()
|
||||
}
|
||||
className={actionClass}
|
||||
data-testid="version-undo"
|
||||
>
|
||||
{source.canRedo ? (
|
||||
<Redo2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
{source.canRedo ? t.redo : t.undo}
|
||||
</button>
|
||||
) : (
|
||||
restoreButton
|
||||
)}
|
||||
<div className="col-span-2 col-start-2 mt-0.5">
|
||||
{summaryLine}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
/** Row of version thumbnails under the panel header; click to compare */
|
||||
export function VersionStrip() {
|
||||
export function VersionStrip({
|
||||
minVersions = 2,
|
||||
}: {
|
||||
/** Hidden below this many versions: one version alone adds nothing to
|
||||
* its card, unless the caller knows there is no card for it */
|
||||
minVersions?: number
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const { versions, onCanvasId } = useVersionsContext()
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const engine = useChatEngine()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Keep the newest version in view
|
||||
@@ -20,24 +23,7 @@ export function VersionStrip() {
|
||||
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
||||
}, [versions.length])
|
||||
|
||||
// Versions with no card in the chat: saved by older app versions, from
|
||||
// a turn that was retried, or drawn by a call that then failed its
|
||||
// screenshot check (a call still running gets its card when done)
|
||||
const hasCardless = versions.some(
|
||||
(v) =>
|
||||
!v.toolCallId ||
|
||||
!engine.messages.some((m) =>
|
||||
m.parts?.some((p) => {
|
||||
const part = p as { toolCallId?: string; state?: string }
|
||||
return (
|
||||
part.toolCallId === v.toolCallId &&
|
||||
part.state !== "output-error"
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
// One version with a card: the strip adds nothing to it
|
||||
if (versions.length < 2 && !hasCardless) return null
|
||||
if (versions.length < minVersions) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
||||
@@ -49,13 +35,13 @@ export function VersionStrip() {
|
||||
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
||||
>
|
||||
{versions.map((version) => {
|
||||
const isCurrent = version.id === onCanvasVersionId
|
||||
const isCurrent = version.id === onCanvasId
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
title={`v${version.number}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||
title={`v${version.number}${isCurrent ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||
className={cn(
|
||||
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
|
||||
// An outline with a gap reads on the white
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
import type { ChangeSummary } from "@/lib/diagram-diff"
|
||||
|
||||
/** One saved state of the diagram, as the version card and the compare
|
||||
* dialog show it */
|
||||
export interface VersionItem {
|
||||
id: string
|
||||
/** Shown as v{number} */
|
||||
number: number
|
||||
/** Full multi-page document */
|
||||
xml: string
|
||||
/** The page the change was made on; none: the first page */
|
||||
pageId?: string
|
||||
/** Document before the change, when it is known */
|
||||
beforeXml?: string
|
||||
/** SVG data URL thumbnail; undefined: still being made; "": none could
|
||||
* be made */
|
||||
svg?: string
|
||||
summary: ChangeSummary
|
||||
/** Drawn on an empty canvas (the card shows totals, not changes) */
|
||||
fromScratch?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the versions come from and what can be done with them. The web app
|
||||
* fills it from the chat's version store; the MCP shell from the server's
|
||||
* history.
|
||||
*/
|
||||
export interface VersionsSource {
|
||||
versions: VersionItem[]
|
||||
/** The version the canvas shows now, or null (hand edits, undone) */
|
||||
onCanvasId: string | null
|
||||
/** The latest version, when its change was undone */
|
||||
undoneId: string | null
|
||||
/** Something is changing the canvas: version actions wait */
|
||||
isBusy: boolean
|
||||
/** The latest version's change can be undone */
|
||||
canUndo: boolean
|
||||
/** The latest version's change was undone and can be put back */
|
||||
canRedo: boolean
|
||||
restore: (id: string) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
}
|
||||
|
||||
const VersionsContext = createContext<VersionsSource | null>(null)
|
||||
|
||||
export function VersionsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: VersionsSource
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<VersionsContext.Provider value={value}>
|
||||
{children}
|
||||
</VersionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVersionsContext(): VersionsSource {
|
||||
const source = useContext(VersionsContext)
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"useVersionsContext must be used within a VersionsProvider",
|
||||
)
|
||||
}
|
||||
return source
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Plus } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
createTemplate,
|
||||
type TemplateCreateInput,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
interface TemplateCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function TemplateCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
initialPrompt = "",
|
||||
}: TemplateCreateDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset form when dialog opens with the latest initialPrompt
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setPrompt(initialPrompt)
|
||||
setPinned(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, initialPrompt])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const input: TemplateCreateInput = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const template = await createTemplate(input)
|
||||
if (template) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.createFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create template:", err)
|
||||
setError(dict.templates.createFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
{dict.templates.createTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.createDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt" className="text-foreground">
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-foreground">
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.templates.createButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Edit2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Bookmark, Edit2, Plus } from "lucide-react"
|
||||
import { useEffect, useId, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,22 +16,32 @@ import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||
import {
|
||||
createTemplate,
|
||||
type Template,
|
||||
updateTemplate,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
interface TemplateEditDialogProps {
|
||||
interface TemplateFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
template: Template | null
|
||||
onSuccess: () => void
|
||||
/** The template to edit; without one the dialog creates a new template */
|
||||
template?: Template | null
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function TemplateEditDialog({
|
||||
// One form for creating and editing a template
|
||||
export function TemplateFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateEditDialogProps) {
|
||||
template,
|
||||
initialPrompt = "",
|
||||
}: TemplateFormDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const id = useId()
|
||||
const isEdit = !!template
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
@@ -39,32 +49,22 @@ export function TemplateEditDialog({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||
// Fill the form each time the dialog opens, dropping cancelled edits
|
||||
useEffect(() => {
|
||||
if (open && template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
if (!open) return
|
||||
setTitle(template?.title || "")
|
||||
setDescription(template?.description || "")
|
||||
setPrompt(template ? template.prompt || "" : initialPrompt)
|
||||
setPinned(template?.pinned || false)
|
||||
setError(null)
|
||||
}, [open, template, initialPrompt])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// any form this dialog may be rendered in
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
if (!template) return
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
@@ -74,40 +74,59 @@ export function TemplateEditDialog({
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const failed = isEdit
|
||||
? dict.templates.updateFailed
|
||||
: dict.templates.createFailed
|
||||
try {
|
||||
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||
const fields = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || template.title,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const updated = await updateTemplate(template.id, updates)
|
||||
if (updated) {
|
||||
// An emptied title keeps the old one when editing; a new template
|
||||
// gets a default title from its prompt
|
||||
const result = template
|
||||
? await updateTemplate(template.id, {
|
||||
...fields,
|
||||
title: title.trim() || template.title,
|
||||
})
|
||||
: await createTemplate({
|
||||
...fields,
|
||||
title: title.trim() || undefined,
|
||||
})
|
||||
if (result) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.updateFailed)
|
||||
setError(failed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update template:", err)
|
||||
setError(dict.templates.updateFailed)
|
||||
console.error("Failed to save template:", err)
|
||||
setError(failed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="w-5 h-5" />
|
||||
{dict.templates.editTitle}
|
||||
{isEdit ? (
|
||||
<Edit2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5" />
|
||||
)}
|
||||
{isEdit
|
||||
? dict.templates.editTitle
|
||||
: dict.templates.createTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.editDescription}
|
||||
{isEdit
|
||||
? dict.templates.editDescription
|
||||
: dict.templates.createDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -115,14 +134,14 @@ export function TemplateEditDialog({
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-prompt"
|
||||
htmlFor={`${id}-prompt`}
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-prompt"
|
||||
id={`${id}-prompt`}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
@@ -134,13 +153,13 @@ export function TemplateEditDialog({
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-title"
|
||||
htmlFor={`${id}-title`}
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
id={`${id}-title`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
@@ -153,13 +172,13 @@ export function TemplateEditDialog({
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-description"
|
||||
htmlFor={`${id}-description`}
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
id={`${id}-description`}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
@@ -173,7 +192,7 @@ export function TemplateEditDialog({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="edit-pinned"
|
||||
htmlFor={`${id}-pinned`}
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
@@ -184,7 +203,7 @@ export function TemplateEditDialog({
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit-pinned"
|
||||
id={`${id}-pinned`}
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
@@ -208,7 +227,9 @@ export function TemplateEditDialog({
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.common.save}
|
||||
: isEdit
|
||||
? dict.common.save
|
||||
: dict.templates.createButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -23,63 +24,33 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatRelativeTime } from "@/lib/relative-time"
|
||||
import {
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
exportTemplates,
|
||||
getAllTemplates,
|
||||
importTemplates,
|
||||
incrementClickCount,
|
||||
incrementRunCount,
|
||||
searchTemplates,
|
||||
type Template,
|
||||
updateTemplate,
|
||||
validateImportData,
|
||||
} from "@/lib/template-storage"
|
||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||
import { TemplateFormDialog } from "./TemplateFormDialog"
|
||||
|
||||
interface TemplatePanelProps {
|
||||
setInput: (input: string) => void
|
||||
/** Whether it was sent */
|
||||
onSendTemplate?: (template: Template) => Promise<boolean>
|
||||
onSendTemplate: (template: Template) => Promise<boolean>
|
||||
currentInput?: string
|
||||
}
|
||||
|
||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||
if (!timestamp) return neverUsedText
|
||||
const now = Date.now()
|
||||
const diffMs = now - timestamp
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||
if (diffMins < 1) return rtf.format(0, "minute")
|
||||
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||
} catch {
|
||||
// Fallback if Intl.RelativeTimeFormat is not available
|
||||
if (diffMins < 1) return "<1m ago"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export function TemplatePanel({
|
||||
setInput,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
}: TemplatePanelProps) {
|
||||
const dict = useDictionary()
|
||||
const params = useParams<{ lang: string }>()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
@@ -111,10 +82,6 @@ export function TemplatePanel({
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
// Reload when a template is saved elsewhere, e.g. from the chat input
|
||||
window.addEventListener("templatesChanged", loadTemplates)
|
||||
return () =>
|
||||
window.removeEventListener("templatesChanged", loadTemplates)
|
||||
}, [loadTemplates])
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
@@ -133,7 +100,7 @@ export function TemplatePanel({
|
||||
const handleDuplicate = async (template: Template) => {
|
||||
const duplicated = await duplicateTemplate(
|
||||
template.id,
|
||||
dict.templates.copySuffix || "(copy)",
|
||||
dict.templates.copySuffix,
|
||||
)
|
||||
if (duplicated) {
|
||||
loadTemplates()
|
||||
@@ -179,23 +146,13 @@ export function TemplatePanel({
|
||||
|
||||
// Actually send the template
|
||||
const sendTemplate = async (template: Template) => {
|
||||
if (onSendTemplate) {
|
||||
// Sent first, while the chat is the one it was clicked in (the
|
||||
// counts wait for storage). A run only when it was sent (not
|
||||
// while a message is still being sent, or attachments read).
|
||||
const sending = onSendTemplate(template)
|
||||
// Increment click count only when actually sending
|
||||
await incrementClickCount(template.id)
|
||||
if (await sending) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
}
|
||||
} else {
|
||||
await incrementClickCount(template.id)
|
||||
// Fallback: just fill the input if no send callback provided
|
||||
setInput(template.prompt)
|
||||
// A run only when it was sent (not while a message is still being
|
||||
// sent, or attachments read)
|
||||
if (await onSendTemplate(template)) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
}
|
||||
setConfirmSendDialogOpen(false)
|
||||
setTemplateToSend(null)
|
||||
@@ -218,7 +175,7 @@ export function TemplatePanel({
|
||||
if (templates.length === 0) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.exportEmpty || "No templates to export",
|
||||
text: dict.templates.exportEmpty,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -260,8 +217,7 @@ export function TemplatePanel({
|
||||
if (!file) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text:
|
||||
dict.templates.importNoFile || "Please select a JSON file",
|
||||
text: dict.templates.importNoFile,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -370,7 +326,7 @@ export function TemplatePanel({
|
||||
<div className="mt-3">{importMessageBox}</div>
|
||||
)}
|
||||
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
@@ -500,7 +456,7 @@ export function TemplatePanel({
|
||||
<div className="relative shrink-0">
|
||||
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||
{template.runCount > 0
|
||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${template.lastUsedAt ? formatRelativeTime(template.lastUsedAt, params.lang, dict.sessionHistory.justNow) : dict.templates.neverUsed}`
|
||||
: dict.templates.neverUsed}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
@@ -517,10 +473,8 @@ export function TemplatePanel({
|
||||
}`}
|
||||
title={
|
||||
template.pinned
|
||||
? dict.templates
|
||||
.unpin || "Unpin"
|
||||
: dict.templates.pin ||
|
||||
"Pin"
|
||||
? dict.templates.unpin
|
||||
: dict.templates.pin
|
||||
}
|
||||
>
|
||||
<Bookmark
|
||||
@@ -545,10 +499,7 @@ export function TemplatePanel({
|
||||
handleDuplicate(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||
title={
|
||||
dict.templates.duplicate ||
|
||||
"Duplicate"
|
||||
}
|
||||
title={dict.templates.duplicate}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -570,13 +521,13 @@ export function TemplatePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
<TemplateEditDialog
|
||||
<TemplateFormDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
template={templateToEdit}
|
||||
@@ -591,12 +542,10 @@ export function TemplatePanel({
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.deleteTitle ||
|
||||
"Delete this template?"}
|
||||
{dict.templates.deleteTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.deleteDescription ||
|
||||
"This will permanently delete this template. This action cannot be undone."}
|
||||
{dict.templates.deleteDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -621,12 +570,10 @@ export function TemplatePanel({
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.confirmSendTitle ||
|
||||
"Replace current input?"}
|
||||
{dict.templates.confirmSendTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.confirmSendDescription ||
|
||||
"You have unsent content in the input. Sending this template will replace it."}
|
||||
{dict.templates.confirmSendDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -634,8 +581,7 @@ export function TemplatePanel({
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSend}>
|
||||
{dict.templates.confirmSendButton ||
|
||||
"Send Template"}
|
||||
{dict.templates.confirmSendButton}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
|
||||
export type ValidationStatus =
|
||||
| "idle"
|
||||
| "capturing"
|
||||
| "validating"
|
||||
| "success"
|
||||
@@ -159,13 +158,10 @@ export function ValidationCard({
|
||||
color: "text-gray-600 bg-gray-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const statusDisplay = getStatusDisplay()
|
||||
if (!statusDisplay || state.status === "idle") return null
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||
|
||||
+127
-200
@@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react"
|
||||
import {
|
||||
type ChatStatus,
|
||||
DefaultChatTransport,
|
||||
getToolName,
|
||||
isToolUIPart,
|
||||
type UIMessage,
|
||||
} from "ai"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import type React from "react"
|
||||
@@ -19,14 +19,10 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import {
|
||||
keepFileVars,
|
||||
useDiagramToolHandlers,
|
||||
} from "@/hooks/use-diagram-tool-handlers"
|
||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
getSelectedAIConfig,
|
||||
@@ -34,13 +30,18 @@ import {
|
||||
useModelConfig,
|
||||
} from "@/hooks/use-model-config"
|
||||
import {
|
||||
getSessionVersions,
|
||||
type SessionData,
|
||||
useSessionManager,
|
||||
} from "@/hooks/use-session-manager"
|
||||
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
type ChatToolName,
|
||||
type ChatUIMessage,
|
||||
type MessageSelection,
|
||||
noticeMessage,
|
||||
} from "@/lib/chat-message"
|
||||
import { buildChatHeaders } from "@/lib/chat-request"
|
||||
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
|
||||
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
|
||||
@@ -80,7 +81,7 @@ const SAVE_DEBOUNCE_MS = 1000
|
||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||
* A call the user stopped is not one to retry.
|
||||
*/
|
||||
function hasToolErrors(messages: UIMessage[]): boolean {
|
||||
function hasToolErrors(messages: ChatUIMessage[]): boolean {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role !== "assistant") return false
|
||||
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
|
||||
@@ -107,45 +108,23 @@ function viewedPageId(): string | null {
|
||||
return useCanvasStore.getState().currentPageId
|
||||
}
|
||||
|
||||
// Shapes sent with a user message (also kept in its metadata)
|
||||
type MessageSelection = Pick<SelectedCell, "id" | "label">[]
|
||||
|
||||
function selectionOf(message: UIMessage): MessageSelection {
|
||||
const meta = message.metadata as
|
||||
| { selectedCells?: MessageSelection }
|
||||
| undefined
|
||||
return Array.isArray(meta?.selectedCells) ? meta.selectedCells : []
|
||||
function selectionOf(message: ChatUIMessage): MessageSelection {
|
||||
const selected = message.metadata?.selectedCells
|
||||
return Array.isArray(selected) ? selected : []
|
||||
}
|
||||
|
||||
function newLangfuseSessionId() {
|
||||
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
const XML_PROLOG =
|
||||
/^(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>)*/i
|
||||
|
||||
/**
|
||||
* Read the diagram document out of a .drawio, .xml or .drawio.svg file, with
|
||||
* its pages inflated: previews and the model read it before draw.io saves
|
||||
* it again. A broken or cut off file gives null.
|
||||
*/
|
||||
async function readDiagramFile(file: File): Promise<string | null> {
|
||||
const raw = await file.text()
|
||||
// What XML allows before the root element: the declaration, comments,
|
||||
// a DOCTYPE (the parser below expects the root first)
|
||||
let text = raw.replace(XML_PROLOG, "")
|
||||
// Editable SVG: the diagram is stored in the root's `content` attribute.
|
||||
// Parsed whole: its DOCTYPE may define entities the content uses.
|
||||
if (/^<(?:[\w.-]+:)?svg[\s>]/i.test(text)) {
|
||||
const doc = new DOMParser().parseFromString(raw, "image/svg+xml")
|
||||
text = (doc.documentElement?.getAttribute("content") ?? "").replace(
|
||||
XML_PROLOG,
|
||||
"",
|
||||
)
|
||||
}
|
||||
const loaded = parseDrawioFileContent(text)
|
||||
if (!loaded.ok) return null
|
||||
return withUniquePageIds(loaded.xml)
|
||||
const loaded = parseDrawioFileContent(await file.text())
|
||||
return loaded.ok ? withUniquePageIds(loaded.xml) : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,15 +169,13 @@ function withUniquePageIds(xml: string): string {
|
||||
class FileReadError extends Error {}
|
||||
|
||||
export interface ChatEngine {
|
||||
messages: UIMessage[]
|
||||
messages: ChatUIMessage[]
|
||||
status: ChatStatus
|
||||
error: Error | undefined
|
||||
isBusy: boolean
|
||||
/** The chat on screen is being left (saved, then another one shown) */
|
||||
isLeaving: boolean
|
||||
/** A message is being sent again; the chat is not empty meanwhile */
|
||||
isResending: boolean
|
||||
setMessages: (messages: UIMessage[]) => void
|
||||
setMessages: (messages: ChatUIMessage[]) => void
|
||||
stop: () => void
|
||||
|
||||
input: string
|
||||
@@ -267,11 +244,10 @@ export function ChatEngineProvider({
|
||||
}) {
|
||||
const {
|
||||
loadDiagram: onDisplayChart,
|
||||
handleExport: onExport,
|
||||
exportResolversRef,
|
||||
fetchDiagramXml,
|
||||
chartXML,
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
latestSvg,
|
||||
chartXMLRef,
|
||||
latestSvgRef,
|
||||
clearDiagram,
|
||||
getThumbnailSvg,
|
||||
captureValidationPng,
|
||||
@@ -284,26 +260,10 @@ export function ChatEngineProvider({
|
||||
const searchParams = useSearchParams()
|
||||
const urlSessionId = searchParams.get("session")
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const vlmValidationEnabled = useSettingsStore((s) => s.vlmValidationEnabled)
|
||||
const openSettings = useUiStore((s) => s.openSettings)
|
||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||
|
||||
const onFetchChart = () => {
|
||||
// Waits for the reply to its own export, by its tag
|
||||
const tag = onExport()
|
||||
return Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
if (tag) exportResolversRef.current[tag] = resolve
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
delete exportResolversRef.current[tag]
|
||||
reject(new Error("Chart export timed out after 10 seconds"))
|
||||
}, 10000)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({
|
||||
tooLong: dict.errors.fileTooLong,
|
||||
readFailed: dict.errors.fileReadFailed,
|
||||
@@ -318,9 +278,6 @@ export function ChatEngineProvider({
|
||||
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
|
||||
|
||||
const [input, setInputState] = useState("")
|
||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||
const [tpmLimit, setTpmLimit] = useState(0)
|
||||
|
||||
// Restore the unsent input from sessionStorage
|
||||
useEffect(() => {
|
||||
@@ -340,21 +297,7 @@ export function ChatEngineProvider({
|
||||
else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
||||
setTpmLimit(data.tpmLimit || 0)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const quotaManager = useQuotaManager({
|
||||
dailyRequestLimit,
|
||||
dailyTokenLimit,
|
||||
tpmLimit,
|
||||
onConfigModel: () => openSettings("models"),
|
||||
})
|
||||
|
||||
@@ -392,20 +335,12 @@ export function ChatEngineProvider({
|
||||
const hasRestoredRef = useRef(false)
|
||||
const [isRestored, setIsRestored] = useState(false)
|
||||
|
||||
// Latest chartXML for callbacks (avoids stale closures)
|
||||
const chartXMLRef = useRef(chartXML)
|
||||
// Session loaded without a diagram (prevents thumbnail contamination)
|
||||
const justLoadedSessionIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
chartXMLRef.current = chartXML
|
||||
if (chartXML) justLoadedSessionIdRef.current = null
|
||||
}, [chartXML])
|
||||
|
||||
const latestSvgRef = useRef(latestSvg)
|
||||
useEffect(() => {
|
||||
latestSvgRef.current = latestSvg
|
||||
}, [latestSvg])
|
||||
|
||||
const autoRetryCountRef = useRef(0)
|
||||
const continuationRetryCountRef = useRef(0)
|
||||
// Partial XML when output was truncated; non-empty means continuation mode
|
||||
@@ -424,9 +359,6 @@ export function ChatEngineProvider({
|
||||
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
|
||||
// SDK has put it back
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
// Presses of Stop: a check that began before one still knows of it after
|
||||
// the next message clears stoppedRef
|
||||
const stopCountRef = useRef(0)
|
||||
@@ -458,13 +390,13 @@ export function ChatEngineProvider({
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
// A preview undone just before the tool call is in this one already
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
chartXMLRef,
|
||||
turnPageIdRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onFetchChart: fetchDiagramXml,
|
||||
captureValidationPng,
|
||||
validateDiagram: validateWithFallback,
|
||||
enableVlmValidation: settings.vlmValidationEnabled,
|
||||
enableVlmValidation: vlmValidationEnabled,
|
||||
sessionId: langfuseSessionId,
|
||||
watchStop: () => {
|
||||
const stopsBefore = stopCountRef.current
|
||||
@@ -492,9 +424,40 @@ export function ChatEngineProvider({
|
||||
onDisplayChart(original, true, "revert")
|
||||
}
|
||||
|
||||
// Requests made in the turn so far. The first carries the diagram the
|
||||
// message was sent with (exported then, or the snapshot it is sent
|
||||
// again from). Each later one, an automatic retry after a tool error,
|
||||
// carries the canvas as it is then: a drawing is committed before its
|
||||
// screenshot check can fail, and the model must go on from that
|
||||
// drawing, not from the one before the turn.
|
||||
const turnRequestsRef = useRef(0)
|
||||
const transport = useMemo(
|
||||
() => new DefaultChatTransport({ api: getApiEndpoint("/api/chat") }),
|
||||
[],
|
||||
() =>
|
||||
new DefaultChatTransport<ChatUIMessage>({
|
||||
api: getApiEndpoint("/api/chat"),
|
||||
prepareSendMessagesRequest: ({
|
||||
id,
|
||||
messages,
|
||||
body,
|
||||
trigger,
|
||||
messageId,
|
||||
}) => ({
|
||||
body: {
|
||||
...body,
|
||||
...(turnRequestsRef.current++ > 0 && {
|
||||
xml: getPageXml(
|
||||
chartXMLRef.current,
|
||||
turnPageIdRef.current,
|
||||
),
|
||||
}),
|
||||
id,
|
||||
messages,
|
||||
trigger,
|
||||
messageId,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[chartXMLRef],
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -505,19 +468,19 @@ export function ChatEngineProvider({
|
||||
error,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
} = useChat<ChatUIMessage>({
|
||||
transport,
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutputSafely)
|
||||
},
|
||||
onFinish: ({ message, isAbort, isError }) => {
|
||||
onFinish: ({ message, isAbort, isError, finishReason }) => {
|
||||
// Stopped, failed, or the reply just ended (a closed connection):
|
||||
// tool calls still streaming never reach the tool handler. Mark
|
||||
// them handled so a later render of the stream does not draw
|
||||
// their preview again.
|
||||
const cut = (message.parts as any[]).filter(
|
||||
(part) => part.state === "input-streaming" && part.toolCallId,
|
||||
)
|
||||
const cut = message.parts
|
||||
.filter(isToolUIPart)
|
||||
.filter((part) => part.state === "input-streaming")
|
||||
for (const part of cut) {
|
||||
processedToolCallsRef.current.add(part.toolCallId)
|
||||
}
|
||||
@@ -526,18 +489,16 @@ export function ChatEngineProvider({
|
||||
// or the page going, aborts a reply). Not once another chat is
|
||||
// on screen: the call is not among its messages.
|
||||
if (isAbort && getChatGeneration() === turnChatRef.current) {
|
||||
for (const part of cut) {
|
||||
addToolOutputSafely({
|
||||
tool: part.type.replace("tool-", ""),
|
||||
toolCallId: part.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: "Stopped by user",
|
||||
})
|
||||
}
|
||||
for (const part of cut) stopToolCall(part)
|
||||
}
|
||||
if (isAbort || isError || cut.length === 0) return
|
||||
// Ended with no error: undo the preview, and say so, as an
|
||||
// error does (with Retry)
|
||||
if (isAbort || isError) return
|
||||
// The output budget ran out before the model called a tool (its
|
||||
// thinking took it all): nothing was drawn
|
||||
const outOfTokens =
|
||||
finishReason === "length" && !message.parts.some(isToolUIPart)
|
||||
if (cut.length === 0 && !outOfTokens) return
|
||||
// Ended with no error and no diagram: undo the preview, and say
|
||||
// so, as an error does (with Retry)
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
@@ -546,16 +507,11 @@ export function ChatEngineProvider({
|
||||
if (getChatGeneration() !== turnChatRef.current) return
|
||||
setMessages((currentMessages) => [
|
||||
...currentMessages,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: dict.errors.answerEndedEarly,
|
||||
},
|
||||
],
|
||||
},
|
||||
noticeMessage({
|
||||
text: outOfTokens
|
||||
? dict.errors.llm.output_truncated
|
||||
: dict.errors.answerEndedEarly,
|
||||
}),
|
||||
])
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -624,19 +580,15 @@ export function ChatEngineProvider({
|
||||
// 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
|
||||
// In the chat, so it is cleared with the conversation; with a
|
||||
// button that opens model settings, on the page of the provider
|
||||
// that refused
|
||||
setMessages((currentMessages) => [
|
||||
...currentMessages,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
parts: [{ type: "text" as const, text }],
|
||||
// The message shows a button that opens model settings,
|
||||
// on the page of the provider that refused
|
||||
...(openModelConfig && {
|
||||
metadata: { openModelConfig: true, providerId },
|
||||
}),
|
||||
},
|
||||
noticeMessage({
|
||||
text,
|
||||
...(openModelConfig && { openModelConfig, providerId }),
|
||||
}),
|
||||
])
|
||||
|
||||
if (isAccessCodeError) openSettings("general")
|
||||
@@ -666,9 +618,19 @@ export function ChatEngineProvider({
|
||||
)
|
||||
}
|
||||
|
||||
// A call the user stopped before it reached the tool handler. The SDK
|
||||
// finds the call by its id; the tool name is for the types only.
|
||||
const stopToolCall = (part: Parameters<typeof getToolName>[0]) =>
|
||||
addToolOutputSafely({
|
||||
tool: getToolName(part) as ChatToolName,
|
||||
toolCallId: part.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: "Stopped by user",
|
||||
})
|
||||
|
||||
// After each request: send the next one (retry, continue a cut off
|
||||
// drawing) or end the turn
|
||||
const shouldSendAgain = (messages: UIMessage[]) => {
|
||||
const shouldSendAgain = (messages: ChatUIMessage[]) => {
|
||||
// The user stopped: a tool result that arrives later (a VLM
|
||||
// check still running) must not start a new request
|
||||
if (stoppedRef.current) return false
|
||||
@@ -720,12 +682,14 @@ export function ChatEngineProvider({
|
||||
}
|
||||
|
||||
const isBusy = status === "streaming" || status === "submitted"
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) setIsResending(false)
|
||||
}, [messages.length])
|
||||
// isBusy of the latest render, for code that runs after an await
|
||||
const busyRef = useRef(isBusy)
|
||||
busyRef.current = isBusy
|
||||
// Canvas components (version cards, the compare dialog, "ask AI") read
|
||||
// the busy flag from the canvas store
|
||||
useEffect(() => {
|
||||
useCanvasStore.getState().set({ isBusy })
|
||||
}, [isBusy])
|
||||
// The page goes (another language mounts a new one): the answer stops,
|
||||
// so it cannot reach the next page's canvas
|
||||
const stopRef = useRef(stop)
|
||||
@@ -829,16 +793,10 @@ export function ChatEngineProvider({
|
||||
const [shownChats, setShownChats] = useState(0)
|
||||
// The messages shown when another chat was put on screen: useChat can
|
||||
// show that chat's own up to 150 ms later than the rest of its state
|
||||
const messagesBeforeShownRef = useRef<UIMessage[] | null>(null)
|
||||
const messagesBeforeShownRef = useRef<ChatUIMessage[] | null>(null)
|
||||
|
||||
const syncUIWithSession = useCallback(
|
||||
(
|
||||
data:
|
||||
| (SessionData & {
|
||||
diagramHistory?: { svg: string; xml: string }[]
|
||||
})
|
||||
| null,
|
||||
) => {
|
||||
(data: SessionData | null) => {
|
||||
// An answer still coming in belongs to the chat that was on
|
||||
// screen (browser back or forward): it stops, before this chat's
|
||||
// messages are put in its place, so none of it lands here
|
||||
@@ -875,26 +833,17 @@ export function ChatEngineProvider({
|
||||
loadedMessageIdsRef.current = new Set(
|
||||
(data.messages as { id: string }[]).map((m) => m.id),
|
||||
)
|
||||
setMessages(data.messages as unknown as UIMessage[])
|
||||
setMessages(data.messages as unknown as ChatUIMessage[])
|
||||
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
||||
if (data.diagramXml) {
|
||||
onDisplayChart(data.diagramXml, true)
|
||||
chartXMLRef.current = data.diagramXml
|
||||
} else {
|
||||
clearDiagram()
|
||||
chartXMLRef.current = ""
|
||||
}
|
||||
if (data.diagramXml) onDisplayChart(data.diagramXml, true)
|
||||
else clearDiagram()
|
||||
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
|
||||
useVersionsStore
|
||||
.getState()
|
||||
.setVersions(getSessionVersions(data))
|
||||
useVersionsStore.getState().setVersions(data.versions ?? [])
|
||||
} else {
|
||||
loadedMessageIdsRef.current = new Set()
|
||||
setMessages([])
|
||||
xmlSnapshotsRef.current.clear()
|
||||
clearDiagram()
|
||||
chartXMLRef.current = ""
|
||||
latestSvgRef.current = ""
|
||||
useVersionsStore.getState().clear()
|
||||
}
|
||||
},
|
||||
@@ -927,7 +876,6 @@ export function ChatEngineProvider({
|
||||
if (isRealDiagram(currentDiagramXml) && options.withThumbnail) {
|
||||
const freshThumb = await getThumbnailSvg()
|
||||
if (freshThumb) {
|
||||
latestSvgRef.current = freshThumb
|
||||
thumbnailDataUrl = freshThumb
|
||||
} else if (latestSvgRef.current) {
|
||||
thumbnailDataUrl = latestSvgRef.current
|
||||
@@ -1192,12 +1140,15 @@ export function ChatEngineProvider({
|
||||
: ""
|
||||
}
|
||||
|
||||
// messageId: the user message sent again (regenerate, edit, retry); the
|
||||
// SDK puts the new one in its place and drops the answers after it
|
||||
const sendChatMessage = (
|
||||
parts: any,
|
||||
xml: string,
|
||||
previousXml: string,
|
||||
turnIndex: number,
|
||||
selectedCells: MessageSelection,
|
||||
messageId?: string,
|
||||
) => {
|
||||
autoRetryCountRef.current = 0
|
||||
continuationRetryCountRef.current = 0
|
||||
@@ -1235,9 +1186,11 @@ export function ChatEngineProvider({
|
||||
maxOutputTokens,
|
||||
}),
|
||||
}
|
||||
turnRequestsRef.current = 0
|
||||
// The selection stays with the message, for regenerate and retry
|
||||
sendMessage(
|
||||
{
|
||||
...(messageId !== undefined && { messageId }),
|
||||
parts,
|
||||
...(selected.length > 0 && {
|
||||
metadata: { selectedCells: selected },
|
||||
@@ -1256,7 +1209,7 @@ export function ChatEngineProvider({
|
||||
selectedCells: MessageSelection = [],
|
||||
generation = getChatGeneration(),
|
||||
): Promise<boolean> => {
|
||||
const chartXml = formatXML(await onFetchChart())
|
||||
const chartXml = formatXML(await fetchDiagramXml())
|
||||
if (getChatGeneration() !== generation || leavingRef.current) {
|
||||
return false
|
||||
}
|
||||
@@ -1339,12 +1292,8 @@ export function ChatEngineProvider({
|
||||
})
|
||||
if (prepared.ok) {
|
||||
const pageId = viewedPageId()
|
||||
const canvasXml = chartXMLRef.current || ""
|
||||
onDisplayChart(
|
||||
keepFileVars(
|
||||
placeOnPage(prepared.xml, canvasXml, pageId),
|
||||
canvasXml,
|
||||
),
|
||||
placeOnPage(prepared.xml, chartXMLRef.current, pageId),
|
||||
true,
|
||||
"commit",
|
||||
{ toolCallId, pageId },
|
||||
@@ -1432,8 +1381,8 @@ export function ChatEngineProvider({
|
||||
}
|
||||
|
||||
// Templates fill the input and send it right away (attachments included)
|
||||
// Whether it was sent (it can run after a wait: the template panel
|
||||
// stores its click count first, so busy is read from the ref)
|
||||
// Whether it was sent (it can run after a wait, so busy is read from
|
||||
// the ref)
|
||||
const sendTemplate = async (prompt: string): Promise<boolean> => {
|
||||
// Like the send button, it waits for an answer or a message being
|
||||
// prepared (that message may still come back to the input), and for
|
||||
@@ -1473,11 +1422,6 @@ export function ChatEngineProvider({
|
||||
}
|
||||
}
|
||||
|
||||
const restoreDiagramFromSnapshot = (savedXml: string) => {
|
||||
onDisplayChart(savedXml, true)
|
||||
chartXMLRef.current = savedXml
|
||||
}
|
||||
|
||||
// Snapshots of the messages after this one go with them
|
||||
const dropLaterSnapshots = (messageIndex: number) => {
|
||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||
@@ -1495,22 +1439,17 @@ export function ChatEngineProvider({
|
||||
return false
|
||||
}
|
||||
const previousXml = getPreviousXml(index)
|
||||
restoreDiagramFromSnapshot(savedXml)
|
||||
onDisplayChart(savedXml, true)
|
||||
dropLaterSnapshots(index)
|
||||
useVersionsStore.getState().removeFromTurn(index)
|
||||
|
||||
// Remove the user message onwards (sendMessage re-adds it). For a
|
||||
// moment there may be no message: that is no empty chat.
|
||||
flushSync(() => {
|
||||
setIsResending(true)
|
||||
setMessages(messages.slice(0, index))
|
||||
})
|
||||
sendChatMessage(
|
||||
parts,
|
||||
getPageXml(savedXml, viewedPageId()),
|
||||
previousXml,
|
||||
index,
|
||||
selectionOf(messages[index]),
|
||||
messages[index].id,
|
||||
)
|
||||
|
||||
return true
|
||||
@@ -1563,7 +1502,7 @@ export function ChatEngineProvider({
|
||||
// Like submit: the diagram export comes first
|
||||
preparingSendRef.current = true
|
||||
try {
|
||||
const chartXml = formatXML(await onFetchChart())
|
||||
const chartXml = formatXML(await fetchDiagramXml())
|
||||
// Another chat came on screen meanwhile (browser back/forward),
|
||||
// or this one is being left
|
||||
if (getChatGeneration() !== generation || leavingRef.current) {
|
||||
@@ -1576,16 +1515,13 @@ export function ChatEngineProvider({
|
||||
chartXMLRef.current || chartXml,
|
||||
)
|
||||
}
|
||||
flushSync(() => {
|
||||
setIsResending(true)
|
||||
setMessages(messagesRef.current.slice(0, index))
|
||||
})
|
||||
sendChatMessage(
|
||||
message.parts,
|
||||
chartXml,
|
||||
getPreviousXml(index),
|
||||
index,
|
||||
selectionOf(message),
|
||||
message.id,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error fetching chart data:", error)
|
||||
@@ -1604,25 +1540,17 @@ export function ChatEngineProvider({
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
// Calls the tool handler already took can still show as streaming:
|
||||
// the messages update at most every 150 ms (useChat throttle)
|
||||
const toolParts = lastMessage?.parts?.filter(
|
||||
(part: any) =>
|
||||
part.type?.startsWith("tool-") &&
|
||||
part.state === "input-streaming" &&
|
||||
!processedToolCallsRef.current.has(part.toolCallId),
|
||||
)
|
||||
for (const part of (toolParts ?? []) as any[]) {
|
||||
if (part.toolCallId) {
|
||||
addToolOutputSafely({
|
||||
tool: part.type.replace("tool-", ""),
|
||||
toolCallId: part.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: "Stopped by user",
|
||||
})
|
||||
}
|
||||
}
|
||||
const streaming = (lastMessage?.parts ?? [])
|
||||
.filter(isToolUIPart)
|
||||
.filter(
|
||||
(part) =>
|
||||
part.state === "input-streaming" &&
|
||||
!processedToolCallsRef.current.has(part.toolCallId),
|
||||
)
|
||||
for (const part of streaming) stopToolCall(part)
|
||||
stop()
|
||||
abandonContinuation()
|
||||
}, [messages, addToolOutputSafely, stop, cancelValidation])
|
||||
}, [messages, stopToolCall, stop, cancelValidation])
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Session actions
|
||||
@@ -1932,8 +1860,7 @@ export function ChatEngineProvider({
|
||||
error,
|
||||
isBusy,
|
||||
isLeaving,
|
||||
isResending,
|
||||
setMessages: setMessages as (messages: UIMessage[]) => void,
|
||||
setMessages: setMessages as (messages: ChatUIMessage[]) => void,
|
||||
stop: handleStop,
|
||||
input,
|
||||
setInput,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { useRef } from "react"
|
||||
import { VersionStrip } from "@/components/canvas/version-strip"
|
||||
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
|
||||
import { BrandMark } from "@/components/workspace/brand-mark"
|
||||
import { IconButton } from "@/components/workspace/icon-button"
|
||||
@@ -21,11 +22,11 @@ import {
|
||||
useSettingsStore,
|
||||
} from "@/stores/settings-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { Composer } from "./composer"
|
||||
import { MessageList } from "./message-list"
|
||||
import { SessionMenu, useSessionTitle } from "./session-menu"
|
||||
import { VersionStrip } from "./version-strip"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
@@ -97,6 +98,25 @@ export function ChatPanel({
|
||||
const openSettings = useUiStore((s) => s.openSettings)
|
||||
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
|
||||
|
||||
// Versions with no card in the chat: saved by older app versions, from
|
||||
// a turn that was retried, or drawn by a call that then failed its
|
||||
// screenshot check (a call still running gets its card when done). The
|
||||
// strip shows them even when there is only one.
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const hasCardless = versions.some(
|
||||
(v) =>
|
||||
!v.toolCallId ||
|
||||
!engine.messages.some((m) =>
|
||||
m.parts?.some((p) => {
|
||||
const part = p as { toolCallId?: string; state?: string }
|
||||
return (
|
||||
part.toolCallId === v.toolCallId &&
|
||||
part.state !== "output-error"
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -160,7 +180,7 @@ export function ChatPanel({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<VersionStrip />
|
||||
<VersionStrip minVersions={hasCardless ? 1 : 2} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
VersionsProvider,
|
||||
type VersionsSource,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
/**
|
||||
* The chat's versions for the canvas components (version cards, the strip,
|
||||
* the compare dialog): the version store's list and flags, and the chat
|
||||
* engine's restore and undo
|
||||
*/
|
||||
export function ChatVersionsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { restoreVersion, undoVersion } = useChatEngine()
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const onCanvasId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const undoneId = useVersionsStore((s) => s.undoneVersionId)
|
||||
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||
|
||||
// Only the latest version can be undone, and only while it is (or was)
|
||||
// on the canvas: after an older version was restored, the latest one
|
||||
// is restored too
|
||||
const latest = versions.at(-1)
|
||||
const undoable = !!latest && latest.beforeXml !== undefined
|
||||
const value: VersionsSource = {
|
||||
versions,
|
||||
onCanvasId,
|
||||
undoneId,
|
||||
isBusy,
|
||||
canUndo: undoable && latest.id === onCanvasId,
|
||||
canRedo: undoable && latest.id === undoneId,
|
||||
restore: restoreVersion,
|
||||
// The engine's undo toggles: it redoes a change that is undone
|
||||
undo: () => latest && undoVersion(latest.id),
|
||||
redo: () => latest && undoVersion(latest.id),
|
||||
}
|
||||
|
||||
return <VersionsProvider value={value}>{children}</VersionsProvider>
|
||||
}
|
||||
@@ -18,9 +18,8 @@ import {
|
||||
useState,
|
||||
} from "react"
|
||||
import { toast } from "sonner"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { FilePreviewList } from "@/components/file-preview-list"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import {
|
||||
@@ -46,24 +45,16 @@ import { useUiStore } from "@/stores/ui-store"
|
||||
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
function showErrorToast(message: React.ReactNode) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
||||
),
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
function showValidationErrors(errors: string[], dict: any) {
|
||||
if (errors.length === 0) return
|
||||
if (errors.length === 1) {
|
||||
showErrorToast(
|
||||
toast.error(
|
||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
return
|
||||
}
|
||||
showErrorToast(
|
||||
toast.error(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">
|
||||
{formatMessage(dict.errors.filesRejected, {
|
||||
@@ -83,6 +74,7 @@ function showValidationErrors(errors: string[], dict: any) {
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -277,10 +269,11 @@ export function Composer({
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
showErrorToast(
|
||||
toast.error(
|
||||
<span className="text-muted-foreground">
|
||||
{err instanceof Error ? err.message : dict.url.failed}
|
||||
</span>,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
} finally {
|
||||
setIsExtractingUrl(false)
|
||||
@@ -519,7 +512,7 @@ export function Composer({
|
||||
onSubmit={handleUrlExtract}
|
||||
isExtracting={isExtractingUrl}
|
||||
/>
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open={saveTemplateOpen}
|
||||
onOpenChange={setSaveTemplateOpen}
|
||||
onSuccess={() => setSaveTemplateOpen(false)}
|
||||
@@ -532,10 +525,6 @@ export function Composer({
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
|
||||
<TemplatePanel
|
||||
setInput={(value) => {
|
||||
setInput(value)
|
||||
setTemplatesOpen(false)
|
||||
}}
|
||||
currentInput={input}
|
||||
onSendTemplate={(template) => {
|
||||
setTemplatesOpen(false)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FileText, FileUp, History, Pencil } from "lucide-react"
|
||||
import { FileUp, History, Pencil } from "lucide-react"
|
||||
import type React from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { Composer } from "./composer"
|
||||
import { McpNotice } from "./mcp-notice"
|
||||
import { SessionMenu } from "./session-menu"
|
||||
|
||||
interface Example {
|
||||
@@ -12,7 +13,7 @@ interface Example {
|
||||
title: string
|
||||
description: string
|
||||
/** Picture of the result (or of the input, for "replicate" examples) */
|
||||
image?: string
|
||||
image: string
|
||||
prompt: string
|
||||
/** Bundled file sent with the prompt; matches the cached example */
|
||||
file?: { path: string; name: string; type: string }
|
||||
@@ -34,7 +35,6 @@ function ExampleTile({
|
||||
title,
|
||||
description,
|
||||
picture,
|
||||
plain,
|
||||
testId,
|
||||
}: {
|
||||
onClick: () => void
|
||||
@@ -42,8 +42,6 @@ function ExampleTile({
|
||||
/** Shown as the tooltip: the title alone names the example */
|
||||
description: string
|
||||
picture: React.ReactNode
|
||||
/** An icon on a tinted tile instead of a diagram on paper */
|
||||
plain?: boolean
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
@@ -54,12 +52,7 @@ function ExampleTile({
|
||||
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
|
||||
data-testid={testId}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-16 w-full items-center justify-center overflow-hidden rounded-lg",
|
||||
plain ? "bg-muted" : "sheet-light bg-white",
|
||||
)}
|
||||
>
|
||||
<span className="flex h-16 w-full items-center justify-center overflow-hidden rounded-lg sheet-light bg-white">
|
||||
{picture}
|
||||
</span>
|
||||
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
|
||||
@@ -86,6 +79,7 @@ export function LobbyHero({
|
||||
const dict = useDictionary()
|
||||
const t = dict.lobby
|
||||
const engine = useChatEngine()
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
|
||||
// Prompts and file names must stay as they are: cached responses match them
|
||||
const examples: Example[] = [
|
||||
@@ -181,6 +175,15 @@ export function LobbyHero({
|
||||
{t.lede}
|
||||
</p>
|
||||
|
||||
{/* MCP server notice, hidden on self-hosted deployments. The lede's
|
||||
margin already spans 24 px (20 px compact); pull the card up so
|
||||
16 px remain above it */}
|
||||
{!isSelfHosted && (
|
||||
<McpNotice
|
||||
className={cn("mb-5", compact ? "-mt-1" : "-mt-2")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Composer
|
||||
variant="hero"
|
||||
autoFocus={!compact}
|
||||
@@ -220,18 +223,13 @@ export function LobbyHero({
|
||||
description={example.description}
|
||||
testId={`example-${example.key}`}
|
||||
picture={
|
||||
example.image ? (
|
||||
// biome-ignore lint/performance/noImgElement: static preview
|
||||
<img
|
||||
src={getAssetUrl(example.image)}
|
||||
alt=""
|
||||
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
||||
/>
|
||||
) : (
|
||||
<FileText className="size-5 text-muted-foreground" />
|
||||
)
|
||||
// biome-ignore lint/performance/noImgElement: static preview
|
||||
<img
|
||||
src={getAssetUrl(example.image)}
|
||||
alt=""
|
||||
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
||||
/>
|
||||
}
|
||||
plain={!example.image}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { ArrowUpRight, Terminal, X } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MCP_URL =
|
||||
"https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
|
||||
/**
|
||||
* Whether the user closed the MCP video once. Starts as closed so the server
|
||||
* render and the first client render agree; the stored choice is read after
|
||||
* mount.
|
||||
*/
|
||||
function useVideoClosed() {
|
||||
const [closed, setClosed] = useState(true)
|
||||
useEffect(() => {
|
||||
setClosed(localStorage.getItem(STORAGE_KEYS.mcpVideoClosed) === "true")
|
||||
}, [])
|
||||
const close = () => {
|
||||
localStorage.setItem(STORAGE_KEYS.mcpVideoClosed, "true")
|
||||
setClosed(true)
|
||||
}
|
||||
return [closed, close] as const
|
||||
}
|
||||
|
||||
function PromoVideo({ className }: { className?: string }) {
|
||||
return (
|
||||
<video
|
||||
src={getAssetUrl("/mcp-promo.mp4")}
|
||||
poster={getAssetUrl("/mcp-promo-poster.jpg")}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
controls
|
||||
preload="metadata"
|
||||
className={cn("aspect-video w-full rounded-lg bg-black", className)}
|
||||
data-testid="mcp-video"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseButton({ onClick }: { onClick: () => void }) {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={dict.common.close}
|
||||
title={dict.common.close}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
data-testid="mcp-video-close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Icon, title and tagline of the MCP server, linking to its README */
|
||||
function NoticeLink({ className }: { className?: string }) {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<a
|
||||
href={MCP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("group flex min-w-0 items-center gap-3", className)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-purple-500/20">
|
||||
<Terminal className="size-4 text-purple-500" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground transition-colors group-hover:text-purple-500">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
<span className="block text-xs leading-relaxed text-balance text-muted-foreground">
|
||||
{dict.examples.mcpTagline}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-purple-500" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const cardClass =
|
||||
"w-full rounded-xl border border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-blue-500/10"
|
||||
|
||||
/**
|
||||
* MCP server notice on the start screen. Until the user closes it, the card
|
||||
* also plays the promo video; after that it is the plain card for good.
|
||||
*/
|
||||
export function McpNotice({ className }: { className?: string }) {
|
||||
const [closed, close] = useVideoClosed()
|
||||
if (closed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
cardClass,
|
||||
"px-4 py-3 transition-colors hover:border-purple-500/40",
|
||||
className,
|
||||
)}
|
||||
data-testid="mcp-notice"
|
||||
>
|
||||
<NoticeLink />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(cardClass, "p-3", className)}
|
||||
data-testid="mcp-notice"
|
||||
>
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<NoticeLink className="flex-1" />
|
||||
<CloseButton onClick={close} />
|
||||
</div>
|
||||
<PromoVideo className="mt-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UIMessage } from "ai"
|
||||
import { isToolUIPart, type UIMessage } from "ai"
|
||||
import {
|
||||
AlertCircle,
|
||||
BookmarkPlus,
|
||||
@@ -16,18 +16,15 @@ import {
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning"
|
||||
import { Reasoning } from "@/components/ai-elements/reasoning"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||
import type { ToolPartLike } from "@/components/chat/types"
|
||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { noticeOf } from "@/lib/chat-message"
|
||||
import { useCopy } from "@/lib/clipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
@@ -291,7 +288,7 @@ function UserMessage({
|
||||
{text && <MessageText text={text} />}
|
||||
</div>
|
||||
{saveTemplate && (
|
||||
<TemplateCreateDialog
|
||||
<TemplateFormDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
||||
onSuccess={() => setSaveTemplate(false)}
|
||||
@@ -373,7 +370,7 @@ function AssistantMessage({
|
||||
parts.forEach((part, partIndex) => {
|
||||
if (part.type === "reasoning")
|
||||
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
|
||||
else if (part.type?.startsWith("tool-"))
|
||||
else if (isToolUIPart(part))
|
||||
blocks.push({ kind: "tool", index: partIndex, texts: [] })
|
||||
else if (part.type === "text") {
|
||||
const text = (part as { text: string }).text
|
||||
@@ -398,13 +395,9 @@ function AssistantMessage({
|
||||
className="w-full"
|
||||
isStreaming={isStreamingReasoning}
|
||||
defaultOpen={!isRestored}
|
||||
getThinkingMessage={thinkingMessage}
|
||||
>
|
||||
<ReasoningTrigger
|
||||
getThinkingMessage={thinkingMessage}
|
||||
/>
|
||||
<ReasoningContent>
|
||||
{(part as { text: string }).text}
|
||||
</ReasoningContent>
|
||||
{(part as { text: string }).text}
|
||||
</Reasoning>
|
||||
)
|
||||
}
|
||||
@@ -576,9 +569,10 @@ export function MessageList() {
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const lastAssistantIndex = messages
|
||||
.map((m) => m.role)
|
||||
.lastIndexOf("assistant")
|
||||
// A notice (a failed request) is an assistant message without an answer
|
||||
const lastAssistantIndex = messages.findLastIndex(
|
||||
(m) => m.role === "assistant" && !noticeOf(m),
|
||||
)
|
||||
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
||||
|
||||
return (
|
||||
@@ -587,6 +581,7 @@ export function MessageList() {
|
||||
const isRestored = engine.loadedMessageIdsRef.current.has(
|
||||
message.id,
|
||||
)
|
||||
const notice = noticeOf(message)
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
@@ -598,33 +593,18 @@ export function MessageList() {
|
||||
index={index}
|
||||
isLast={index === lastUserIndex}
|
||||
/>
|
||||
) : message.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
index={index}
|
||||
isLastMessage={index === messages.length - 1}
|
||||
isLastAssistant={index === lastAssistantIndex}
|
||||
/>
|
||||
) : (
|
||||
) : notice ? (
|
||||
<ErrorNotice
|
||||
text={getMessageTextContent(message)}
|
||||
openModelConfig={
|
||||
!!(
|
||||
message.metadata as
|
||||
| { openModelConfig?: boolean }
|
||||
| undefined
|
||||
)?.openModelConfig
|
||||
}
|
||||
onOpenModelConfig={() => {
|
||||
const { providerId } =
|
||||
(message.metadata as
|
||||
| { providerId?: string }
|
||||
| undefined) ?? {}
|
||||
text={notice.text}
|
||||
openModelConfig={!!notice.openModelConfig}
|
||||
onOpenModelConfig={() =>
|
||||
openSettings(
|
||||
"models",
|
||||
providerId ? { providerId } : "list",
|
||||
notice.providerId
|
||||
? { providerId: notice.providerId }
|
||||
: "list",
|
||||
)
|
||||
}}
|
||||
}
|
||||
onRetry={
|
||||
index === messages.length - 1 &&
|
||||
!engine.isBusy &&
|
||||
@@ -633,6 +613,13 @@ export function MessageList() {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
index={index}
|
||||
isLastMessage={index === messages.length - 1}
|
||||
isLastAssistant={index === lastAssistantIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getToolName } from "ai"
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
@@ -5,19 +6,14 @@ import {
|
||||
Copy,
|
||||
Library,
|
||||
PenTool,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { VersionCard as VersionCardView } from "@/components/canvas/version-card"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { useCopy } from "@/lib/clipboard"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn, isMxCellXmlComplete } from "@/lib/utils"
|
||||
import { describeChanges, describeTotals } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
@@ -83,45 +79,9 @@ function toolText(part: ToolPartLike): string {
|
||||
return JSON.stringify(input, null, 2)
|
||||
}
|
||||
|
||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||
export function VersionThumb({
|
||||
svg,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
/** undefined or null: still being made; "": none could be made */
|
||||
svg: string | null | undefined
|
||||
dimmed?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const t = useDictionary().versions
|
||||
if (svg) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={svg}
|
||||
alt=""
|
||||
className={cn(
|
||||
"object-contain transition-opacity",
|
||||
dimmed && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (svg === "") {
|
||||
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||
}
|
||||
return (
|
||||
<Shimmer as="span" className="text-xs">
|
||||
{t.rendering}
|
||||
</Shimmer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for one diagram version produced by a tool call. The latest shows
|
||||
* its picture; older ones fold into one row (the picture opens Compare).
|
||||
* Card for one diagram version produced by a tool call: the shared card
|
||||
* with a link to what the AI wrote (shown in a panel under the card)
|
||||
*/
|
||||
function VersionCard({
|
||||
part,
|
||||
@@ -134,175 +94,49 @@ function VersionCard({
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const undoneVersionId = useVersionsStore((s) => s.undoneVersionId)
|
||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const [showCode, setShowCode] = useState(false)
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
const isUndone = undoneVersionId === version.id
|
||||
const isOnCanvas = onCanvasVersionId === version.id
|
||||
const isEdit = part.type === "tool-edit_diagram"
|
||||
// Undo/redo belongs to the newest change while it is (or was) on the
|
||||
// canvas; after an older version was restored, this one is restored too
|
||||
const canUndo =
|
||||
isLatest && version.beforeXml !== undefined && (isOnCanvas || isUndone)
|
||||
const title = isUndone ? t.undone : isEdit ? t.edited : t.created
|
||||
const summary = version.fromScratch
|
||||
? describeTotals(version.summary, t)
|
||||
: describeChanges(version.summary, t)
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||
isOnCanvas
|
||||
? "bg-marker-soft text-marker-ink"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
v{version.number}
|
||||
</span>
|
||||
)
|
||||
const actionClass =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
const restoreButton = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy || isOnCanvas}
|
||||
onClick={() => engine.restoreVersion(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-restore"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{formatMessage(t.restoreVersion, { n: version.number })}
|
||||
</button>
|
||||
)
|
||||
|
||||
// What the AI wrote: a text link in the summary line, so the card's
|
||||
// only button is the action (undo or restore)
|
||||
const summaryLine = (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary}
|
||||
{/* The link wraps as one unit with its separator */}
|
||||
<span className="whitespace-nowrap">
|
||||
<span aria-hidden> · </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCode((v) => !v)}
|
||||
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
||||
>
|
||||
{showCode ? t.hideCode : t.showCode}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
const codePanel = showCode && (
|
||||
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(toolText(part))}
|
||||
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
|
||||
aria-label={dict.chat.copyResponse}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<ToolInputDetails part={part} />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isLatest) {
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border border-border bg-card"
|
||||
data-testid="version-card"
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
||||
aria-label={t.compare}
|
||||
title={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
className="max-h-8 max-w-[52px]"
|
||||
/>
|
||||
</button>
|
||||
{badge}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{summaryLine}
|
||||
</div>
|
||||
{restoreButton}
|
||||
</div>
|
||||
{codePanel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
isOnCanvas && "border-foreground/15",
|
||||
)}
|
||||
data-testid="version-card"
|
||||
<VersionCardView
|
||||
version={version}
|
||||
isLatest={isLatest}
|
||||
label={isEdit ? t.edited : t.created}
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
||||
aria-label={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
dimmed={isUndone}
|
||||
className="max-h-20 max-w-[88%]"
|
||||
/>
|
||||
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
||||
{t.compare}
|
||||
</span>
|
||||
</button>
|
||||
{/* Title row with the action; the summary runs under both */}
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
||||
{badge}
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{canUndo ? (
|
||||
// A text link, so the card's only button is the action (undo
|
||||
// or restore); it wraps as one unit with its separator
|
||||
afterSummary={
|
||||
<span className="whitespace-nowrap">
|
||||
<span aria-hidden> · </span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy}
|
||||
onClick={() => engine.undoVersion(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-undo"
|
||||
onClick={() => setShowCode((v) => !v)}
|
||||
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
||||
>
|
||||
{isUndone ? (
|
||||
<Redo2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
{isUndone ? t.redo : t.undo}
|
||||
{showCode ? t.hideCode : t.showCode}
|
||||
</button>
|
||||
) : (
|
||||
restoreButton
|
||||
)}
|
||||
<div className="col-span-2 col-start-2 mt-0.5">
|
||||
{summaryLine}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{showCode && (
|
||||
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(toolText(part))}
|
||||
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
|
||||
aria-label={dict.chat.copyResponse}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<ToolInputDetails part={part} />
|
||||
</div>
|
||||
</div>
|
||||
{codePanel}
|
||||
</div>
|
||||
)}
|
||||
</VersionCardView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -331,7 +165,7 @@ function ToolRow({
|
||||
tone === "error" && "border-destructive/25",
|
||||
)}
|
||||
data-testid="tool-row"
|
||||
data-tool-name={part.type?.replace("tool-", "")}
|
||||
data-tool-name={getToolName(part)}
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<button
|
||||
@@ -413,7 +247,7 @@ export function ToolActivity({
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const toolName = getToolName(part)
|
||||
const { state, errorText, output } = part
|
||||
|
||||
const versionIndex = versions.findIndex(
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { DynamicToolUIPart, ToolUIPart, UITools } from "ai"
|
||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
|
||||
export type { DiagramOperation }
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: {
|
||||
xml?: string
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>
|
||||
/**
|
||||
* A tool part as the UI reads it, in any state: input, output and errorText
|
||||
* are each there or not, instead of one shape per state.
|
||||
*/
|
||||
export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & {
|
||||
input?:
|
||||
| ({
|
||||
xml?: string
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>)
|
||||
| undefined
|
||||
output?: string
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
@@ -5,25 +5,25 @@ import type { RefObject } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { pageSelectorFor } from "@/lib/diagram-pages"
|
||||
import {
|
||||
convertToLegalXml,
|
||||
extractCompleteMxCells,
|
||||
replaceNodes,
|
||||
} from "@/lib/utils"
|
||||
import { pageSelectorFor, replacePageModel } from "@/lib/diagram-pages"
|
||||
import { extractCompleteMxCells } from "@/lib/utils"
|
||||
import {
|
||||
defaultLayerOf,
|
||||
expandCompactCells,
|
||||
} from "@/packages/mcp-server/src/compact-cells.ts"
|
||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
import { targetPageXml } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
import {
|
||||
BLANK_MXFILE,
|
||||
wrapCellsInModel,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
import {
|
||||
addDefaultStyles,
|
||||
applyStyleClasses,
|
||||
edgeIdsOf,
|
||||
readStyleClasses,
|
||||
} from "@/packages/mcp-server/src/style-classes.ts"
|
||||
import { autoFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
|
||||
// Helper to extract complete operations from streaming input
|
||||
function getCompleteOperations(
|
||||
@@ -91,32 +91,31 @@ export function useDiagramStreaming({
|
||||
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
|
||||
const completeCells = extractCompleteMxCells(cellsXml)
|
||||
if (!completeCells) return
|
||||
const convertedXml = addDefaultStyles(
|
||||
// The same wrapping, fixes and expansion as the final diagram
|
||||
// (prepareNewDiagram), so the preview matches what is committed
|
||||
const model = addDefaultStyles(
|
||||
applyStyleClasses(
|
||||
expandCompactCells(convertToLegalXml(completeCells)),
|
||||
expandCompactCells(
|
||||
autoFixXml(wrapCellsInModel(completeCells)).fixed,
|
||||
),
|
||||
classes,
|
||||
),
|
||||
)
|
||||
if (convertedXml === previousXML.current) return
|
||||
if (model === previousXML.current) return
|
||||
|
||||
// Skip this update while the cells written so far don't parse
|
||||
const testDoc = new DOMParser().parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
const testDoc = new DOMParser().parseFromString(model, "text/xml")
|
||||
if (testDoc.querySelector("parsererror")) return
|
||||
|
||||
try {
|
||||
// Replace the model's page's cells so other pages stay
|
||||
// intact. An empty canvas gets a default mxfile to put the
|
||||
// cells in.
|
||||
const baseXML = chartXML || BLANK_MXFILE
|
||||
const replacedXML = replaceNodes(
|
||||
baseXML,
|
||||
convertedXml,
|
||||
// Replace the model's page so other pages stay intact; an
|
||||
// empty canvas gets the blank mxfile to put the page in
|
||||
const replacedXML = replacePageModel(
|
||||
chartXML,
|
||||
turnPageIdRef?.current,
|
||||
model,
|
||||
)
|
||||
previousXML.current = convertedXml
|
||||
previousXML.current = model
|
||||
loadDiagram(replacedXML, true, "preview")
|
||||
} catch (error) {
|
||||
console.error("Error processing XML:", error)
|
||||
|
||||
@@ -4,7 +4,10 @@ import { toast } from "sonner"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
|
||||
import {
|
||||
clearHighlights,
|
||||
highlightChangedCells,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
@@ -63,14 +66,7 @@ export function useVersions({
|
||||
fromScratch,
|
||||
})
|
||||
// Mark what changed, unless the whole diagram is new
|
||||
if (!fromScratch) {
|
||||
setTimeout(() => {
|
||||
const marker = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--marker")
|
||||
.trim()
|
||||
highlightCells(touchedIds, marker || "#ffd84d")
|
||||
}, 60)
|
||||
}
|
||||
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||
// The picture is of the canvas: take it right away, before a
|
||||
// newer version can be on it; once one is, this one gets none
|
||||
// rather than the newer one's
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
@@ -135,15 +134,12 @@ const DEV_XML_PRESETS: Record<string, string> = {
|
||||
interface DevXmlSimulatorProps {
|
||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||
onDisplayChart: (xml: string) => void
|
||||
onShowQuotaToast?: () => void
|
||||
}
|
||||
|
||||
export function DevXmlSimulator({
|
||||
setMessages,
|
||||
onDisplayChart,
|
||||
onShowQuotaToast,
|
||||
}: DevXmlSimulatorProps) {
|
||||
const dict = useDictionary()
|
||||
const [devXml, setDevXml] = useState("")
|
||||
const [isSimulating, setIsSimulating] = useState(false)
|
||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||
@@ -180,7 +176,7 @@ export function DevXmlSimulator({
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: dict.dev.simulatingMessage,
|
||||
text: "[Dev] Simulating XML streaming",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -230,7 +226,7 @@ export function DevXmlSimulator({
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].state = "output-available"
|
||||
lastMsg.parts[0].output = dict.dev.successMessage
|
||||
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||
lastMsg.parts[0].input = { xml }
|
||||
}
|
||||
return updated
|
||||
@@ -247,12 +243,12 @@ export function DevXmlSimulator({
|
||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||
<details>
|
||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||
{dict.dev.title}
|
||||
Dev: XML Streaming Simulator
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.preset}
|
||||
Preset:
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
@@ -264,7 +260,7 @@ export function DevXmlSimulator({
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
{dict.dev.selectPreset}
|
||||
Select a preset...
|
||||
</option>
|
||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
@@ -277,19 +273,19 @@ export function DevXmlSimulator({
|
||||
onClick={() => setDevXml("")}
|
||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||
>
|
||||
{dict.dev.clear}
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={devXml}
|
||||
onChange={(e) => setDevXml(e.target.value)}
|
||||
placeholder={dict.dev.placeholder}
|
||||
placeholder="Paste mxCell XML here or select a preset..."
|
||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.interval}
|
||||
Interval:
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -308,7 +304,7 @@ export function DevXmlSimulator({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.chars}
|
||||
Chars:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -332,8 +328,8 @@ export function DevXmlSimulator({
|
||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSimulating
|
||||
? dict.dev.streaming
|
||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
? "Streaming..."
|
||||
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
</button>
|
||||
{isSimulating && (
|
||||
<button
|
||||
@@ -343,16 +339,7 @@ export function DevXmlSimulator({
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
{dict.dev.stop}
|
||||
</button>
|
||||
)}
|
||||
{onShowQuotaToast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowQuotaToast}
|
||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||
>
|
||||
{dict.dev.testQuotaToast}
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
interface ErrorToastProps {
|
||||
message: React.ReactNode
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
tabIndex={0}
|
||||
onClick={onDismiss}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-destructive"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-foreground">{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// GitHub mark as an inline SVG, so no icon package is needed for one icon
|
||||
export function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -56,13 +55,13 @@ import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import type { ListedModel } from "@/lib/provider-models"
|
||||
import { hasCredentials } from "@/lib/provider-setup"
|
||||
import { PROVIDERS, publicDefaultUrl } from "@/lib/providers"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type {
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
@@ -164,7 +163,6 @@ export function ModelConfigDialog({
|
||||
)
|
||||
const [customModelInput, setCustomModelInput] = useState("")
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("")
|
||||
// Models whose test is running (they are all tested at once)
|
||||
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
@@ -302,19 +300,36 @@ export function ModelConfigDialog({
|
||||
const errorHints = (p: ProviderConfig): Record<string, string> => ({
|
||||
...(dict.errors.llm as Record<string, string>),
|
||||
invalid_api_key:
|
||||
p.provider === "bedrock" && !p.apiKey
|
||||
PROVIDERS[p.provider].credential === "aws" && !p.apiKey
|
||||
? t.awsKeysRejected
|
||||
: t.keyRejected,
|
||||
model_not_found: t.modelNotFound,
|
||||
})
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
// Requests in flight, by provider id: a second request for the same
|
||||
// provider (the button clicked while the automatic fetch runs, or the
|
||||
// reverse) is not sent
|
||||
const inFlight = useRef(new Set<string>())
|
||||
// The credentials each provider's automatic fetch ran with, or was
|
||||
// overtaken by a click with: one automatic fetch per set of credentials
|
||||
const autoFetched = useRef<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* Fetch the shown provider's model list. A click opens the picker with
|
||||
* the result and shows what went wrong; the automatic fetch after
|
||||
* credentials were entered (auto) only fills the list, so it never
|
||||
* takes the focus or flashes an error while the user is still typing.
|
||||
*/
|
||||
const handleFetchModels = async (auto = false) => {
|
||||
if (!selectedProvider) return
|
||||
const providerId = selectedProvider.id
|
||||
if (inFlight.current.has(providerId)) return
|
||||
const askedWith = credentialsOf(providerId)
|
||||
autoFetched.current[providerId] = askedWith
|
||||
const hints = errorHints(selectedProvider)
|
||||
inFlight.current.add(providerId)
|
||||
setFetchingFor((current) => [...current, providerId])
|
||||
setFetchModelsError("")
|
||||
if (!auto) setFetchModelsError("")
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/provider-models"),
|
||||
@@ -341,11 +356,11 @@ export function ModelConfigDialog({
|
||||
...current,
|
||||
[providerId]: { askedWith, models: data.models },
|
||||
}))
|
||||
if (!stillShown) return
|
||||
if (!stillShown || auto) return
|
||||
if (data.models.length > 0) setModelPickerOpen(true)
|
||||
else setFetchModelsError(t.noModelsReturned)
|
||||
} else {
|
||||
if (!stillShown) return
|
||||
if (!stillShown || auto) return
|
||||
setFetchModelsError(
|
||||
[hints[data.code], data.error].filter(Boolean).join(" ") ||
|
||||
formatMessage(t.requestFailed, {
|
||||
@@ -355,12 +370,14 @@ export function ModelConfigDialog({
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
!auto &&
|
||||
selectedProviderIdRef.current === providerId &&
|
||||
credentialsOf(providerId) === askedWith
|
||||
) {
|
||||
setFetchModelsError(dict.errors.networkError)
|
||||
}
|
||||
} finally {
|
||||
inFlight.current.delete(providerId)
|
||||
setFetchingFor((current) =>
|
||||
current.filter((id) => id !== providerId),
|
||||
)
|
||||
@@ -368,16 +385,18 @@ export function ModelConfigDialog({
|
||||
}
|
||||
|
||||
// The provider's own list, if fetched with the credentials it has now,
|
||||
// else the suggested models
|
||||
// else the record's short list (only providers that cannot list models
|
||||
// have one)
|
||||
const fetched = selectedProvider
|
||||
? fetchedModels[selectedProvider.id]
|
||||
: undefined
|
||||
const recordModels = selectedProvider
|
||||
? (PROVIDERS[selectedProvider.provider].models ?? [])
|
||||
: []
|
||||
const suggestedModels: ListedModel[] = selectedProvider
|
||||
? fetched && fetched.askedWith === credentialsOf(selectedProvider.id)
|
||||
? fetched.models
|
||||
: (SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({
|
||||
id,
|
||||
}))
|
||||
: recordModels.map((id) => ({ id }))
|
||||
: []
|
||||
// The picker closes when there is nothing left to pick from, so it does
|
||||
// not open by itself once there is again
|
||||
@@ -400,20 +419,60 @@ export function ModelConfigDialog({
|
||||
const availableSuggestions = suggestedModels.filter(
|
||||
(model) => !existingModelIds.includes(model.id),
|
||||
)
|
||||
// A few suggested models to add with one click
|
||||
const quickSuggestions = selectedProvider
|
||||
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
|
||||
.filter((modelId) => !existingModelIds.includes(modelId))
|
||||
.slice(0, 4)
|
||||
: []
|
||||
// A few of the record's models to add with one click
|
||||
const quickSuggestions = recordModels
|
||||
.filter((modelId) => !existingModelIds.includes(modelId))
|
||||
.slice(0, 4)
|
||||
|
||||
// A provider that can list its models gets its list fetched by itself,
|
||||
// once, for credentials entered on its page while it has no model yet,
|
||||
// so "Browse models" offers the provider's own list instead of a
|
||||
// hand-written one. Credentials a provider already had when its page
|
||||
// opened fetch nothing (the button does), nor does a provider with
|
||||
// models, nor a local example URL still in the field (SGLang's).
|
||||
const shownWith = useRef<{ id: string; credentials: string } | null>(null)
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProvider) {
|
||||
// The baseline belongs to one visit of one provider's page
|
||||
shownWith.current = null
|
||||
return
|
||||
}
|
||||
const id = selectedProvider.id
|
||||
const now = credentialsOf(id)
|
||||
if (shownWith.current?.id !== id) {
|
||||
shownWith.current = { id, credentials: now }
|
||||
}
|
||||
const def = PROVIDERS[selectedProvider.provider]
|
||||
const localExample =
|
||||
!!selectedProvider.baseUrl &&
|
||||
selectedProvider.baseUrl === def.defaultBaseUrl &&
|
||||
!publicDefaultUrl(selectedProvider.provider)
|
||||
if (
|
||||
!def.modelList ||
|
||||
selectedProvider.models.length > 0 ||
|
||||
!hasCredentials(selectedProvider) ||
|
||||
localExample ||
|
||||
now === shownWith.current.credentials ||
|
||||
autoFetched.current[id] === now
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Once the typing or pasting pauses
|
||||
const timer = setTimeout(() => {
|
||||
if (autoFetched.current[id] === now) return
|
||||
void handleFetchModels(true)
|
||||
}, 600)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open, selectedProvider])
|
||||
|
||||
const openProvider = (providerId: string) => setModelsPage({ providerId })
|
||||
|
||||
// Handle adding a new provider
|
||||
const handleAddProvider = (providerType: ProviderName) => {
|
||||
const newProvider = addProvider(providerType)
|
||||
// With the first suggested model picked, a key is all a test needs
|
||||
const firstModel = SUGGESTED_MODELS[providerType]?.[0]
|
||||
// A provider that cannot list its models starts with the first of
|
||||
// the record's; the others fetch their list once the key is in
|
||||
const firstModel = PROVIDERS[providerType].models?.[0]
|
||||
if (firstModel) addModel(newProvider.id, firstModel)
|
||||
openProvider(newProvider.id)
|
||||
}
|
||||
@@ -441,7 +500,6 @@ export function ModelConfigDialog({
|
||||
setFetchModelsError("")
|
||||
updates = {
|
||||
...updates,
|
||||
validated: false,
|
||||
models: selectedProvider.models.map((m) => ({
|
||||
...m,
|
||||
validated: undefined,
|
||||
@@ -533,9 +591,9 @@ export function ModelConfigDialog({
|
||||
const run = (latestTestRun.get(selectedProviderId) ?? 0) + 1
|
||||
latestTestRun.set(selectedProviderId, run)
|
||||
|
||||
// For EdgeOne, construct baseUrl from current origin
|
||||
// The site's own function (EdgeOne): its URL is this origin's
|
||||
const baseUrl =
|
||||
selectedProvider.provider === "edgeone"
|
||||
PROVIDERS[selectedProvider.provider].credential === "none"
|
||||
? `${window.location.origin}/api/edgeai`
|
||||
: selectedProvider.baseUrl
|
||||
const hints = errorHints(selectedProvider)
|
||||
@@ -659,10 +717,6 @@ export function ModelConfigDialog({
|
||||
return
|
||||
}
|
||||
|
||||
// A model whose id changed was not tested
|
||||
if (allValid && !idChanged) {
|
||||
updateProvider(selectedProviderId, { validated: true })
|
||||
}
|
||||
markRejected(selectedProviderId, askedWith, rejected)
|
||||
// The status line is about the provider shown now
|
||||
if (selectedProviderIdRef.current !== selectedProviderId) return
|
||||
@@ -748,7 +802,7 @@ export function ModelConfigDialog({
|
||||
}
|
||||
|
||||
const providerName = (p: ProviderConfig) =>
|
||||
p.name || PROVIDER_INFO[p.provider].label
|
||||
p.name || PROVIDERS[p.provider].label
|
||||
|
||||
const renderModelRow = (model: ModelConfig) => {
|
||||
if (!selectedProvider) return null
|
||||
@@ -929,11 +983,13 @@ export function ModelConfigDialog({
|
||||
: undefined
|
||||
const canTest = credentialsDone && models.length > 0
|
||||
const testing = validationStatus === "validating"
|
||||
const def = PROVIDERS[provider.provider]
|
||||
// A key-only provider without a default URL (Azure) needs its URL too
|
||||
const testBlocker = !credentialsDone
|
||||
? provider.provider === "bedrock"
|
||||
? def.credential === "aws"
|
||||
? t.needAws
|
||||
: provider.provider === "azure"
|
||||
? t.needAzure
|
||||
: def.credential === "apiKey" && !def.defaultBaseUrl
|
||||
? t.needUrl
|
||||
: t.needKey
|
||||
: models.length === 0
|
||||
? t.needModel
|
||||
@@ -991,7 +1047,7 @@ export function ModelConfigDialog({
|
||||
done={credentialsDone && !keyRejected}
|
||||
title={t.stepConnect}
|
||||
>
|
||||
{provider.provider === "edgeone" ? (
|
||||
{def.credential === "none" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.edgeoneNoKey}
|
||||
</p>
|
||||
@@ -1004,7 +1060,6 @@ export function ModelConfigDialog({
|
||||
name={provider.name}
|
||||
baseUrl={provider.baseUrl}
|
||||
awsRegion={provider.awsRegion}
|
||||
bedrockApiKey
|
||||
settingsLayout
|
||||
bedrockFilled={{
|
||||
apiKey: !!provider.apiKey,
|
||||
@@ -1035,10 +1090,10 @@ export function ModelConfigDialog({
|
||||
done={models.length > 0}
|
||||
title={t.stepModels}
|
||||
action={
|
||||
PROVIDER_INFO[provider.provider].modelList && (
|
||||
def.modelList && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchModels}
|
||||
onClick={() => handleFetchModels()}
|
||||
disabled={fetchingFor.includes(
|
||||
provider.id,
|
||||
)}
|
||||
@@ -1389,10 +1444,7 @@ export function ModelConfigDialog({
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeleteConfirmOpen(open)
|
||||
if (!open) setDeleteConfirmText("")
|
||||
}}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
>
|
||||
<AlertDialogContent className="border-destructive/30">
|
||||
<AlertDialogHeader>
|
||||
@@ -1410,39 +1462,11 @@ export function ModelConfigDialog({
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{selectedProvider &&
|
||||
selectedProvider.models.length >= 3 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Label
|
||||
htmlFor="delete-confirm"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{formatMessage(t.typeToConfirm, {
|
||||
name: providerName(selectedProvider),
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) =>
|
||||
setDeleteConfirmText(e.target.value)
|
||||
}
|
||||
placeholder={t.typeProviderName}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteProvider}
|
||||
disabled={
|
||||
selectedProvider &&
|
||||
selectedProvider.models.length >= 3 &&
|
||||
deleteConfirmText !==
|
||||
providerName(selectedProvider)
|
||||
}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t.delete}
|
||||
</AlertDialogAction>
|
||||
|
||||
+368
-334
@@ -10,28 +10,38 @@ import {
|
||||
Settings2,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelector as ModelSelectorRoot,
|
||||
ModelSelectorSectionHeader,
|
||||
ModelSelectorSeparator,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
PROVIDER_LOGO_MAP,
|
||||
} from "@/lib/types/model-config"
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { FlattenedModel, ProviderName } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
@@ -44,25 +54,22 @@ interface ModelSelectorProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] }
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
function groupModelsByProvider(
|
||||
models: FlattenedModel[],
|
||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ provider: string; models: FlattenedModel[] }
|
||||
>()
|
||||
): Map<string, ProviderGroup> {
|
||||
const groups = new Map<string, ProviderGroup>()
|
||||
for (const model of models) {
|
||||
// For server models, strip "Server · " prefix for cleaner grouping
|
||||
const key =
|
||||
model.source === "server"
|
||||
? model.providerLabel.replace(/^Server · /, "")
|
||||
: model.providerLabel
|
||||
const existing = groups.get(key)
|
||||
const existing = groups.get(model.providerLabel)
|
||||
if (existing) {
|
||||
existing.models.push(model)
|
||||
} else {
|
||||
groups.set(key, { provider: model.provider, models: [model] })
|
||||
groups.set(model.providerLabel, {
|
||||
provider: model.provider,
|
||||
models: [model],
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
@@ -88,6 +95,72 @@ export function shortModelName(id: string): string {
|
||||
return short
|
||||
}
|
||||
|
||||
// The command list with a shadow at the bottom while more items are below
|
||||
function ModelList({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof CommandList>) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
if (!listElement) return
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||
// A small threshold handles fractional pixel rendering
|
||||
setShowShadow(
|
||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
checkScroll()
|
||||
listElement.addEventListener("scroll", checkScroll)
|
||||
window.addEventListener("resize", checkScroll)
|
||||
// Content changes, e.g. the search filtering items
|
||||
const observer = new MutationObserver(checkScroll)
|
||||
observer.observe(listElement, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
listElement.removeEventListener("scroll", checkScroll)
|
||||
window.removeEventListener("resize", checkScroll)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||
showShadow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ icon, label }: { icon: ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1">
|
||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
@@ -146,6 +219,52 @@ export function ModelSelector({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// One group per provider; `trailing` is the small label at the end of a
|
||||
// row ("Default" for server models, the test state for user models)
|
||||
const renderGroups = (
|
||||
groups: Map<string, ProviderGroup>,
|
||||
keyPrefix: "server" | "user",
|
||||
trailing: (model: FlattenedModel) => ReactNode,
|
||||
) =>
|
||||
Array.from(groups.entries()).map(
|
||||
([providerLabel, { provider, models: providerModels }]) => (
|
||||
<CommandGroup
|
||||
key={`${keyPrefix}-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
// Unique value so same-named models highlight
|
||||
// separately; keywords keep search by name
|
||||
value={model.id}
|
||||
keywords={[model.modelId, providerLabel]}
|
||||
onSelect={() => handleSelect(model.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId === model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ProviderLogo
|
||||
provider={provider}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{model.modelId}
|
||||
</span>
|
||||
{trailing(model)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)
|
||||
|
||||
const tooltipContent = selectedModel
|
||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||
@@ -186,329 +305,244 @@ export function ModelSelector({
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||
<ModelSelectorRoot
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) toSettingsRef.current = false
|
||||
setOpen(next)
|
||||
}}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={tooltipContent}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
{selectedModel ? (
|
||||
<ProviderLogo
|
||||
provider={selectedModel.provider}
|
||||
className="size-3.5 flex-shrink-0 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? shortModelName(selectedModel.modelId)
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
) : (
|
||||
// Keep an sr-only label for screen readers when hidden
|
||||
<span className="sr-only">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
</ModelSelectorTrigger>
|
||||
{/* The dialog trigger must wrap the button itself: Radix
|
||||
Tooltip's root renders no element to pass its props to */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
{selectedModel ? (
|
||||
<ProviderLogo
|
||||
provider={selectedModel.provider}
|
||||
className="size-3.5 flex-shrink-0 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? shortModelName(
|
||||
selectedModel.modelId,
|
||||
)
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
) : (
|
||||
// Keep an sr-only label for screen readers when hidden
|
||||
<span className="sr-only">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.serverDefault}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-wrap">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ModelSelectorContent
|
||||
title={dict.modelConfig.selectModel}
|
||||
<DialogContent
|
||||
className="p-0"
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!toSettingsRef.current) return
|
||||
toSettingsRef.current = false
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorEmpty>
|
||||
{dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
<DialogTitle className="sr-only">
|
||||
{dict.modelConfig.selectModel}
|
||||
</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
<CommandInput
|
||||
className="h-auto py-3.5"
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelList className="overflow-y-auto scrollbar-thin">
|
||||
<CommandEmpty>
|
||||
{dict.modelConfig.noModelsFound}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<ModelSelectorGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<CommandGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
<Check
|
||||
<CommandItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
"cursor-pointer",
|
||||
!selectedModelId &&
|
||||
"bg-accent",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{
|
||||
dict.modelConfig
|
||||
.serverDefault
|
||||
}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<>
|
||||
<SectionHeader
|
||||
icon={<Monitor />}
|
||||
label={
|
||||
dict.modelConfig
|
||||
.serverModels
|
||||
}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.serverDefault}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
)}
|
||||
{renderGroups(
|
||||
groupedServerModels,
|
||||
"server",
|
||||
(model) =>
|
||||
model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<>
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<Monitor />}
|
||||
label={
|
||||
dict.modelConfig.serverModels
|
||||
}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedServerModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`server-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
// Unique value so same-named models highlight
|
||||
// separately; keywords keep search by name
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{serverModels.length > 0 && (
|
||||
<ModelSelectorSeparator />
|
||||
)}
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<User />}
|
||||
label={dict.modelConfig.userModels}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedUserModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`user-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.validated !==
|
||||
true && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pl-2 text-xs",
|
||||
model.validated ===
|
||||
false
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{model.validated ===
|
||||
false
|
||||
? dict
|
||||
.modelConfig
|
||||
.modelFailed
|
||||
: dict
|
||||
.modelConfig
|
||||
.modelUntested}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
</div>
|
||||
{/* Pinned footer: add a provider, configure models
|
||||
(z-10 above list shadow). Buttons, outside the
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onConfigure)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{dict.modelConfig.configureModels}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{serverModels.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
<SectionHeader
|
||||
icon={<User />}
|
||||
label={
|
||||
dict.modelConfig.userModels
|
||||
}
|
||||
/>
|
||||
{renderGroups(
|
||||
groupedUserModels,
|
||||
"user",
|
||||
(model) =>
|
||||
model.validated !==
|
||||
true && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pl-2 text-xs",
|
||||
model.validated ===
|
||||
false
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{model.validated ===
|
||||
false
|
||||
? dict
|
||||
.modelConfig
|
||||
.modelFailed
|
||||
: dict
|
||||
.modelConfig
|
||||
.modelUntested}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModelList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
{/* Pinned footer: add a provider, configure models
|
||||
(z-10 above list shadow). Buttons, outside the
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onConfigure)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{
|
||||
dict.modelConfig
|
||||
.configureModels
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ChevronRight, Key, Link2, Tag } from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { useLocale } from "@/components/canvas/locale-context"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
@@ -13,13 +14,8 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { OPTIONS_OPEN } from "@/lib/provider-setup"
|
||||
import {
|
||||
chatRequestUrl,
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
@@ -58,9 +54,6 @@ interface ProviderCredentialsFieldsProps {
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
awsRegion?: string
|
||||
// Bedrock: also offer an API key (the user dialog; the admin panel
|
||||
// stores access keys only)
|
||||
bedrockApiKey?: boolean
|
||||
disabled?: boolean
|
||||
// Update a plain text field
|
||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||
@@ -68,9 +61,6 @@ interface ProviderCredentialsFieldsProps {
|
||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||
// shared component only supplies the label above it.
|
||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||
// Extra content after the fields — used for the Bedrock test row and the
|
||||
// EdgeOne test button, which aren't beside a credential input.
|
||||
footer?: ReactNode
|
||||
// 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
|
||||
@@ -91,21 +81,24 @@ export function ProviderCredentialsFields({
|
||||
name,
|
||||
baseUrl,
|
||||
awsRegion,
|
||||
bedrockApiKey,
|
||||
disabled,
|
||||
onChange,
|
||||
renderSecret,
|
||||
footer,
|
||||
settingsLayout,
|
||||
clearSecrets,
|
||||
bedrockFilled,
|
||||
sessionTokenInput,
|
||||
}: ProviderCredentialsFieldsProps) {
|
||||
const dict = useDictionary()
|
||||
const info = PROVIDER_INFO[provider]
|
||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
const locale = useLocale()
|
||||
const info = PROVIDERS[provider]
|
||||
// A key-only provider without a default URL (Azure, custom) needs one
|
||||
const baseUrlLabel =
|
||||
info.credential === "apiKey" && !info.defaultBaseUrl
|
||||
? dict.modelConfig.baseUrlRequired
|
||||
: formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
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
|
||||
@@ -113,7 +106,7 @@ export function ProviderCredentialsFields({
|
||||
() =>
|
||||
!!name ||
|
||||
(!!baseUrl && baseUrl !== info.defaultBaseUrl) ||
|
||||
OPTIONS_OPEN.includes(provider),
|
||||
!!info.optionsOpen,
|
||||
)
|
||||
// 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
|
||||
@@ -207,7 +200,8 @@ export function ProviderCredentialsFields({
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" && ` ${dict.modelConfig.optional}`}
|
||||
{info.credential === "optionalKey" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
@@ -256,28 +250,21 @@ export function ProviderCredentialsFields({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
{info.baseUrlHint && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
{info.baseUrlHint[locale]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
return <div className="space-y-5">{footer}</div>
|
||||
}
|
||||
// A provider without credentials (EdgeOne) has no fields
|
||||
if (info.credential === "none") return null
|
||||
|
||||
if (settingsLayout) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{provider === "bedrock" ? (
|
||||
{info.credential === "aws" ? (
|
||||
<>
|
||||
{/* Buttons, not radios: arrow keys would empty
|
||||
secrets without a deliberate press */}
|
||||
@@ -403,7 +390,7 @@ export function ProviderCredentialsFields({
|
||||
)}
|
||||
{regionField}
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
) : info.credential === "vertexKey" ? (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-api-key"
|
||||
@@ -433,21 +420,20 @@ export function ProviderCredentialsFields({
|
||||
optionsOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{provider === "bedrock"
|
||||
{info.credential === "aws"
|
||||
? dict.modelConfig.moreOptionsName
|
||||
: dict.modelConfig.moreOptions}
|
||||
</button>
|
||||
{optionsOpen && (
|
||||
<>
|
||||
{provider !== "bedrock" &&
|
||||
(provider === "vertexai"
|
||||
{info.credential !== "aws" &&
|
||||
(info.credential === "vertexKey"
|
||||
? vertexBaseUrlField
|
||||
: baseUrlField)}
|
||||
{nameField}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -456,28 +442,8 @@ export function ProviderCredentialsFields({
|
||||
<div className="space-y-5">
|
||||
{nameField}
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
{info.credential === "aws" ? (
|
||||
<>
|
||||
{/* Bedrock API key: used instead of the access keys */}
|
||||
{bedrockApiKey && (
|
||||
<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",
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.bedrockApiKeyHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AWS Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
@@ -510,7 +476,7 @@ export function ProviderCredentialsFields({
|
||||
|
||||
{regionField}
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
) : info.credential === "vertexKey" ? (
|
||||
<>
|
||||
{/* Vertex AI API Key (Express Mode) */}
|
||||
<div className="space-y-2">
|
||||
@@ -535,8 +501,6 @@ export function ProviderCredentialsFields({
|
||||
{baseUrlField}
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Cloud, Server, Sparkles } from "lucide-react"
|
||||
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Provider logo from models.dev, with Lucide fallbacks for providers
|
||||
const ICONS = { cloud: Cloud, server: Server, sparkles: Sparkles }
|
||||
|
||||
// Provider logo from models.dev, or the record's Lucide icon for providers
|
||||
// that have no logo there
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
@@ -11,17 +13,11 @@ export function ProviderLogo({
|
||||
provider: ProviderName
|
||||
className?: string
|
||||
}) {
|
||||
if (provider === "bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
const { icon, logo } = PROVIDERS[provider]
|
||||
if (icon) {
|
||||
const Icon = ICONS[icon]
|
||||
return <Icon className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "sglang") {
|
||||
return <Server className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "doubao") {
|
||||
return <Sparkles className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
@@ -29,7 +25,7 @@ export function ProviderLogo({
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${logoName}.svg`}
|
||||
src={`https://models.dev/logos/${logo ?? provider}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Coffee, Settings, X } from "lucide-react"
|
||||
import type React from "react"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import { GitHubIcon } from "@/components/icons/github"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
|
||||
@@ -28,16 +28,10 @@ export function QuotaLimitToast({
|
||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||
|
||||
const quotaMessage = isTokenLimit
|
||||
? isSelfHosted
|
||||
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
||||
: dict.quota.messageToken
|
||||
: isSelfHosted
|
||||
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
||||
: dict.quota.messageApi
|
||||
? dict.quota.messageToken
|
||||
: dict.quota.messageApi
|
||||
|
||||
const tipHtml = isSelfHosted
|
||||
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
||||
: dict.quota.tip
|
||||
const tipHtml = isSelfHosted ? dict.quota.tipSelfHosted : dict.quota.tip
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
@@ -52,7 +46,7 @@ export function QuotaLimitToast({
|
||||
aria-live="polite"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in"
|
||||
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-float animate-message-in"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
@@ -115,7 +109,7 @@ export function QuotaLimitToast({
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
<GitHubIcon className="w-3.5 h-3.5" />
|
||||
{dict.quota.selfHost}
|
||||
</a>
|
||||
<a
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Github,
|
||||
Info,
|
||||
Monitor,
|
||||
Moon,
|
||||
@@ -13,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||
import { GitHubIcon } from "@/components/icons/github"
|
||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -510,7 +510,7 @@ function AboutTab() {
|
||||
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
|
||||
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
||||
const aboutPath = `/${lang}/about`
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -553,7 +553,7 @@ function AboutTab() {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Github className="size-3.5" />
|
||||
<GitHubIcon className="size-3.5" />
|
||||
GitHub
|
||||
</a>
|
||||
{showAbout && (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ChevronRight, Search } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useLocale } from "@/components/canvas/locale-context"
|
||||
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"
|
||||
import { filterProviderGroups } from "@/lib/provider-setup"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
|
||||
/** Every provider in groups, with a search; picking one adds it */
|
||||
export function ProviderPicker({
|
||||
@@ -20,6 +21,7 @@ export function ProviderPicker({
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.modelConfig
|
||||
const locale = useLocale()
|
||||
const [query, setQuery] = useState("")
|
||||
const groups = filterProviderGroups(query)
|
||||
const groupLabels: Record<string, string> = {
|
||||
@@ -28,8 +30,6 @@ export function ProviderPicker({
|
||||
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>
|
||||
@@ -93,9 +93,8 @@ export function ProviderPicker({
|
||||
</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],
|
||||
PROVIDERS[provider].note?.[locale],
|
||||
added.includes(provider) && t.added,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -116,14 +115,11 @@ export function ProviderPicker({
|
||||
<span
|
||||
className="block truncate text-[12.5px] font-medium"
|
||||
title={
|
||||
PROVIDER_INFO[provider]
|
||||
PROVIDERS[provider]
|
||||
.label
|
||||
}
|
||||
>
|
||||
{
|
||||
PROVIDER_INFO[provider]
|
||||
.label
|
||||
}
|
||||
{PROVIDERS[provider].label}
|
||||
</span>
|
||||
{note && (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -16,11 +16,11 @@ 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,
|
||||
import { PROVIDERS } from "@/lib/providers"
|
||||
import type {
|
||||
FlattenedModel,
|
||||
ProviderConfig,
|
||||
ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -169,7 +169,7 @@ export function ProvidersList({
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_INFO[provider].label}
|
||||
{PROVIDERS[provider].label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -180,8 +180,7 @@ export function ProvidersList({
|
||||
className="flex items-center gap-1 text-[13px] font-medium hover:underline"
|
||||
>
|
||||
{formatMessage(t.viewAllProviders, {
|
||||
count: Object.keys(PROVIDER_INFO)
|
||||
.length,
|
||||
count: Object.keys(PROVIDERS).length,
|
||||
})}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
@@ -225,7 +224,7 @@ export function ProvidersList({
|
||||
<span className="flex items-center gap-2 text-[13px] font-medium">
|
||||
<span className="truncate">
|
||||
{p.name ||
|
||||
PROVIDER_INFO[
|
||||
PROVIDERS[
|
||||
p.provider
|
||||
].label}
|
||||
</span>
|
||||
|
||||
@@ -12,14 +12,6 @@ function AlertDialog({
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
@@ -144,9 +136,6 @@ function AlertDialogCancel({
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
|
||||
@@ -5,13 +5,6 @@ import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
@@ -29,32 +22,6 @@ function Command({
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
@@ -164,30 +131,12 @@ function CommandItem({
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
||||
@@ -24,12 +24,6 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
@@ -65,7 +59,7 @@ function DialogContent({
|
||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"grid gap-4 p-6",
|
||||
// Refined visual treatment
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-pop",
|
||||
// Entry/exit animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
@@ -151,13 +145,10 @@ function DialogDescription({
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
@@ -39,10 +39,4 @@ function PopoverContent({
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
|
||||
@@ -55,4 +55,4 @@ function ScrollBar({
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea }
|
||||
|
||||
@@ -12,12 +12,6 @@ function Select({
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
@@ -87,19 +81,6 @@ function SelectContent({
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
@@ -124,19 +105,6 @@ function SelectItem({
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
@@ -176,12 +144,7 @@ function SelectScrollDownButton({
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** The app icon in a rounded tile; the white variant is used in dark mode */
|
||||
/** The app icon in a rounded tile; the icon has its own white tile, so dark mode uses it too */
|
||||
export function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
@@ -16,13 +16,7 @@ export function BrandMark({ className }: { className?: string }) {
|
||||
<img
|
||||
src={getAssetUrl("/favicon-192x192.png")}
|
||||
alt=""
|
||||
className="size-full object-contain dark:hidden"
|
||||
/>
|
||||
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
|
||||
<img
|
||||
src={getAssetUrl("/favicon-white.svg")}
|
||||
alt=""
|
||||
className="hidden size-5 object-contain dark:block"
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -13,9 +13,7 @@ interface IconButtonProps extends React.ComponentProps<"button"> {
|
||||
label: string
|
||||
/** Shown next to the label in the tooltip, e.g. "⌘B" */
|
||||
shortcut?: string
|
||||
active?: boolean
|
||||
tooltipSide?: "top" | "bottom" | "left" | "right"
|
||||
size?: "sm" | "md"
|
||||
}
|
||||
|
||||
/** Square icon button with a tooltip; the tooltip text is also its label */
|
||||
@@ -24,9 +22,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
{
|
||||
label,
|
||||
shortcut,
|
||||
active = false,
|
||||
tooltipSide = "bottom",
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
@@ -40,17 +36,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active || undefined}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
||||
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
||||
"hover:bg-accent hover:text-foreground",
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
|
||||
"disabled:pointer-events-none disabled:opacity-35",
|
||||
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
|
||||
size === "md" ? "size-8" : "size-7",
|
||||
// A tinted tile: solid ink is for the main action
|
||||
active &&
|
||||
"bg-interactive-active text-foreground hover:bg-interactive-active [&_svg]:stroke-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -73,8 +64,3 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/** Thin vertical divider between toolbar groups */
|
||||
export function ToolbarDivider() {
|
||||
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
||||
import {
|
||||
ChatEngineProvider,
|
||||
useChatEngine,
|
||||
} from "@/components/chat/chat-engine"
|
||||
import { ChatPanel } from "@/components/chat/chat-panel"
|
||||
import { CompareDialog } from "@/components/chat/compare-dialog"
|
||||
import { ChatVersionsProvider } from "@/components/chat/chat-versions"
|
||||
import { LobbyHero } from "@/components/chat/lobby-hero"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
@@ -116,7 +118,9 @@ export function Workspace() {
|
||||
|
||||
return (
|
||||
<ChatEngineProvider>
|
||||
<WorkspaceLayout />
|
||||
<ChatVersionsProvider>
|
||||
<WorkspaceLayout />
|
||||
</ChatVersionsProvider>
|
||||
</ChatEngineProvider>
|
||||
)
|
||||
}
|
||||
@@ -146,7 +150,6 @@ function WorkspaceLayout() {
|
||||
const showHero =
|
||||
engine.isRestored &&
|
||||
engine.messages.length === 0 &&
|
||||
!engine.isResending &&
|
||||
!isRealDiagram(chartXML) &&
|
||||
!heroDismissed
|
||||
|
||||
@@ -267,6 +270,13 @@ function WorkspaceLayout() {
|
||||
}
|
||||
style={isMobile ? undefined : { right: canvasRight }}
|
||||
hideOverlays={!isMobile && showHero}
|
||||
overlay={(size) => (
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
/>
|
||||
)}
|
||||
waitForSavedLocale
|
||||
/>
|
||||
{isMobile ? (
|
||||
mobileView === "chat" &&
|
||||
|
||||
+197
-147
@@ -60,24 +60,34 @@ export interface DiagramCommit {
|
||||
pageId?: string | null
|
||||
}
|
||||
|
||||
/** An export as draw.io's export action takes it: the format, and for PNG
|
||||
* its scale, width cap and the page to render (pageId) */
|
||||
export interface ExportRequest {
|
||||
format: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** What draw.io answered: the image (a data URL, or SVG text) in data; for
|
||||
* format "xml" the document in xml instead */
|
||||
export interface ExportResult {
|
||||
data?: string
|
||||
xml?: string
|
||||
}
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string
|
||||
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||
chartXMLRef: React.MutableRefObject<string>
|
||||
latestSvg: string
|
||||
// The diagram's latest SVG export, a fallback thumbnail for saves
|
||||
latestSvgRef: React.MutableRefObject<string>
|
||||
loadDiagram: (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string; pageId?: string | null },
|
||||
) => string | null
|
||||
// Returns the export's tag (empty when draw.io is not there yet)
|
||||
handleExport: () => string
|
||||
// Pending exports by tag; a plain export's resolver gets the XML of the
|
||||
// page on screen
|
||||
exportResolversRef: React.MutableRefObject<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
>
|
||||
/** The XML of the page on screen, exported from draw.io */
|
||||
fetchDiagramXml: () => Promise<string>
|
||||
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
|
||||
handleDiagramExport: (data: DrawioExportEvent) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
@@ -87,10 +97,23 @@ interface DiagramContextType {
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
successMessage?: string,
|
||||
) => void
|
||||
) => Promise<void>
|
||||
getThumbnailSvg: () => Promise<string | null>
|
||||
getVersionSvg: () => Promise<string | null>
|
||||
captureValidationPng: () => Promise<string | null>
|
||||
/** One export with its own result; null when draw.io does not answer
|
||||
* in time (the MCP shell answers the server's export requests with it) */
|
||||
requestExport: (
|
||||
request: ExportRequest,
|
||||
timeoutMs?: number,
|
||||
) => Promise<ExportResult | null>
|
||||
/** Show a document for an export only (the MCP's one-page projection of
|
||||
* another page): not recorded as the diagram, its autosaves ignored,
|
||||
* until the next loadDiagram puts the real document back */
|
||||
showTransient: (xml: string) => void
|
||||
/** A full load was sent and draw.io has not reported it yet: the
|
||||
* editor still shows the previous document */
|
||||
hasPendingLoad: () => boolean
|
||||
isDrawioReady: boolean
|
||||
onDrawioLoad: () => void
|
||||
resetDrawioReady: () => void
|
||||
@@ -108,11 +131,11 @@ const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
// echoes the request back in the export event, so each result reaches its
|
||||
// own caller. Tags end in a request number, so a late result never answers
|
||||
// a newer request.
|
||||
type ExportTag = "thumbnail" | "validation" | "version"
|
||||
type ExportTag = "thumbnail" | "validation" | "version" | "fetch" | "save"
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [chartXML, setChartXML] = useState<string>("")
|
||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||
const latestSvgRef = useRef("")
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const hasCalledOnLoadRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
@@ -125,7 +148,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const drawioRef = useRef<DrawioFrameHandle | null>(null)
|
||||
// Pending exports, keyed by their export tag
|
||||
const exportResolversRef = useRef<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
Record<string, (result: ExportResult) => void>
|
||||
>({})
|
||||
const exportSeqRef = useRef(0)
|
||||
// Track latest chartXML for restoration after remount
|
||||
@@ -141,13 +164,19 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// The page to show again once a full load is done: draw.io shows the
|
||||
// first page of a loaded document
|
||||
const pageAfterLoadRef = useRef<string | null>(null)
|
||||
// A transient document (showTransient) is on the canvas, and the page
|
||||
// the user was on before it
|
||||
const transientRef = useRef(false)
|
||||
const pageBeforeTransientRef = useRef<string | null>(null)
|
||||
|
||||
// Sends a full load. draw.io runs it when its message arrives, and then
|
||||
// reports "load": until then the editor shows the diagram from before,
|
||||
// so later changes go the same way and keep their order
|
||||
const fullLoad = (xml: string) => {
|
||||
const fullLoad = (
|
||||
xml: string,
|
||||
pageId: string | null = useCanvasStore.getState().currentPageId,
|
||||
) => {
|
||||
if (!drawioRef.current) return
|
||||
const pageId = useCanvasStore.getState().currentPageId
|
||||
pageAfterLoadRef.current =
|
||||
pageId && xml.includes(`id="${pageId}"`) ? pageId : null
|
||||
pendingLoadsRef.current++
|
||||
@@ -184,26 +213,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
setChartXML(xml)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
if (!drawioRef.current) return ""
|
||||
// Export of the current state (for the chat and edit_diagram)
|
||||
const tag = `fetch-${++exportSeqRef.current}`
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
message: tag,
|
||||
})
|
||||
return tag
|
||||
}
|
||||
|
||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||
// event) and wait for that result. Resolves to null on timeout, which is
|
||||
// expected occasionally.
|
||||
// (Reads refs only, so it keeps one identity)
|
||||
const requestTaggedExport = useCallback(
|
||||
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
new Promise<ExportResult | null>((resolve) => {
|
||||
const id = `${tag}-${++exportSeqRef.current}`
|
||||
const finish = (value: string | null) => {
|
||||
const finish = (value: ExportResult | null) => {
|
||||
clearTimeout(timer)
|
||||
delete exportResolversRef.current[id]
|
||||
resolve(value)
|
||||
@@ -216,20 +234,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
|
||||
// Get current diagram as SVG for thumbnail (used by session storage).
|
||||
// One identity: the chat's auto-save depends on it, and each thumbnail
|
||||
// renders this provider again (latestSvg), which would otherwise start
|
||||
// the next save
|
||||
// One identity: the chat's auto-save effect depends on it
|
||||
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
|
||||
// xmlsvg exports return an SVG data URL
|
||||
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||
if (svgData?.startsWith("data:image/svg")) {
|
||||
// Update latestSvg so it's available for future saves
|
||||
setLatestSvg(svgData)
|
||||
return svgData
|
||||
const result = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||
if (result?.data?.startsWith("data:image/svg")) {
|
||||
// Kept for later saves that take no fresh thumbnail
|
||||
latestSvgRef.current = result.data
|
||||
return result.data
|
||||
}
|
||||
return null
|
||||
}, [requestTaggedExport])
|
||||
@@ -237,8 +253,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// Plain SVG (no embedded diagram data) for version cards
|
||||
const getVersionSvg = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
const svgData = await requestTaggedExport("version", "svg", 4000)
|
||||
return svgData?.startsWith("data:image/svg") ? svgData : null
|
||||
const result = await requestTaggedExport("version", "svg", 4000)
|
||||
return result?.data?.startsWith("data:image/svg") ? result.data : null
|
||||
}
|
||||
|
||||
// Capture current diagram as PNG for VLM validation
|
||||
@@ -247,9 +263,58 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
|
||||
const pngData = await requestTaggedExport("validation", "png", 5000)
|
||||
const result = await requestTaggedExport("validation", "png", 5000)
|
||||
// PNG data should be a base64 data URL
|
||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||
return result?.data?.startsWith("data:image/png") ? result.data : null
|
||||
}
|
||||
|
||||
// The page the user is viewing (the first one with an external draw.io,
|
||||
// which cannot tell us), for the chat and edit_diagram.
|
||||
// Not written to chartXML: exports don't change the diagram, and the
|
||||
// export's xml has compressed <diagram> payloads that would break
|
||||
// edit_diagram/display_diagram. Autosave keeps chartXML up to date with
|
||||
// the full uncompressed multi-page document (#879).
|
||||
const fetchDiagramXml = async (): Promise<string> => {
|
||||
const result = await requestTaggedExport("fetch", "xmlsvg", 10000)
|
||||
if (!result?.data) {
|
||||
throw new Error("Chart export timed out after 10 seconds")
|
||||
}
|
||||
latestSvgRef.current = result.data
|
||||
return extractDiagramXML(
|
||||
result.data,
|
||||
useCanvasStore.getState().currentPageId,
|
||||
)
|
||||
}
|
||||
|
||||
// Like requestTaggedExport, with the request's other parameters (a PNG's
|
||||
// page and width) and both parts of the answer
|
||||
const requestExport = useCallback(
|
||||
(request: ExportRequest, timeoutMs = 10000) =>
|
||||
new Promise<ExportResult | null>((resolve) => {
|
||||
if (!drawioRef.current) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const id = `export-${++exportSeqRef.current}`
|
||||
const finish = (value: ExportResult | null) => {
|
||||
clearTimeout(timer)
|
||||
delete exportResolversRef.current[id]
|
||||
resolve(value)
|
||||
}
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
exportResolversRef.current[id] = finish
|
||||
drawioRef.current.exportDiagram({ ...request, message: id })
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const showTransient = (xml: string) => {
|
||||
if (!transientRef.current) {
|
||||
pageBeforeTransientRef.current =
|
||||
useCanvasStore.getState().currentPageId
|
||||
}
|
||||
transientRef.current = true
|
||||
fullLoad(xml)
|
||||
}
|
||||
|
||||
const loadDiagram = (
|
||||
@@ -261,6 +326,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// The editor bridge is shared: a page that is gone (another language
|
||||
// mounted a new one) must not change the new page's canvas
|
||||
if (!mountedRef.current) return null
|
||||
// A real document ends a transient one; the user goes back to the
|
||||
// page they were on before it
|
||||
const pageBeforeTransient = transientRef.current
|
||||
? pageBeforeTransientRef.current
|
||||
: undefined
|
||||
transientRef.current = false
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal
|
||||
@@ -315,7 +386,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!applied) {
|
||||
// A full load replaces any preview, and its base is stale now
|
||||
resetPreview()
|
||||
fullLoad(xmlToLoad)
|
||||
fullLoad(xmlToLoad, pageBeforeTransient)
|
||||
}
|
||||
|
||||
if (mode === "commit") {
|
||||
@@ -335,40 +406,22 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
resetPreview()
|
||||
}
|
||||
|
||||
// Each export result goes to the caller that asked for it, by its tag
|
||||
const handleDiagramExport = (data: DrawioExportEvent) => {
|
||||
// Thumbnail, version, validation PNG and file save exports go only
|
||||
// to their own caller
|
||||
const tag = data.message?.message
|
||||
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
|
||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
// The chat gets the page the user is viewing (the first one with
|
||||
// an external draw.io, which cannot tell us)
|
||||
const extractedXML = extractDiagramXML(
|
||||
data.data,
|
||||
useCanvasStore.getState().currentPageId,
|
||||
)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// The chat's own export (onFetchChart), not another one in flight
|
||||
const resolve =
|
||||
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
||||
if (resolve) {
|
||||
delete exportResolversRef.current[tag as string]
|
||||
resolve(extractedXML)
|
||||
}
|
||||
if (!tag) return
|
||||
exportResolversRef.current[tag]?.({ data: data.data, xml: data.xml })
|
||||
}
|
||||
|
||||
// The frame registers this callback once per iframe mount, so it must
|
||||
// read refs: state captured in its closure would stay stale after a remount
|
||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||
if (!data?.xml) return
|
||||
// An edit of a transient document is not the diagram's; nor is one
|
||||
// of the canvas a full load still on its way replaces (draw.io
|
||||
// reports each load, in order, so no autosave of the new document
|
||||
// comes before that)
|
||||
if (transientRef.current || pendingLoadsRef.current > 0) return
|
||||
// Don't overwrite a pending restore - if we have a diagram but
|
||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
|
||||
@@ -383,10 +436,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const emptyDiagram = BLANK_MXFILE
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
loadDiagram(emptyDiagram, true)
|
||||
setLatestSvg("")
|
||||
latestSvgRef.current = ""
|
||||
}
|
||||
|
||||
const saveDiagramToFile = (
|
||||
const saveDiagramToFile = async (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
@@ -401,85 +454,80 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const drawioFormat =
|
||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||
|
||||
// Each save has its own tag, so two at once never swap results
|
||||
const tag = `save-${++exportSeqRef.current}`
|
||||
exportResolversRef.current[tag] = (
|
||||
exportData: string,
|
||||
fullDiagramXML?: string,
|
||||
) => {
|
||||
delete exportResolversRef.current[tag]
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
// Each save has its own tag, so two at once never swap results. A
|
||||
// large PNG takes a while to render.
|
||||
const result = await requestTaggedExport("save", drawioFormat, 30000)
|
||||
if (!result?.data) {
|
||||
console.warn("Diagram export for saving timed out")
|
||||
return
|
||||
}
|
||||
const exportData = result.data
|
||||
const fullDiagramXML = result.xml
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
fileContent =
|
||||
normalizeToMxfile(xml, {
|
||||
pageId: "page-1",
|
||||
pageName: "Page-1",
|
||||
}) ?? xml
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, { duration: 2500 })
|
||||
}
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
if (format === "drawio") {
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
fileContent =
|
||||
normalizeToMxfile(xml, {
|
||||
pageId: "page-1",
|
||||
pageName: "Page-1",
|
||||
}) ?? xml
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
drawioRef.current.exportDiagram({
|
||||
format: drawioFormat,
|
||||
message: tag,
|
||||
})
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, { duration: 2500 })
|
||||
}
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||
@@ -510,10 +558,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
value={{
|
||||
chartXML,
|
||||
chartXMLRef,
|
||||
latestSvg,
|
||||
latestSvgRef,
|
||||
loadDiagram,
|
||||
handleExport,
|
||||
exportResolversRef,
|
||||
fetchDiagramXml,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
@@ -522,6 +569,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
getThumbnailSvg,
|
||||
getVersionSvg,
|
||||
captureValidationPng,
|
||||
requestExport,
|
||||
showTransient,
|
||||
hasPendingLoad: () => pendingLoadsRef.current > 0,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
|
||||
@@ -92,6 +92,8 @@ https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
|
||||
|
||||
### 在 AI 代理里使用(MCP)
|
||||
|
||||
https://github.com/user-attachments/assets/6f53a67b-ab21-4113-9fee-7fb32f9de678
|
||||
|
||||
通过 MCP(Model Context Protocol,让 AI 代理调用外部工具的协议),Claude Desktop、Cursor、VS Code 等都能直接画 draw.io 图。在客户端的 MCP 配置里加上:
|
||||
|
||||
```json
|
||||
@@ -114,9 +116,12 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
|
||||
|
||||
- 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
|
||||
- 预览页就是网页版的画布:AI 的改动会被标出,按一次 Ctrl+Z 就能撤销,AI 还能读到你选中的图形
|
||||
- draw.io 编辑器随包一起安装,装好后预览不需要联网(也可以用 `DRAWIO_BASE_URL` 改用外部的 draw.io)
|
||||
- 截图工具,AI 可以看一眼画好的图并自行修正
|
||||
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
|
||||
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
|
||||
- 可以让 AI 撤销修改,也可以在 `~/.next-ai-drawio/instructions.md` 里写下自己的画图规则
|
||||
|
||||
VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
|
||||
|
||||
@@ -146,7 +151,7 @@ npm run dev
|
||||
|
||||
### 模型与服务商
|
||||
|
||||
支持 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)。
|
||||
支持 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,以及一个可填任意地址的 OpenAI 兼容预设(vLLM、LM Studio、网关),共 25 家。各家的环境变量和注意事项见[服务商配置指南](./ai-providers.md)。
|
||||
|
||||
**选哪个模型**:这项任务要生成格式严格的长文本(draw.io XML),请选能力较强的模型,小模型容易画出错误的图。
|
||||
|
||||
|
||||
+39
-3
@@ -132,7 +132,7 @@ AI_MODEL=your-deployment-name
|
||||
|
||||
```bash
|
||||
AZURE_API_KEY=your_api_key
|
||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME 的替代方案
|
||||
AZURE_BASE_URL=https://your-resource.openai.azure.com/openai # AZURE_RESOURCE_NAME 的替代方案
|
||||
AI_MODEL=your-deployment-name
|
||||
```
|
||||
|
||||
@@ -323,6 +323,18 @@ AI_MODEL=mimo-v2.5-pro
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
### 任何 OpenAI 兼容接口(custom)
|
||||
|
||||
上面没有列出的服务(vLLM、LM Studio、LiteLLM、API 网关),在设置里选择 **OpenAI-compatible**,填入它的地址和 Key;服务端不校验 key 时填任意非空内容即可。多条 system 消息会合并成一条(开源模型的对话模板通常要求这样),`<think>` 里的推理内容会从回复里分离出来。
|
||||
|
||||
```bash
|
||||
CUSTOM_API_KEY=your_api_key
|
||||
CUSTOM_BASE_URL=http://127.0.0.1:8000/v1
|
||||
AI_MODEL=your_model_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自动检测
|
||||
|
||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||
@@ -330,7 +342,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
AI_PROVIDER=google # 或:openai, anthropic, google, vertexai, azure, bedrock, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud, custom
|
||||
```
|
||||
|
||||
## 服务端多模型配置
|
||||
@@ -392,7 +404,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
||||
| `models` | 是 | 模型 ID 列表 |
|
||||
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
|
||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
||||
|
||||
### 说明
|
||||
@@ -431,3 +443,27 @@ TEMPERATURE=0 # 输出更具确定性(推荐用于图表)
|
||||
- **经济实惠**:DeepSeek 提供具有竞争力的价格
|
||||
- **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持)
|
||||
- **灵活性**:OpenRouter 通过单一 API 提供对众多模型的访问
|
||||
|
||||
## 新增一个服务商
|
||||
|
||||
一个服务商的全部事实都在 `lib/providers.ts` 的一条记录里:用哪个 AI SDK(`sdk`)、用什么凭据登录(`credential`)、环境变量 `<PREFIX>_API_KEY` / `<PREFIX>_BASE_URL` 的前缀(`envPrefix`)、官方地址、选择器里的分组,以及可选的提示文案。设置界面、连接测试、聊天路由和管理面板都读这条记录,所以新增一个 OpenAI 兼容服务商只需:
|
||||
|
||||
1. 在 `lib/providers.ts` 里加一条记录,例如:
|
||||
|
||||
```ts
|
||||
acme: {
|
||||
label: "Acme AI",
|
||||
sdk: "openai-compatible",
|
||||
credential: "apiKey",
|
||||
group: "aggregators",
|
||||
envPrefix: "ACME",
|
||||
defaultBaseUrl: "https://api.acme.example/v1",
|
||||
apiKeyUrl: "https://acme.example/keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
```
|
||||
|
||||
需要在地址输入框下方显示提示,或在选择器里显示说明的服务商,在记录里加 `baseUrlHint` 或 `note` 对象,四种界面语言(`en`、`zh`、`ja`、`zh-Hant`)都要写。不能在线列出模型的服务商不写 `modelList`,改写一个短的 `models` 列表。
|
||||
2. 在 `env.example` 里加上 `ACME_API_KEY` 和 `ACME_BASE_URL`(有一个单元测试检查每条记录的变量名都出现在里面)。
|
||||
3. 在 `docs/en`、`docs/cn`、`docs/ja` 的本文档里各加一节说明。
|
||||
4. 把它加进 `tests/unit/provider-matrix.test.ts` 开头的两个清单(`MATRIX_PROVIDERS` 和 `MATRIX_MODEL_IDS`),用 `npx vitest run tests/unit/provider-matrix.test.ts -u` 重新生成行为快照;新增的行都属于这家服务商,已有的行不能变。`tests/unit/providers.test.ts` 写明了选择器的分组和会改变服务端行为的服务商集合(自动检测、只接受一条 system 消息),也要加进去。三份 README 里的服务商数量和本文档的 `AI_PROVIDER` 可选值清单同样要改。
|
||||
|
||||
@@ -68,47 +68,15 @@ cp env.example .env.local
|
||||
|
||||
## 第三步 — 选择部署类型
|
||||
|
||||
### 选项 A:不使用 R2 部署(简单,免费)
|
||||
### 选项 A:不使用 R2 部署(默认,免费)
|
||||
|
||||
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
|
||||
|
||||
**1. 使用简单的 `open-next.config.ts`:**
|
||||
|
||||
```ts
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||
|
||||
export default defineCloudflareConfig({})
|
||||
```
|
||||
|
||||
**2. 使用简单的 `wrangler.jsonc`(不包含 r2_buckets):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "next-ai-draw-io-worker",
|
||||
"compatibility_date": "2025-12-08",
|
||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "next-ai-draw-io-worker"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
直接跳至 **第四步**。
|
||||
仓库自带的就是这套配置:`open-next.config.ts` 为 `defineCloudflareConfig({})`,`wrangler.jsonc` 中没有 `r2_buckets`。无需改动任何文件,直接跳至 **第四步**。
|
||||
|
||||
---
|
||||
|
||||
### 选项 B:使用 R2 部署(完整的 ISR 支持)
|
||||
### 选项 B:使用 R2 部署(可选,用于 ISR 缓存)
|
||||
|
||||
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
|
||||
R2 为 OpenNext 提供存放 **增量静态再生 (ISR)** 页面的空间。本应用目前没有 ISR 页面,只有在您自己添加了此类页面时才需要这一步。需要在您的 Cloudflare 账户中绑定支付方式。
|
||||
|
||||
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
||||
|
||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
||||
})
|
||||
```
|
||||
|
||||
**3. 配置 `wrangler.jsonc`(包含 R2):**
|
||||
**3. 在 `wrangler.jsonc` 中加入 `r2_buckets`:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
||||
|
||||
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
||||
|
||||
> **注意:** 定时重新验证(页面或 fetch 上的 `revalidate`)还需要在 `open-next.config.ts` 中配置 `queue`,请参阅 OpenNext 的 Cloudflare 缓存文档。
|
||||
|
||||
---
|
||||
|
||||
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
||||
@@ -264,4 +234,4 @@ npm run preview
|
||||
| API 路由 | 支持 | 支持 |
|
||||
| 配置复杂度 | 简单 | 中等 |
|
||||
|
||||
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。
|
||||
仓库默认 **不使用 R2**。只有当您添加了需要 ISR 缓存的页面时,才选择 **使用 R2**。
|
||||
|
||||
@@ -14,7 +14,7 @@ Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)
|
||||
|
||||
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
|
||||
|
||||
- 在另一台机器上下载 `scripts/fetch-drawio.mjs` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
||||
- 在另一台机器上下载 `packages/mcp-server/src/drawio-version.json` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
||||
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
|
||||
|
||||
## 来自其他网站的图片
|
||||
|
||||
+39
-3
@@ -147,7 +147,7 @@ Or use a custom endpoint instead of resource name:
|
||||
|
||||
```bash
|
||||
AZURE_API_KEY=your_api_key
|
||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # Alternative to AZURE_RESOURCE_NAME
|
||||
AZURE_BASE_URL=https://your-resource.openai.azure.com/openai # Alternative to AZURE_RESOURCE_NAME
|
||||
AI_MODEL=your-deployment-name
|
||||
```
|
||||
|
||||
@@ -338,6 +338,18 @@ Optional custom endpoint (Token Plan subscribers should set their dedicated Base
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
### Any OpenAI-compatible API (custom)
|
||||
|
||||
For a server not listed above (vLLM, LM Studio, LiteLLM, an API gateway), pick **OpenAI-compatible** in the settings and enter its base URL and a key; a server that ignores keys takes any non-empty value. Several system messages are merged into one, as open-source chat templates expect, and `<think>` reasoning is split from the reply.
|
||||
|
||||
```bash
|
||||
CUSTOM_API_KEY=your_api_key
|
||||
CUSTOM_BASE_URL=http://127.0.0.1:8000/v1
|
||||
AI_MODEL=your_model_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||
@@ -345,7 +357,7 @@ If you only configure **one** provider's API key, the system will automatically
|
||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
AI_PROVIDER=google # or: openai, anthropic, google, vertexai, azure, bedrock, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud, custom
|
||||
```
|
||||
|
||||
## Server-Side Multi-Model Configuration
|
||||
@@ -407,7 +419,7 @@ This is shorthand for the equivalent `ai-models.json`. For multiple providers or
|
||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
||||
| `models` | Yes | List of model IDs |
|
||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`). May also be an array of env var names: each request picks one that has a value at random, spreading load over several keys |
|
||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
||||
|
||||
### Notes
|
||||
@@ -446,3 +458,27 @@ When unset, the model uses its default behavior.
|
||||
- **Budget-friendly**: DeepSeek offers competitive pricing
|
||||
- **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware)
|
||||
- **Flexibility**: OpenRouter provides access to many models through a single API
|
||||
|
||||
## Adding a Provider
|
||||
|
||||
Every fact about a provider lives in one record in `lib/providers.ts`: which AI SDK runs it (`sdk`), what it signs in with (`credential`), the prefix of its `<PREFIX>_API_KEY` / `<PREFIX>_BASE_URL` variables (`envPrefix`), its official URL, the picker group, and optional notes. The settings UI, the connection test, the chat route and the admin panel all read that record, so adding an OpenAI-compatible provider is:
|
||||
|
||||
1. Add a record to `lib/providers.ts`, for example:
|
||||
|
||||
```ts
|
||||
acme: {
|
||||
label: "Acme AI",
|
||||
sdk: "openai-compatible",
|
||||
credential: "apiKey",
|
||||
group: "aggregators",
|
||||
envPrefix: "ACME",
|
||||
defaultBaseUrl: "https://api.acme.example/v1",
|
||||
apiKeyUrl: "https://acme.example/keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
```
|
||||
|
||||
A provider that needs a hint under its base URL field, or a note in the picker, gets a `baseUrlHint` or `note` object with all four UI languages (`en`, `zh`, `ja`, `zh-Hant`). Providers that cannot list their models online keep a short `models` list instead of `modelList`.
|
||||
2. Add `ACME_API_KEY` and `ACME_BASE_URL` to `env.example` (a unit test checks that every record's variables appear there).
|
||||
3. Add a section for it to this document in `docs/en`, `docs/cn` and `docs/ja`.
|
||||
4. Add it to the two lists at the top of `tests/unit/provider-matrix.test.ts` (`MATRIX_PROVIDERS` and `MATRIX_MODEL_IDS`) and regenerate the behavior snapshot with `npx vitest run tests/unit/provider-matrix.test.ts -u`; the new rows are the provider's, the existing rows must not change. `tests/unit/providers.test.ts` spells out the picker's groups and the provider sets that change what the server does (auto-detection, single system message), so add it there too. The provider count in the three READMEs and the `AI_PROVIDER` list in this document change as well.
|
||||
|
||||
@@ -68,47 +68,15 @@ Fill in the same values there.
|
||||
|
||||
## Step 3 — Choose your deployment type
|
||||
|
||||
### Option A: Deploy WITHOUT R2 (Simple, Free)
|
||||
### Option A: Deploy WITHOUT R2 (Default, Free)
|
||||
|
||||
If you don't need ISR caching, you can deploy without R2:
|
||||
|
||||
**1. Use simple `open-next.config.ts`:**
|
||||
|
||||
```ts
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||
|
||||
export default defineCloudflareConfig({})
|
||||
```
|
||||
|
||||
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "next-ai-draw-io-worker",
|
||||
"compatibility_date": "2025-12-08",
|
||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "next-ai-draw-io-worker"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Skip to **Step 4**.
|
||||
This is how the repository is configured: `open-next.config.ts` is `defineCloudflareConfig({})` and `wrangler.jsonc` has no `r2_buckets`. Nothing to change here. Skip to **Step 4**.
|
||||
|
||||
---
|
||||
|
||||
### Option B: Deploy WITH R2 (Full ISR Support)
|
||||
### Option B: Deploy WITH R2 (Optional, ISR caching)
|
||||
|
||||
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
|
||||
R2 gives OpenNext a place to store **Incremental Static Regeneration (ISR)** pages. This app currently has no ISR pages, so you only need this step if you add some. Requires a payment method on your Cloudflare account.
|
||||
|
||||
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
||||
|
||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
||||
})
|
||||
```
|
||||
|
||||
**3. Configure `wrangler.jsonc` (with R2):**
|
||||
**3. Add `r2_buckets` to `wrangler.jsonc`:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
||||
|
||||
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
||||
|
||||
> **Note:** Time-based revalidation (`revalidate` on a page or fetch) also needs a `queue` in `open-next.config.ts`. See the OpenNext Cloudflare caching docs.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Register a workers.dev subdomain (first-time only)
|
||||
@@ -264,4 +234,4 @@ npm run preview
|
||||
| API Routes | Yes | Yes |
|
||||
| Setup Complexity | Simple | Moderate |
|
||||
|
||||
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
|
||||
The repository defaults to **without R2**. Choose **with R2** only if you add pages that need ISR caching.
|
||||
|
||||
@@ -14,7 +14,7 @@ No separate draw.io container is needed.
|
||||
|
||||
The build downloads `draw.war` (about 50 MB) from the [draw.io releases](https://github.com/jgraph/drawio/releases). If the build machine cannot reach GitHub:
|
||||
|
||||
- On another machine, download `draw.war` for the version in `scripts/fetch-drawio.mjs`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
|
||||
- On another machine, download `draw.war` for the version in `packages/mcp-server/src/drawio-version.json`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
|
||||
- Or build the image on a machine with internet access and transfer it to the offline network.
|
||||
|
||||
## Images From Other Websites
|
||||
|
||||
@@ -92,6 +92,8 @@ Web アプリ、Windows / macOS / Linux 向けデスクトップアプリ、そ
|
||||
|
||||
### AI エージェントから使う(MCP)
|
||||
|
||||
https://github.com/user-attachments/assets/6f53a67b-ab21-4113-9fee-7fb32f9de678
|
||||
|
||||
MCP(Model Context Protocol、AI エージェントが外部ツールを呼び出すためのプロトコル)を通して、Claude Desktop、Cursor、VS Code などから直接 draw.io のダイアグラムを描けます。クライアントの MCP 設定に次を追加します。
|
||||
|
||||
```json
|
||||
@@ -114,9 +116,12 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
あとは AI に「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを描いて」と頼めば、描かれていく様子がブラウザに表示されます。MCP サーバーには Web アプリの作図機能のほとんどが入っています。
|
||||
|
||||
- 同じ作図ルールとアイコンライブラリ(AWS、Azure、GCP、Kubernetes など)
|
||||
- プレビューは Web アプリと同じキャンバス。AI の変更には印が付き、Ctrl+Z 一回で元に戻せ、選択中の図形を AI が読み取れる
|
||||
- draw.io エディターはパッケージに同梱されているので、インストール後はオフラインでもプレビューが動く(外部の draw.io を使うなら `DRAWIO_BASE_URL` を設定する)
|
||||
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
|
||||
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
|
||||
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
|
||||
- AI に元に戻す操作を頼める。独自の作図ルールは `~/.next-ai-drawio/instructions.md` に書ける
|
||||
|
||||
VS Code、Cursor などの設定は [MCP サーバーの README](../../packages/mcp-server/README.md) を参照してください。
|
||||
|
||||
@@ -146,7 +151,7 @@ npm run dev
|
||||
|
||||
### モデルとプロバイダー
|
||||
|
||||
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)にあります。
|
||||
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、および任意の URL を使える OpenAI 互換プリセット(vLLM、LM Studio、ゲートウェイ)の 25 プロバイダーに対応しています。各プロバイダーの環境変数と注意点は[プロバイダー設定ガイド](./ai-providers.md)にあります。
|
||||
|
||||
**どのモデルを選ぶか**:厳密な形式の長いテキスト(draw.io の XML)を生成するタスクなので、能力の高いモデルを選んでください。小さなモデルは壊れた図を出しがちです。
|
||||
|
||||
|
||||
+39
-3
@@ -132,7 +132,7 @@ AI_MODEL=your-deployment-name
|
||||
|
||||
```bash
|
||||
AZURE_API_KEY=your_api_key
|
||||
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME の代替
|
||||
AZURE_BASE_URL=https://your-resource.openai.azure.com/openai # AZURE_RESOURCE_NAME の代替
|
||||
AI_MODEL=your-deployment-name
|
||||
```
|
||||
|
||||
@@ -323,6 +323,18 @@ AI_MODEL=mimo-v2.5-pro
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
### 任意の OpenAI 互換 API(custom)
|
||||
|
||||
上に挙がっていないサーバー(vLLM、LM Studio、LiteLLM、API ゲートウェイ)は、設定で **OpenAI-compatible** を選び、そのベース URL とキーを入力します。キーを確認しないサーバーには空でない任意の値を入れてください。複数の system メッセージは 1 つにまとめられ(オープンソースモデルのチャットテンプレートはそれを前提にしています)、`<think>` 内の推論は返答から分離されます。
|
||||
|
||||
```bash
|
||||
CUSTOM_API_KEY=your_api_key
|
||||
CUSTOM_BASE_URL=http://127.0.0.1:8000/v1
|
||||
AI_MODEL=your_model_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自動検出
|
||||
|
||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||
@@ -330,7 +342,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
AI_PROVIDER=google # または: openai, anthropic, google, vertexai, azure, bedrock, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud, custom
|
||||
```
|
||||
|
||||
## サーバーサイドマルチモデル設定
|
||||
@@ -392,7 +404,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
||||
| `models` | はい | モデルIDのリスト |
|
||||
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数)。環境変数名の配列も指定でき、リクエストごとに値のある変数から1つがランダムに選ばれます(複数キーへの負荷分散) |
|
||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
||||
|
||||
### 備考
|
||||
@@ -431,3 +443,27 @@ TEMPERATURE=0 # より決定論的な出力(ダイアグラムに推奨)
|
||||
- **低コスト**: DeepSeek は競争力のある価格を提供しています
|
||||
- **プライバシー**: 完全にローカルなオフライン操作には Ollama を使用してください(強力なハードウェアが必要です)
|
||||
- **柔軟性**: OpenRouter は単一の API で多数のモデルへのアクセスを提供します
|
||||
|
||||
## プロバイダーの追加
|
||||
|
||||
プロバイダーに関する事実はすべて `lib/providers.ts` の 1 つのレコードにあります。使用する AI SDK(`sdk`)、サインインに使う資格情報(`credential`)、環境変数 `<PREFIX>_API_KEY` / `<PREFIX>_BASE_URL` のプレフィックス(`envPrefix`)、公式 URL、ピッカーのグループ、任意の注記です。設定画面、接続テスト、チャットのルート、管理パネルはすべてこのレコードを読むため、OpenAI 互換プロバイダーの追加は次の手順だけです。
|
||||
|
||||
1. `lib/providers.ts` にレコードを追加します。例:
|
||||
|
||||
```ts
|
||||
acme: {
|
||||
label: "Acme AI",
|
||||
sdk: "openai-compatible",
|
||||
credential: "apiKey",
|
||||
group: "aggregators",
|
||||
envPrefix: "ACME",
|
||||
defaultBaseUrl: "https://api.acme.example/v1",
|
||||
apiKeyUrl: "https://acme.example/keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
```
|
||||
|
||||
ベース URL 欄の下にヒントを表示したい、またはピッカーに注記を表示したいプロバイダーには、4 つの UI 言語(`en`、`zh`、`ja`、`zh-Hant`)をすべて含む `baseUrlHint` または `note` オブジェクトを追加します。モデル一覧をオンラインで取得できないプロバイダーは、`modelList` の代わりに短い `models` リストを持ちます。
|
||||
2. `env.example` に `ACME_API_KEY` と `ACME_BASE_URL` を追加します(各レコードの変数名がそこに現れることをユニットテストが確認します)。
|
||||
3. `docs/en`、`docs/cn`、`docs/ja` のこのドキュメントにセクションを追加します。
|
||||
4. `tests/unit/provider-matrix.test.ts` 先頭の 2 つのリスト(`MATRIX_PROVIDERS` と `MATRIX_MODEL_IDS`)に追加し、`npx vitest run tests/unit/provider-matrix.test.ts -u` で動作スナップショットを再生成します。追加される行はこのプロバイダーのものだけで、既存の行は変わってはいけません。`tests/unit/providers.test.ts` にはピッカーのグループと、サーバーの動作を変えるプロバイダーの集合(自動検出、system メッセージが 1 つだけ)が書き出されているので、そこにも追加します。3 つの README のプロバイダー数と、このドキュメントの `AI_PROVIDER` の選択肢一覧も更新します。
|
||||
|
||||
@@ -68,47 +68,15 @@ cp env.example .env.local
|
||||
|
||||
## ステップ 3 — デプロイタイプの選択
|
||||
|
||||
### オプション A: R2 なしでのデプロイ(シンプル、無料)
|
||||
### オプション A: R2 なしでのデプロイ(デフォルト、無料)
|
||||
|
||||
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
|
||||
|
||||
**1. シンプルな `open-next.config.ts` を使用:**
|
||||
|
||||
```ts
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||
|
||||
export default defineCloudflareConfig({})
|
||||
```
|
||||
|
||||
**2. シンプルな `wrangler.jsonc` を使用(r2_buckets なし):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "next-ai-draw-io-worker",
|
||||
"compatibility_date": "2025-12-08",
|
||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "next-ai-draw-io-worker"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**ステップ 4** へ進んでください。
|
||||
リポジトリはこの構成のままです。`open-next.config.ts` は `defineCloudflareConfig({})` で、`wrangler.jsonc` に `r2_buckets` はありません。ファイルを変更する必要はないので、**ステップ 4** へ進んでください。
|
||||
|
||||
---
|
||||
|
||||
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
|
||||
### オプション B: R2 ありでのデプロイ(オプション、ISR キャッシュ用)
|
||||
|
||||
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
|
||||
R2 は OpenNext が **Incremental Static Regeneration (ISR)** ページを保存する場所になります。このアプリには現在 ISR ページがないため、自分で ISR ページを追加した場合にだけ必要です。Cloudflare アカウントに支払い方法の登録が必要です。
|
||||
|
||||
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
||||
|
||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
||||
})
|
||||
```
|
||||
|
||||
**3. `wrangler.jsonc` の設定(R2 あり):**
|
||||
**3. `wrangler.jsonc` に `r2_buckets` を追加:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
||||
|
||||
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
||||
|
||||
> **注意:** 時間ベースの再検証(ページや fetch の `revalidate`)には、`open-next.config.ts` で `queue` の設定も必要です。OpenNext の Cloudflare キャッシュドキュメントを参照してください。
|
||||
|
||||
---
|
||||
|
||||
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
||||
@@ -264,4 +234,4 @@ npm run preview
|
||||
| API ルート | あり | あり |
|
||||
| 設定の複雑さ | シンプル | 普通 |
|
||||
|
||||
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。
|
||||
リポジトリのデフォルトは **R2 なし** です。ISR キャッシュが必要なページを追加した場合にだけ **R2 あり** を選んでください。
|
||||
|
||||
@@ -14,7 +14,7 @@ draw.io 用の別コンテナは不要です。
|
||||
|
||||
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
|
||||
|
||||
- 別のマシンで `scripts/fetch-drawio.mjs` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
||||
- 別のマシンで `packages/mcp-server/src/drawio-version.json` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
||||
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
|
||||
|
||||
## ほかのサイトの画像
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
# AI provider 层重构方案
|
||||
|
||||
本文最初基于 2026-10-11 对提交 2418c47 的代码阅读写成;本次修订(2026-10-12)把它核对到提交 99ced88,即死代码清理(#986)和 AI SDK 用法修正(#987)之后的 main,文中所有文件行号都在该提交上重新核对过。三份从不同角度写的草稿经过对照和核实后合并为本文,取舍记录在文末的"评审记录"。
|
||||
|
||||
术语约定,首次出现时解释,之后直接使用:
|
||||
|
||||
- "provider"指一个模型服务商的接入配置,例如 OpenAI、Qiniu、Amazon Bedrock。
|
||||
- "AI SDK"指 Vercel 的 `ai` 包和各家 `@ai-sdk/*` 工厂函数(createOpenAI、createAnthropic 等)。"provider 层"指把(provider 名、模型 id、凭据、base URL)变成 AI SDK 的 LanguageModel 对象、并向界面描述每个 provider 的那部分代码。
|
||||
- "OpenAI 兼容服务商"指只提供与 OpenAI `/v1/chat/completions` 相同接口的服务商,走 `@ai-sdk/openai-compatible`。
|
||||
- "凭据来源"指一次请求最终用谁的 API key:客户端随请求送来的(用户自己的),还是服务器环境变量里的(部署者付费)。
|
||||
- "服务器模型"指管理员在 ai-models.json、AI_MODELS_CONFIG 或管理面板里配置、由服务器 key 付费、供所有用户使用的模型。它们通过 apiKeyEnv 和 baseUrlEnv 两个字段指向环境变量名。
|
||||
- "管理面板"指 app/[lang]/admin 下的页面,它把 provider 条目存进 settings.json,并把凭据写进 `ADMIN_<PROVIDER>_API_KEY[_n]` / `ADMIN_<PROVIDER>_BASE_URL[_n]` 环境变量。
|
||||
- "连接测试"指设置对话框的 Test 按钮和管理面板的 Test 按钮共用的那段逻辑,今天在 lib/test-model-connection.ts 的 testModelConnection(req, body, trustedBaseUrl) 里,由 app/api/validate-model/route.ts(29 行,只解析请求体)和 app/api/admin/test-model/route.ts 调用。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状的问题(已逐条核对)
|
||||
|
||||
核对范围:行号与函数位置逐条在提交 99ced88 上核对过;用例数用 `npx vitest run --reporter=dot` 的汇总和 `grep -c -E "^\s*test\(" <e2e 文件>` 生成,模型数来自 lib/model-catalog.json,行数用 `wc -l` 统计。
|
||||
|
||||
**"用谁的 key、发到哪个 URL"这一个问题由多个函数各自推导,靠人手保持一致。** 相关函数如下:
|
||||
|
||||
| 函数 | 位置 | 回答的问题 |
|
||||
|---|---|---|
|
||||
| resolveBaseURL | lib/ai-providers.ts:132 | 有用户 key 时只许用户 URL(没有则留空),否则用户 URL 或服务器 URL |
|
||||
| resolveApiKey | lib/ai-providers.ts:156 | 用户 key,否则 apiKeyEnv,否则默认变量 |
|
||||
| resolveBaseUrlEnv | lib/ai-providers.ts:192 | baseUrlEnv,否则默认变量 |
|
||||
| detectProvider | lib/ai-providers.ts:483 | 服务器配了哪些 provider 的 key(Anthropic 和 Azure 各有特例) |
|
||||
| validateProviderCredentials | lib/ai-providers.ts:525 | 服务器 key 是否齐全(Anthropic 和 Azure 的特例再写一遍) |
|
||||
| getAIModel 的五个分支 | lib/ai-providers.ts:863 到 :1073 | bedrock、vertexai、ollama、edgeone、default 各自算一遍 key 和 URL |
|
||||
| globalBaseUrl | lib/ai-providers.ts:1097 | 服务器自己的 URL,供管理面板的 Test 按钮用 |
|
||||
| getServerProvider | lib/ai-providers.ts:1121 | AI_PROVIDER,否则自动检测 |
|
||||
| usesServerEndpoint | lib/ai-providers.ts:1132 | 请求是否落在部署自己的网络上(配额) |
|
||||
| usesServerCredentials | lib/ai-providers.ts:1152 | 是否由服务器 key 付费(配额和白名单) |
|
||||
| listFallbackUrl | lib/provider-models.ts:138 | 列模型时没有用户 URL 该去哪 |
|
||||
|
||||
其中 usesServerCredentials 的注释写着 "Mirrors which key each branch of getAIModel ends up using"(:1149 到 :1150),说明它必须和 getAIModel 的分支手工对齐,没有机制保证。环境变量名的拼法 `provider === "gateway" ? "AI_GATEWAY_BASE_URL" : \`${provider.toUpperCase()}_BASE_URL\`` 在 lib/ai-providers.ts:1022 到 :1025、lib/ai-providers.ts:1113 到 :1116 和 lib/admin/providers.ts:92 出现三次。Anthropic 接受 ANTHROPIC_AUTH_TOKEN、Azure 还需要 URL 或资源名这两条规则,在 detectProvider 和 validateProviderCredentials 里各写一遍。
|
||||
|
||||
核对后要指出,这十一个函数并不都在回答同一个问题。真正按请求推导"用谁的 key、去哪个 URL"的是 getAIModel 的五个分支和 usesServerCredentials;detectProvider 和 validateProviderCredentials 回答"服务器环境配齐了没有";getServerProvider 回答"用哪家";usesServerEndpoint 回答"请求落在谁的网络";listFallbackUrl 的规则与请求不同(列模型永远不用服务器 key)。本方案按这个区分处理:按请求推导的部分合并为一个函数,其余函数改为读统一的数据,但保留各自的职责。
|
||||
|
||||
**同一个 provider 的事实散在十三张以 provider 名为键的表加一个 ProviderName 联合类型里,分布在四个文件。**
|
||||
|
||||
| 表 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| ProviderName 联合类型 | lib/types/model-config.ts:3 | 24 个名字 |
|
||||
| PROVIDER_INFO | lib/types/model-config.ts:132 | label、defaultBaseUrl、apiKeyUrl、modelList |
|
||||
| FIXED_CRED_PROVIDERS | lib/types/model-config.ts:90 | bedrock、vertexai、ollama |
|
||||
| PROVIDER_LOGO_MAP | lib/types/model-config.ts:97 | models.dev 的 logo 名,19 条 |
|
||||
| SUGGESTED_MODELS | lib/types/model-config.ts:270 到 :528 | 手工维护的推荐模型 id,259 行、179 个 id |
|
||||
| chatRequestUrl 内的 ownPaths | lib/types/model-config.ts:638 | SDK 自己拼请求路径的 7 家 |
|
||||
| SINGLE_SYSTEM_PROVIDERS | lib/ai-providers.ts:43 | 只接受一条 system 消息的 7 家 |
|
||||
| PROVIDER_ENV_VARS | lib/ai-providers.ts:452 | 每家 API key 的环境变量名(模块私有) |
|
||||
| SDK_KNOWS_ENDPOINT | lib/ai-providers.ts:599 | SDK 内置官方地址的 6 家 |
|
||||
| PROVIDER_GROUPS | lib/provider-setup.ts:13 | 选择器的四个分组及组内顺序 |
|
||||
| POPULAR_PROVIDERS | lib/provider-setup.ts:50 | 空状态时推荐的 6 家 |
|
||||
| PROVIDER_NOTE_KEYS | lib/provider-setup.ts:60 | 选择器里的提示文案 key,6 家 |
|
||||
| OPTIONS_OPEN | lib/provider-setup.ts:74 | 默认展开高级选项的 5 家 |
|
||||
| NO_KEY_NEEDED | app/api/provider-models/route.ts:20 | 列模型不需要 key 的 3 家 |
|
||||
|
||||
此外脚本有一处:scripts/update-model-catalog.mjs:9 的 PROVIDERS(本项目 provider 名到 models.dev provider id 的映射,21 项)。它运行在一个独立的 node 脚本里,与 lib/ 没有导入关系,本方案保持它不变,列为待决问题 5。提交 #986 删掉了 Electron 的配置预设系统,electron/main/config-manager.ts 现在是 57 行,内容只有界面语言设置,与 provider 无关。
|
||||
|
||||
**加一个 provider 要改多处。** 提交 c8463ae(Atlas Cloud,纯 OpenAI 兼容)改了 lib/ai-providers.ts 5 处、lib/types/model-config.ts 4 处、当时的 app/api/validate-model/route.ts 1 处 case 加 1 处注释、env.example,加两个测试文件。提交 4984be8(MiMo)改了 12 个文件,其中 4 个 i18n 字典、3 个文档和 components/provider-credentials-fields.tsx 是为了一条 base URL 提示。需要说明的是,c8463ae 当时连接测试路由和 getAIModel 里还各有一个按 provider 名的 switch,提交 0b63e28 之后它们已经合并进 createModel 的 default 分支,所以今天的起点比当时好一些:一个纯 OpenAI 兼容服务商最少改 3 个代码文件 5 到 7 处(ProviderName 联合类型、PROVIDER_INFO、PROVIDER_ENV_VARS、PROVIDER_GROUPS 必改,SUGGESTED_MODELS、SINGLE_SYSTEM_PROVIDERS、PROVIDER_LOGO_MAP 视情况)。
|
||||
|
||||
**createModel 的 switch 按 24 个 provider 名分支,实际只有 14 种做法。** lib/ai-providers.ts:645 到 :736 里,kimi、mimo 和 deepseek 共用 createDeepSeek;siliconflow、sglang、modelscope、glm、qwen、qiniu、novita、atlascloud、edgeone 九家全部落到 default 的 compatibleModel。Bedrock、Vertex、Ollama、EdgeOne 四家在 getAIModel 的第二个 switch(:863 到 :1073)里另有分支。要知道一个 provider 走哪条路,必须读完两个 switch。
|
||||
|
||||
**provider 名分支散落在层外。** 按"与 provider 名字面量比较或 case"统计,lib/ai-providers.ts 之外共 44 处:components/provider-credentials-fields.tsx 11 处、lib/admin/providers.ts 8 处、lib/test-model-connection.ts 6 处(:80、:96、:109、:119 两处、:184)、lib/provider-setup.ts 5 处、components/model-config-dialog.tsx 5 处、components/provider-logo.tsx 3 处、app/api/chat/route.ts 2 处(:176 到 :178 的 isEdgeOne 和 :423 的 openai)、lib/types/model-config.ts 2 处(chatRequestUrl 的 anthropic 和 ollama)、lib/provider-models.ts 2 处(:139 ollama、:166 aihubmix;:173 到 :237 的六个 case 判别的是 modelList 字段,已经是能力字段,不计入)。这些分支问的几乎都是能力问题("要不要 key""凭据是 AWS 形式还是单个 key""能不能列模型"),回答方式却是列举 provider 名。
|
||||
|
||||
**reasoning 判定靠模型名正则,模型目录闲置。** buildProviderOptions(lib/ai-providers.ts:295 到 :449,155 行)用 `/^(o\d|gpt-([5-9]|[1-9]\d))/`(:313)判断 OpenAI 模型,google 和 vertexai 两个分支共用 geminiThinkingOptions(:248),它用 `/gemini-?[23]/`(:253)判断是否开思维链。lib/model-catalog.json 有 21 个 provider 共 1638 个模型,其中 1182 个带 reasoning 标记,969 个带 images 标记,但 buildProviderOptions 不读它。第 3.7 节给出两者的实际对比数据,结论与先前分析的倾向有出入。
|
||||
|
||||
**规模。** lib/ai-providers.ts 1240 行,lib/types/model-config.ts 648 行,lib/provider-setup.ts 138 行,lib/provider-models.ts 259 行,lib/admin/providers.ts 309 行,五个文件合计 2594 行。连接测试另在 lib/test-model-connection.ts 244 行。
|
||||
|
||||
### 1.2 "完成"的样子(可度量)
|
||||
|
||||
1. 新增一个 OpenAI 兼容服务商:代码只改 1 个文件 1 处(lib/providers.ts 加一个对象字面量,约 10 行);能在线列模型的服务商没有手写推荐列表,用户填好 key 后对话框自动拉取;env.example 和 docs/{cn,en,ja}/ai-providers.md 照常各加一段,env.example 继续手写,由第 6 步的测试保证每条记录的变量名都出现在里面。今天是 3 个代码文件 5 到 7 处。
|
||||
2. 应用代码里以 provider 名为键的表从 13 张降到 2 张:PROVIDERS(唯一的事实来源)和 POPULAR_PROVIDERS(6 家的有序列表,是界面决定)。6 家不能在线列模型的服务商的短推荐列表放在各自记录的 models 字段里。脚本的一张映射是否并入见待决问题 5。
|
||||
3. 按请求推导"用谁的 key、去哪个 URL"的代码从 getAIModel 的五个分支加 usesServerCredentials 的镜像,变为一个函数 resolveEndpoint(它读参数和 process.env,没有异常、网络请求和日志这三种副作用);环境变量名的拼法只在 envVarsOf 一处;"服务器配齐了没有"的判断只在 serverCredentialError 一处。usesServerCredentials 的"Mirrors…"注释随之删除。
|
||||
4. createModel 按 SDK 种类分支,default 不再列 provider 名;getAIModel 的第二个 switch 消失,只剩一个按 `sdk === "bedrock"` 把 Bedrock 交给 bedrockModel 的判断(见 3.2)。
|
||||
5. 界面组件、连接测试和两个路由里的 provider 名分支改读记录字段:getAIModel 顶部的 guard 和 usesServerEndpoint 读 credential 与 sdk,EdgeOne 的站点端点和 cookie 透传用 `credential === "none"` 表达。按名字写的只剩管理面板 deriveEnvUpdates 和 derivedEnvKeys 里 bedrock、vertexai、ollama 三家写固定变量名的分支(所有者要求保留,共 6 处比较)。理由见 3.4。
|
||||
6. 行数:lib/ai-providers.ts 从 1240 降到约 1140;lib/types/model-config.ts 从 648 降到约 190;lib/provider-setup.ts 从 138 降到约 70;新增 lib/providers.ts 约 470 行(含 6 家的推荐列表和 9 条四语文案:8 条从字典搬来,加 Azure 新的地址提示)。整层总行数从 2594 降到约 2430。这个数字要如实说明:本方案的收益在"每个事实只写一处"和"加 provider 的改动面",而在行数上收益很小。
|
||||
7. 现有 723 个单元测试(63 个文件)和四个 e2e 文件(settings-providers、settings-bedrock、provider-models、model-test)的 56 个用例全程保持通过,断言不改,例外有三处并且都在各自的步骤里单独 commit:第 5 步 Azure 去掉占位地址后 tests/e2e/settings-providers.spec.ts:552 那个用例的种子数据和提示文案;第 7 步推荐列表改为在线拉取后三处引用推荐模型的 e2e 断言;第 8 步 custom 记录把选择器计数断言从 24 改为 25。新增一份行为快照测试,证明每一步前后 getAIModel 送给 SDK 工厂的参数、usesServerCredentials、usesServerEndpoint、globalBaseUrl 和 chatRequestUrl 的答案逐一相同。
|
||||
|
||||
## 2. 非目标与必须保持不变的行为
|
||||
|
||||
### 2.1 非目标
|
||||
|
||||
- 换掉 Vercel AI SDK,或自己实现协议转换。magpie(参考的第三方 Go 网关)的协议翻译、路由分组和账号管理与本项目无关,AI SDK 已经做了协议处理。
|
||||
- 改动管理面板把凭据写进 `ADMIN_<PROVIDER>_API_KEY[_n]` / `ADMIN_<PROVIDER>_BASE_URL[_n]` 的机制(lib/admin/providers.ts:84 credEnvNames,:232 deriveEnvUpdates,:282 derivedEnvKeys)。它能用、有 33 个测试、所有者要求保留。本方案只让 credEnvNames 从统一记录读前缀和"固定凭据"标记,deriveEnvUpdates 和 derivedEnvKeys 里 bedrock、vertexai、ollama 三家写固定变量名的分支原样保留。
|
||||
- 在管理面板加"拉取模型"按钮。面板里的 key 是打码存储的({isSet} 标记),拉取要在服务器端解出真实 key,列为第 7 步之后的可选后续工作。
|
||||
- 改 lib/ssrf-protection.ts。
|
||||
- 改 electron/ 目录(主进程用 esbuild 单独打包,见 package.json 的 electron:compile;#986 之后它已经不含 provider 知识)。
|
||||
- 改 buildProviderOptions 读取的环境变量名、默认值和正则(第 3.7 节解释为什么维持现状)。
|
||||
- 在界面上增删 provider,或改变 provider 选择器的分组。
|
||||
- 合并七个 URL 帮助函数(normalizeBaseUrl、ollamaApiUrl、chatRequestUrl、normalizeMiniMaxBaseURL、isAihubmixStandardBaseURL、bedrockRuntimeUrl、edgeOneEndpoint)。每个函数负责一件事且有测试,合并只会产生一个更难读的函数。chatRequestUrl 内部的 ownPaths 列表改读记录字段,函数本身保留。
|
||||
|
||||
### 2.2 必须保持的规则
|
||||
|
||||
每条写明今天实现它的位置,以及方案如何保住它。"快照"指第 4 节第 0 步建立的行为快照测试。表末三行是本方案有意改变的行为,单独列出。
|
||||
|
||||
| 规则 | 今天的实现 | 方案如何保住 |
|
||||
|---|---|---|
|
||||
| 用户自己的 key 永远不发到服务器配置的 base URL;用户 key 只去用户自己的 URL 或服务商官方 URL | resolveBaseURL(lib/ai-providers.ts:132);Vertex 分支在 :964 调用它;Ollama 分支在 :988 到 :992 单独写了一遍 | resolveBaseURL 原样保留,其 9 个单元测试不改,resolveEndpoint 内部调用它。Ollama 的规则成为 resolveEndpoint 里 `credential === "optionalKey"` 的那一支,逐字搬入。快照里每个 provider 都有"用户 key 加服务器 URL 变量"场景 |
|
||||
| 客户端自定 base URL 必须带客户端自己的 key,例外:EdgeOne、没有服务器 OLLAMA_API_KEY 时的 Ollama、带 vertexApiKey 的 Vertex、trustedBaseUrl | getAIModel 顶部的 guard(lib/ai-providers.ts:760 到 :772) | 条件结构不变,三个例外改为读记录:`credential === "none"`、`credential === "optionalKey"` 且服务器没有该家的 key 变量(变量名来自 envVarsOf)、`credential === "vertexKey"` 且带 vertexApiKey。guard 在 provider 名校验之前运行,所以用 `PROVIDERS[overrides.provider]?.credential`,未知名字得到 undefined,走到与今天相同的抛错。快照里有"仅 URL 无 key"场景,所有 provider 都应抛同一条错误 |
|
||||
| 发往客户端选择的 URL 的请求用 redirectGuardedFetch;chat 路由和连接测试拒绝私网地址,除非 ALLOW_PRIVATE_URLS 允许;管理面板自己的服务器 URL 受信任(trustedBaseUrl) | lib/ai-providers.ts:855 到 :858 guardedFetch;app/api/chat/route.ts:187;lib/test-model-connection.ts:83 到 :92;app/api/admin/test-model/route.ts:56 到 :69 用 globalBaseUrl 的结果作为 trustedBaseUrl(`!resolved.baseUrl && !!serverUrl`) | guardedFetch 的构造条件搬进 resolveEndpoint 的 guarded 字段,条件逐字不变(有客户端 URL 且非 trusted)。路由和连接测试的私网检查不动。快照记录每个场景工厂是否收到守卫 fetch。注意 redirectGuardedFetch() 在 ALLOW_PRIVATE_URLS 未关闭且没有 DYNAMODB_QUOTA_TABLE 时返回 undefined(lib/ssrf-protection.ts:149 到 :151),所以快照测试把它 mock 成一个固定的哨兵函数,记录的是"构造条件是否成立",与环境开关无关。现状里 AIHubMix 官方地址走 createAihubmix(:712 到 :718)不传 fetch,这是现有例外,快照会原样记录 |
|
||||
| Bedrock:客户端 region 必须匹配 `/^[a-z]{2,4}(-[a-z]+)+-\d{1,2}$/`;四个 createAmazonBedrock 分支各自冻结:客户端 API key 分支传客户端 key、显式 baseURL 和一个抛错的 credentialProvider(key 为空时 SDK 不得回落到服务器 AWS 凭据);客户端 access key 对分支传 apiKey:"" 和显式 baseURL(SDK 不读服务器的 AWS_BEARER_TOKEN_BEDROCK 和 endpoint 变量);管理面板分支传 ADMIN_AWS_* 和 apiKey:"";无凭据时 fromNodeProviderChain | lib/ai-providers.ts:864 到 :943(:864 到 :933 是凭据选择和工厂调用,:935 到 :943 是 beta 选项合并),bedrockRuntimeUrl :581 | 凭据选择和四个 createAmazonBedrock 调用的 70 行逐字搬进函数 bedrockModel(modelId, overrides),参数一个字不变,由 getAIModel 按 `sdk === "bedrock"` 直接调用。resolveEndpoint 对 Bedrock 只回答 keySource。tests/unit/ai-providers-credentials.test.ts 的 6 个 Bedrock 用例是门禁;客户端 API key 分支的 credentialProvider 闭包必须抛错、绝不回退到服务器 AWS 凭据这一点,由 tests/unit/bedrock-api-key.test.ts 用真实 SDK 加空白 key 验证(快照里函数值进不了 JSON,验证不了这一点) |
|
||||
| usesServerCredentials 和 usesServerEndpoint 决定配额计数与服务器模型白名单,对每个输入的答案必须不变 | lib/ai-providers.ts:1152 和 :1132;调用方 app/api/chat/route.ts:245、:271,lib/test-model-connection.ts:128、:150 | usesServerCredentials 保留签名,函数体改为读 resolveEndpoint 的 keySource。usesServerEndpoint 的三个名字判断改为 `credential === "none"`、`credential === "optionalKey"` 和 `sdk !== "bedrock"`(provider 为 null 时读到 undefined,与今天 `null !== "bedrock"` 的结果相同)。第 0 步把两个函数的现有函数体原文复制进测试文件,按第 5.1 节的等价类全部枚举,逐项断言新旧相等。这份差分测试守到第 4c 步两个函数改读字段为止,之后删除 legacy 副本,由第 0 步快照里记录的两个函数返回值长期守住答案 |
|
||||
| 服务器凭据只在客户端没带自己的凭据时校验。apiKeyEnv 为数组时,至少一个变量有值即通过,并且不再做任何后续检查(Azure 的数组配置今天不要求地址)。apiKeyEnv 为单个字符串时以这一个变量为准,为空就报错,此时默认变量被忽略;只有没给 apiKeyEnv 时 Anthropic 才接受 ANTHROPIC_AUTH_TOKEN 代替 ANTHROPIC_API_KEY。apiKeyEnv 为非数组时,Azure 另需 AZURE_BASE_URL、AZURE_RESOURCE_NAME 或服务器模型自己的 baseUrlEnv 之一。四段错误文案(数组、Either、单变量、Azure 地址)和抛出顺序(先 key 再地址)不变 | isClientOverride(:775);validateProviderCredentials(:525);detectProvider(:483) | 两个函数改为共用 serverCredentialError(provider, apiKeyEnv?, baseUrlEnv?),它返回 null 或四段错误文案之一,内部顺序与今天 :531 到 :577 相同(数组提前返回、Anthropic 仅在无自定义变量时看 altKeyEnv、单变量不回退、最后 Azure 地址);detectProvider 判断它是否为 null,validateProviderCredentials 直接抛出它返回的文案。它读记录上的 keyEnv、altKeyEnv、resourceNameEnv 字段。快照包含"无客户端 key 且服务器缺变量"场景以及 Azure 数组形式无地址场景的错误文本或无错误 |
|
||||
| 服务器模型的 apiKeyEnv 可以是数组,随机选一个有值的 | resolveApiKey(:156 到 :189) | 随机抽取逐字搬入 resolveEndpoint 内部,但 `[API Key Routing] Selected…` 日志(:176)从 resolveApiKey 移到 getAIModel(Endpoint 新增 selectedKeyEnv 字段记录选中的变量名)。原因:chat 路由(app/api/chat/route.ts:240、:245)先调 getAIModel 再调 usesServerCredentials,后者改读 resolveEndpoint 后会对数组 apiKeyEnv 再抽一次;抽取结果不影响 keySource,日志移走后这次多余抽取没有任何可见效果。快照测试里把 Math.random 固定为 0 |
|
||||
| OpenAI 官方 URL 走 Responses API,任何配置过的 base URL 走 Chat Completions | createModel openai 分支(:656 到 :664)读 configuredBaseURL | resolveEndpoint 返回 configuredBaseURL,含义不变(URL 来自客户端或服务器变量时为真)。快照记录 openai 走的是 chat 还是 responses |
|
||||
| 带用户 key 的 OpenAI 显式传官方 URL(避开服务器 OPENAI_BASE_URL);带用户 key 的 Azure 必须有 URL(避开服务器 AZURE_RESOURCE_NAME);SGLang 的 127.0.0.1 默认值仅作为表单的预填值,服务器从来没有调用过它 | getAIModel default 分支 :1015 到 :1072(这三条在 :1030 到 :1055) | 规则逐字进入 resolveEndpoint,判别改为字段:OpenAI 那条用 `sdk === "openai"`,Azure 那条用 `resourceNameEnv`(SDK 会从服务器资源名拼地址的 provider,带客户端 key 时必须自带 URL),sdkKnowsEndpoint 成为记录字段。现有用例"sends a user's OpenAI key to the official endpoint""needs a base URL with a user's Azure key""needs a base URL for SGLang instead of using 127.0.0.1"不改 |
|
||||
| SINGLE_SYSTEM_PROVIDERS 的 provider 和带自定 URL 的 openai 合并 system 消息 | lib/ai-providers.ts:43;app/api/chat/route.ts:423 到 :431 | 成为记录字段 singleSystem;SINGLE_SYSTEM_PROVIDERS 保留为派生常量,chat 路由这段的 `resolvedProvider === "openai"` 改为 `PROVIDERS[resolvedProvider].sdk === "openai"`,其余不改 |
|
||||
| 管理面板:Bedrock、Vertex、Ollama 只能各有一条,凭据写固定环境变量;gateway 的前缀是 AI_GATEWAY | FIXED_CRED_PROVIDERS(lib/types/model-config.ts:90);lib/admin/providers.ts:88、:92、:173、:246 到 :262、:288 到 :302 | 成为记录字段 fixedCredentials 和 envPrefix;FIXED_CRED_PROVIDERS 在第 2 步成为派生常量,第 5 步它的两个读者(app/[lang]/admin/models-section.tsx:535 和 lib/admin/providers.ts:173)改读记录字段后,第 6 步删除。新增测试断言 24 家的 credEnvNames 输出与改动前逐字相同 |
|
||||
| 部署目标都要能用:Vercel、Cloudflare(open-next)、EdgeOne Pages(edgeone 用站点自己的 /api/edgeai 函数和 cookie)、Docker、Electron(NEXT_AI_DRAWIO_DESKTOP=1 时所有 key 都是用户的) | edgeOneEndpoint(:1083);chat 路由 :176 到 :183 和 :219 到 :224;lib/test-model-connection.ts:80 和 :184;getAIModel 的 edgeone 分支 :1003 到 :1013;两个 uses* 函数开头的 desktop 判断 | 新文件 lib/providers.ts 是纯数据,没有任何运行时 import,可被客户端组件、Edge runtime 和 Node runtime 同时引用。路由和连接测试仍调用 edgeOneEndpoint(req) 把请求 origin 变成站点地址并透传 cookie,判断条件从 provider 名改为 `credential === "none"`。desktop 判断留在两个函数开头并进快照。各目标的验证见 5.5 和 5.5a |
|
||||
| 列模型时只用客户端的 key;没有用户 URL 时去官方 URL,Ollama 无 key 时去服务器的 Ollama;SGLang 的本地默认值不作为列表地址 | lib/provider-models.ts:138 listFallbackUrl 及 :132 到 :137 的注释 | 函数保留,`startsWith("https://")` 的判断改为调用新的 publicDefaultUrl(provider) 帮助函数(getAIModel 共用),Ollama 那条改为 `credential === "optionalKey" && !apiKey`。tests/unit/provider-models.test.ts 的 19 个用例不改 |
|
||||
| env.example、三语文档、四份 i18n 字典里的通用文案 | 文件本身 | env.example 继续手写,第 6 步加测试保证每条记录的变量名都在里面。字典里服务商专属的 8 个 key(minimaxBaseUrlHint、mimoBaseUrlHint、noteBedrock、noteVertex、noteAzure、noteEdgeone、noteOllama、noteSglang)搬进记录,见 3.1;通用界面文案留在字典。tests/unit/provider-setup.test.ts 的"have their notes in every language"由类型保证代替(记录里的文案对象要求四种语言齐全) |
|
||||
| 选择器各组内的 provider 顺序 | PROVIDER_GROUPS(lib/provider-setup.ts:13) | 第 2 步 PROVIDERS 按选择器顺序书写,fixture 测试对 PROVIDER_GROUPS 逐项断言顺序;管理面板下拉框和错误文案里 provider 的排列顺序不在保持范围内,见待决问题 2 |
|
||||
| 连接测试对畸形 provider 名的回应(有意变化) | 今天 lib/test-model-connection.ts 对未知名字不校验:无 key 时 :119 返回 400 "API key is required",有 key 时到 getAIModel 才被拒,返回 200 加 "Invalid provider"。管理面板路由的 AdminProviderSchema 已经用 ProviderNameSchema 拒绝未知名字,不受影响 | 连接测试在 :71 到 :76 的非空检查里加 `Object.hasOwn(PROVIDERS, provider)`,未知名字统一返回 400 "Invalid provider: <name>. Allowed providers: …"(见 3.4 表和第 5 步的三个用例) |
|
||||
| Azure 的占位地址(有意变化) | PROVIDER_INFO.azure.defaultBaseUrl 是 "https://your-resource.openai.azure.com/openai"(lib/types/model-config.ts:162),createProviderConfig 把它写进新条目,hasCredentials(lib/provider-setup.ts:108)和对话框的 testBlocker(components/model-config-dialog.tsx:929)按名字识别"还没改过" | Azure 记录不带 defaultBaseUrl,由通用规则"credential 为 apiKey 且记录没有默认地址的服务商必须填地址"覆盖;新条目的地址为空。已经把占位地址存进 localStorage 的旧条目从此被当作填好了地址,Test 会去连 your-resource 这个主机并得到连接错误,这是本变化唯一的用户可见影响,第 5 步说明 |
|
||||
| 推荐模型列表(有意变化) | SUGGESTED_MODELS 给 19 家各一份手写列表,添加 provider 时自动加入第一个(components/model-config-dialog.tsx:414) | 能在线列模型的 18 家删除手写列表,填好凭据后对话框自动拉取一次;不能在线列模型的 6 家把列表放进记录的 models 字段并保留自动加入第一个的行为。见 3.7 和第 7 步 |
|
||||
|
||||
## 3. 目标设计
|
||||
|
||||
### 3.1 数据模型:一条 provider 记录
|
||||
|
||||
新文件 lib/providers.ts,只含类型和数据,没有任何运行时 import。ModelListStyle 类型从 lib/types/model-config.ts 搬过来。
|
||||
|
||||
```ts
|
||||
// lib/providers.ts: pure data, no runtime imports, safe for client components and every runtime
|
||||
|
||||
/** Which AI SDK factory createModel() calls */
|
||||
export type Sdk =
|
||||
| "openai" // @ai-sdk/openai: Responses API, Chat Completions behind a configured URL
|
||||
| "openai-compatible" // @ai-sdk/openai-compatible plus <think> tag extraction
|
||||
| "deepseek" // @ai-sdk/deepseek: reasoning_content round trip (deepseek, kimi, mimo)
|
||||
| "doubao" // deepseek for DeepSeek and Kimi model ids, openai-compatible otherwise
|
||||
| "anthropic"
|
||||
| "minimax" // anthropic or openai-compatible, chosen from the URL path
|
||||
| "google"
|
||||
| "vertex"
|
||||
| "azure"
|
||||
| "bedrock"
|
||||
| "openrouter"
|
||||
| "gateway"
|
||||
| "aihubmix" // @aihubmix/ai-sdk-provider on the official URL, openai-compatible elsewhere
|
||||
| "ollama"
|
||||
|
||||
/** What the settings form asks for and what a request signs in with */
|
||||
export type Credential =
|
||||
| "apiKey" // one key, required with a custom base URL
|
||||
| "optionalKey" // Ollama: the key may be empty
|
||||
| "none" // EdgeOne: the site's own function, cookies authenticate
|
||||
| "aws" // Bedrock: a Bedrock API key, or an access key pair, plus a region
|
||||
| "vertexKey" // Vertex AI Express Mode key
|
||||
|
||||
export type ModelListStyle =
|
||||
| "openai" | "anthropic" | "google" | "ollama" | "openrouter" | "aihubmix" | "gateway"
|
||||
|
||||
export type ProviderGroup = "vendors" | "aggregators" | "cloud" | "selfHosted"
|
||||
|
||||
/** One sentence in every UI language; the keys are lib/i18n/config.ts's locales */
|
||||
export type Copy = Record<"en" | "zh" | "ja" | "zh-Hant", string>
|
||||
|
||||
export interface ProviderDef {
|
||||
label: string
|
||||
sdk: Sdk
|
||||
credential: Credential
|
||||
/** The picker's group; the order of keys in PROVIDERS is the order inside a group */
|
||||
group: ProviderGroup
|
||||
/**
|
||||
* The server reads <PREFIX>_API_KEY and <PREFIX>_BASE_URL. This is only the
|
||||
* spelling of the names: whether the key is required (auto-detection,
|
||||
* validation) follows `credential`, and whether the admin panel writes
|
||||
* ADMIN_<PREFIX>_API_KEY[_n] follows `fixedCredentials`.
|
||||
* null: no key variables (Bedrock, EdgeOne)
|
||||
*/
|
||||
envPrefix: string | null
|
||||
/** The key variable when it does not follow the prefix (GOOGLE_GENERATIVE_AI_API_KEY) */
|
||||
keyEnv?: string
|
||||
/** A second variable accepted as the server key (ANTHROPIC_AUTH_TOKEN, sent as Bearer) */
|
||||
altKeyEnv?: string
|
||||
/** Azure: the SDK builds the URL from this variable when there is no base URL */
|
||||
resourceNameEnv?: string
|
||||
/**
|
||||
* The official endpoint, also the form's placeholder. A non-https value
|
||||
* (SGLang's 127.0.0.1) only fills the form: the server never calls it.
|
||||
* Missing for a credential "apiKey" provider: the user must enter one
|
||||
*/
|
||||
defaultBaseUrl?: string
|
||||
/** The SDK has the official endpoint built in: pass no baseURL unless one is configured */
|
||||
sdkKnowsEndpoint?: true
|
||||
/** Where users create a key */
|
||||
apiKeyUrl?: string
|
||||
/** How to list models; missing when a key alone cannot (Bedrock, Vertex, Azure, EdgeOne, Doubao, MiniMax) */
|
||||
modelList?: ModelListStyle
|
||||
/** The list is public or local: no key needed to list (Ollama, OpenRouter, AIHubMix) */
|
||||
listNeedsNoKey?: true
|
||||
/** A short hand-written list for providers without modelList; the first one is added with the provider */
|
||||
models?: string[]
|
||||
/** The SDK builds the request path itself, so chatRequestUrl shows nothing */
|
||||
ownRequestPath?: true
|
||||
/** Only one system message per request */
|
||||
singleSystem?: true
|
||||
/** Server credentials live in fixed env vars; the admin panel allows one entry */
|
||||
fixedCredentials?: true
|
||||
/** models.dev logo name when it differs from the key */
|
||||
logo?: string
|
||||
/** A Lucide icon for providers without a logo on models.dev */
|
||||
icon?: "cloud" | "server" | "sparkles"
|
||||
/** The note shown under the name in the picker (Bedrock: "AWS credentials") */
|
||||
note?: Copy
|
||||
/** A hint under the base URL field (MiniMax: which path selects which protocol) */
|
||||
baseUrlHint?: Copy
|
||||
/** The folded options start open: the base URL usually needs changing */
|
||||
optionsOpen?: true
|
||||
}
|
||||
|
||||
const defs = {
|
||||
openai: {
|
||||
label: "OpenAI",
|
||||
sdk: "openai",
|
||||
credential: "apiKey",
|
||||
group: "vendors",
|
||||
envPrefix: "OPENAI",
|
||||
defaultBaseUrl: "https://api.openai.com/v1",
|
||||
sdkKnowsEndpoint: true,
|
||||
apiKeyUrl: "https://platform.openai.com/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
qiniu: {
|
||||
label: "Qiniu",
|
||||
sdk: "openai-compatible",
|
||||
credential: "apiKey",
|
||||
group: "aggregators",
|
||||
envPrefix: "QINIU",
|
||||
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
||||
apiKeyUrl: "https://www.qiniu.com/ai/models",
|
||||
modelList: "openai",
|
||||
singleSystem: true,
|
||||
},
|
||||
bedrock: {
|
||||
label: "Amazon Bedrock",
|
||||
sdk: "bedrock",
|
||||
credential: "aws",
|
||||
group: "cloud",
|
||||
envPrefix: null,
|
||||
fixedCredentials: true,
|
||||
ownRequestPath: true,
|
||||
logo: "amazon-bedrock",
|
||||
icon: "cloud",
|
||||
note: { en: "AWS credentials", zh: "AWS 凭证", ja: "AWS 認証情報", "zh-Hant": "AWS 憑證" },
|
||||
models: ["global.anthropic.claude-opus-5-5", /* ... the 22 ids of today's list */],
|
||||
},
|
||||
// ... the other 21, written in the order the picker shows them
|
||||
} satisfies Record<string, ProviderDef>
|
||||
|
||||
/** The names come from the record's keys; the values are read through the widened ProviderDef type so optional fields can be accessed on any provider */
|
||||
export type ProviderName = keyof typeof defs
|
||||
export const PROVIDERS: Record<ProviderName, ProviderDef> = defs
|
||||
export const PROVIDER_NAMES = Object.keys(PROVIDERS) as ProviderName[]
|
||||
|
||||
/** The https default a server may call on its own; a local default only fills the form */
|
||||
export function publicDefaultUrl(provider: ProviderName): string | undefined
|
||||
```
|
||||
|
||||
为什么要分成 defs 和 PROVIDERS 两步:直接对 satisfies 的结果做 `PROVIDERS[p].note` 这类访问会报 TS2339,因为 satisfies 保留每条记录的字面量类型,联合类型上只有所有记录共有的字段;加宽成 `Record<ProviderName, ProviderDef>` 后字段统一,`switch (PROVIDERS[p].sdk)` 的判别仍是 Sdk 联合,不受影响。
|
||||
|
||||
字段语义说明:
|
||||
|
||||
- `sdk` 回答"createModel 调哪个工厂",取代按 provider 名的 switch。其中 "doubao"、"minimax"、"aihubmix" 三个值是服务商专属的,因为这三家各有一段无法用布尔字段表达的规则(Doubao 按模型名选 createDeepSeek,MiniMax 按 URL 路径选协议,AIHubMix 在官方地址用专用 SDK 并附 appCode)。三个专属 case 比一套通用的"按 URL 或模型名选协议"抽象更短、更好懂。kimi 和 mimo 直接写 `sdk: "deepseek"`。
|
||||
- `credential` 回答"表单收什么、连接测试查什么",取代 lib/provider-setup.ts:97 hasCredentials、lib/test-model-connection.ts:96 到 :124、components/provider-credentials-fields.tsx 和 components/model-config-dialog.tsx:925 里按名字的分支。
|
||||
- `envPrefix` 加 `keyEnv` 覆盖今天三处拼变量名的全部情况:21 家按 `<PREFIX>_API_KEY` / `<PREFIX>_BASE_URL`;gateway 的前缀是 AI_GATEWAY;vertexai 的前缀是 GOOGLE_VERTEX(正好对应 GOOGLE_VERTEX_API_KEY 和 GOOGLE_VERTEX_BASE_URL);google 的 URL 变量是 GOOGLE_BASE_URL 但 key 变量是 GOOGLE_GENERATIVE_AI_API_KEY,所以需要 keyEnv;bedrock 和 edgeone 为 null。ollama 今天在 PROVIDER_ENV_VARS 里是 null,含义是"自动检测和校验时不要求它",实际它有 OLLAMA_API_KEY。新模型里它的 envPrefix 是 "OLLAMA",credential 是 "optionalKey"。自动检测和服务器凭据校验按 credential 为 apiKey 或 vertexKey 过滤;aws、optionalKey、none(bedrock、ollama、edgeone)不参与,这个集合与今天 PROVIDER_ENV_VARS 的非 null 条目逐项相同。派生 PROVIDER_ENV_VARS 时,credential 为 aws、optionalKey、none 的 provider 保持 null,所以 ollama 虽有 envPrefix "OLLAMA",在这张表里仍是 null,两段 'No AI provider configured' 和 'Multiple AI providers configured' 的文案不变。envPrefix 只决定变量名的拼法,是否参与自动检测和校验由 credential 决定,是否进管理面板由 fixedCredentials 决定。因此第 2 步派生 PROVIDER_ENV_VARS 时只对 credential 为 apiKey 或 vertexKey 的 provider 给出变量名,其余为 null(第 2 步的 fixture 测试守住这一点);第 3 步的 serverCredentialError 对 credential 为 aws、optionalKey、none 的 provider 在没有 apiKeyEnv 时返回 null,对应今天 :555 的 requiredVar 为 null 时不抛错。
|
||||
- `fixedCredentials` 显式标在 bedrock、vertexai、ollama 三家上。从 credential 推导(非 apiKey 非 none 即固定)看似可行,但将来若加一个本地服务商也用 optionalKey,推导会把它误判为固定凭据,显式标注更安全。
|
||||
- `sdkKnowsEndpoint` 逐字对应今天的 SDK_KNOWS_ENDPOINT 集合(openai、google、azure、openrouter、gateway、deepseek)。注意 kimi 和 mimo 虽然走 createDeepSeek 但不在集合里,它们的 defaultBaseUrl 要显式传给工厂,所以这是 provider 级别的字段,与 sdk 无关。
|
||||
- `defaultBaseUrl` 对 Azure 不填。今天的占位值 "https://your-resource.openai.azure.com/openai" 让 hasCredentials、createProviderConfig 和对话框各写一条"还没改过占位值"的 Azure 特判;去掉后 Azure 和 custom 一样落进通用规则"credential 为 apiKey 且没有默认地址,必须填地址"。地址形状改由 Azure 记录的 baseUrlHint 说明(四种语言各一句,内容是 https://<资源名>.openai.azure.com/openai)。
|
||||
- `ownRequestPath` 逐字对应 chatRequestUrl 的 ownPaths(google、vertexai、azure、bedrock、gateway、minimax、edgeone)。EdgeOne 虽然是 openai-compatible 却在列表里(它的 URL 是站点自己的,显示请求地址没有意义),所以这个字段按 provider 逐条标注,与 sdk 字段无关。
|
||||
- `logo` 只在 models.dev 的名字与 provider 键不同时填写(今天 PROVIDER_LOGO_MAP 的 19 条里有 11 条与键相同,可以省略,派生出的 PROVIDER_LOGO_MAP 只剩 8 条)。它唯一的读者 components/provider-logo.tsx:24 写 `PROVIDER_LOGO_MAP[provider] || provider`,所以省略同名条目后渲染结果不变;components/model-selector.tsx 已经通过 ProviderLogo 组件渲染 logo(:254、:333),不读这张表。测试按每家的 `logo ?? key` 比较,见第 2 步。sglang(logo: "openai", icon: "server")、doubao(logo: "bytedance", icon: "sparkles")与 bedrock 一样同时填两个字段,第 2 步的 fixture 测试会要求 PROVIDER_LOGO_MAP 的派生值与旧表相同。`icon` 取代 components/provider-logo.tsx:14 到 :22 的三个 if。
|
||||
- `models` 字段仅在没有 modelList 的 6 家上出现(bedrock 22 个、azure 10 个、doubao 9 个、vertexai 7 个、minimax 4 个、edgeone 1 个,共 53 个 id,约 70 行)。能在线列模型的 18 家没有这个字段,对话框在凭据填好后自动拉取,见 3.7 和第 7 步。
|
||||
- `group`、`note`、`optionsOpen`、`baseUrlHint` 取代 lib/provider-setup.ts 的 PROVIDER_GROUPS、PROVIDER_NOTE_KEYS、OPTIONS_OPEN,以及 provider-credentials-fields.tsx:251 到 :259 的 minimax、mimo 提示分支。note 和 baseUrlHint 是四种语言齐全的文案对象,内容从今天四份字典的 8 个 key 逐字搬来(lib/i18n/dictionaries/zh.json:301、:302、:345 到 :350,其余三份字典同名 key),这 8 个 key 随后从字典删除。组件用 components/canvas/locale-context.tsx 的 useLocale() 取当前语言后读 `note[locale]`。将来再加一家需要地址提示的服务商(MiMo 那种情况),代码改动只有记录里一个字段。
|
||||
- 记录键的书写顺序就是选择器各组内的显示顺序。核对发现今天 PROVIDER_GROUPS 的组内顺序与 PROVIDER_INFO 的键顺序并不一致:vendors 组是 openai、anthropic、google、deepseek、qwen、doubao、glm、kimi、minimax、mimo,而 PROVIDER_INFO 的键顺序会得到 deepseek、doubao、glm、qwen;aggregators 和 cloud 两组也不同。方案是把记录按选择器顺序书写一次。管理面板"添加 provider"下拉框(app/[lang]/admin/models-section.tsx:529)用 `Object.keys(PROVIDER_INFO)`,顺序会跟着变成与选择器一致,列入待决问题 2。
|
||||
|
||||
**保留为独立表的一张及理由。** POPULAR_PROVIDERS 是一个有顺序的 6 元素列表(openai、anthropic、google、deepseek、openrouter、qwen),顺序与记录键顺序不同(openrouter 在 qwen 前),保留为手写列表。推荐模型列表不再是独立表:18 家能在线列模型的服务商改为自动拉取,6 家不能列的把短列表放进记录的 models 字段,理由见 3.7。
|
||||
|
||||
**兼容性与过渡。** lib/types/model-config.ts 先 `import { PROVIDERS, type ProviderName } from "@/lib/providers"` 供文件内 ModelConfig、ProviderConfig、createProviderConfig 等处使用,再 `export { PROVIDERS as PROVIDER_INFO, type ProviderName }` 转发给其余 11 个文件;转发导出本身在模块内部不建立绑定,所以两句都要写。PROVIDER_INFO 今天被 12 个文件引用,它们读的 label、defaultBaseUrl、apiKeyUrl、modelList 四个字段都在 ProviderDef 上,而 PROVIDERS 的值类型就是 ProviderDef,所以引用处不用改。其余的表里,十张(ProviderName 联合类型改为 keyof typeof PROVIDERS,FIXED_CRED_PROVIDERS、PROVIDER_LOGO_MAP、ownPaths、SINGLE_SYSTEM_PROVIDERS、PROVIDER_ENV_VARS、SDK_KNOWS_ENDPOINT、PROVIDER_GROUPS、OPTIONS_OPEN、NO_KEY_NEEDED)在第 2 步变成从 PROVIDERS 派生的常量,放在原文件以原名导出;PROVIDER_NOTE_KEYS 是文案 key 的表,记录里存的是文案本身,派生不出来,它和 SUGGESTED_MODELS 在第 2 步原样保留,分别在第 5 步和第 7 步随读者一起改掉:
|
||||
|
||||
```ts
|
||||
// lib/provider-setup.ts, transitional
|
||||
export const PROVIDER_GROUPS = (["vendors", "aggregators", "cloud", "selfHosted"] as const).map(
|
||||
(id) => ({
|
||||
id,
|
||||
providers: PROVIDER_NAMES.filter((p) => PROVIDERS[p].group === id),
|
||||
}),
|
||||
)
|
||||
export const OPTIONS_OPEN = PROVIDER_NAMES.filter((p) => PROVIDERS[p].optionsOpen)
|
||||
|
||||
// lib/ai-providers.ts, transitional
|
||||
export const SINGLE_SYSTEM_PROVIDERS = new Set(PROVIDER_NAMES.filter((p) => PROVIDERS[p].singleSystem))
|
||||
```
|
||||
|
||||
这样数据层的改动和界面的改动分在不同步骤,第 5 步的界面改动可以单独回滚而不影响数据层;回滚第 2 步需要先回滚第 5、6 步。读者在第 5 步后直接读字段的派生常量,在第 6 步删除。
|
||||
|
||||
### 3.2 从记录选 AI SDK 工厂
|
||||
|
||||
createModel(lib/ai-providers.ts:645)的签名不变(Bedrock 除外,见下),switch 的判别式从 provider 名换成 `PROVIDERS[provider].sdk`,每个 case 的内容照搬:
|
||||
|
||||
```ts
|
||||
function createModel(provider: ProviderName, modelId: string, e: Endpoint): LanguageModel {
|
||||
switch (PROVIDERS[provider].sdk) {
|
||||
case "openai": ... // unchanged body: chat behind a configured URL, else responses
|
||||
case "anthropic": ...
|
||||
case "google": ...
|
||||
case "azure": ...
|
||||
case "openrouter": ...
|
||||
case "gateway": ...
|
||||
case "deepseek": ... // deepseek, kimi, mimo
|
||||
case "doubao": ... // model-name choice, unchanged
|
||||
case "aihubmix": ...
|
||||
case "minimax": ...
|
||||
case "openai-compatible": // siliconflow, sglang, modelscope, glm, qwen, qiniu, novita, atlascloud, edgeone, custom
|
||||
return compatibleModel(provider, modelId, e)
|
||||
// "bedrock" never reaches createModel: getAIModel calls bedrockModel(modelId, overrides) first, see below
|
||||
case "vertex": // today's createVertex call at :969
|
||||
return createVertex({ apiKey: e.apiKey, ...(e.baseURL && { baseURL: e.baseURL }), ...(e.fetch && { fetch: e.fetch }) })(modelId)
|
||||
case "ollama": // today's createOllama call at :993
|
||||
return createOllama({ ...(e.baseURL && { baseURL: ollamaApiUrl(e.baseURL) }), ...(e.apiKey && { headers: { Authorization: `Bearer ${e.apiKey}` } }), ...(e.fetch && { fetch: e.fetch }) })(modelId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bedrock 在 getAIModel 里先于 createModel 处理:`if (PROVIDERS[provider].sdk === "bedrock") { model = bedrockModel(modelId, overrides) } else { model = createModel(provider, modelId, {...}) }`。bedrockModel 需要 overrides 里的 apiKey、awsAccessKeyId、awsSecretAccessKey、awsSessionToken、awsRegion 五个字段,这些不进 Endpoint,所以它没有成为 createModel 的 case。getAIModel 里的第二个 switch(:863 到 :1073)随之消失。Bedrock 分支里凭据选择和 createAmazonBedrock 调用的 70 行(:864 到 :933)搬进 bedrockModel(modelId, overrides),内容不动(region 正则、apiKey:"" 和 bedrockRuntimeUrl、ADMIN_AWS_* 优先于 fromNodeProviderChain);:935 到 :943 的 anthropic.claude beta 选项合并留在 getAIModel 里,按模型 id 判断。Vertex、Ollama 的 key 和 URL 规则进入 resolveEndpoint,工厂调用成为 createModel 的 case。EdgeOne 成为 openai-compatible 加 `credential: "none"` 的普通情况。加一个 OpenAI 兼容服务商时这个函数一字不改。
|
||||
|
||||
### 3.3 一个决定 key 和 URL 的函数
|
||||
|
||||
lib/ai-providers.ts:609 到 :618 今天已经有一个模块私有的 Endpoint 接口(apiKey、baseURL、headers、fetch、authToken、resourceName、可选的 configuredBaseURL),createModel 和 compatibleModel 读它。方案把它导出并补三个字段:
|
||||
|
||||
```ts
|
||||
// lib/ai-providers.ts
|
||||
|
||||
export type KeySource = "client" | "server" | "none"
|
||||
|
||||
/** Where and how to call a provider, once credentials are resolved */
|
||||
export interface Endpoint {
|
||||
apiKey?: string
|
||||
authToken?: string // Anthropic Bearer auth (ANTHROPIC_AUTH_TOKEN), server only
|
||||
resourceName?: string // Azure, server resource only
|
||||
baseURL?: string // undefined: the SDK's built-in endpoint
|
||||
configuredBaseURL: boolean // the URL came from the client or a server env var (today optional, becomes required)
|
||||
guarded: boolean // a client-chosen, untrusted URL: wrap fetch in redirectGuardedFetch
|
||||
keySource: KeySource // "server" is what usesServerCredentials answers true for
|
||||
selectedKeyEnv?: string // which apiKeyEnv entry was drawn (array case); getAIModel logs it
|
||||
headers?: Record<string, string>
|
||||
fetch?: typeof fetch // set by getAIModel from guarded; resolveEndpoint never fills it
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose key the call uses and where it goes. Reads its arguments and
|
||||
* process.env, and draws one key with Math.random when apiKeyEnv is an
|
||||
* array; never throws, never fetches, never logs. getAIModel decides what to do
|
||||
* when a key or URL is missing; usesServerCredentials reads keySource.
|
||||
* Callers pass a validated ProviderName: getAIModel after its guard, the chat
|
||||
* route via getAIModel's resolvedProvider, testModelConnection after its own
|
||||
* Object.hasOwn check. An unknown name is a programming error here.
|
||||
*/
|
||||
export function resolveEndpoint(provider: ProviderName, overrides?: ClientOverrides): Endpoint
|
||||
|
||||
/**
|
||||
* The env var names of a provider's key and base URL, from PROVIDERS. Always the
|
||||
* record-derived names (ollama gets OLLAMA_API_KEY, which resolveEndpoint needs);
|
||||
* whether the server must have the key is serverCredentialError's question
|
||||
*/
|
||||
export function envVarsOf(provider: ProviderName): { apiKey?: string; baseUrl?: string }
|
||||
|
||||
/**
|
||||
* Why the server lacks this provider's credentials, or null when it has them; same
|
||||
* checks and order as today's validateProviderCredentials (detectProvider tests for
|
||||
* null, validateProviderCredentials throws the string). With no apiKeyEnv given,
|
||||
* aws, optionalKey and none return null at once, like today's unchecked requiredVar === null
|
||||
*/
|
||||
function serverCredentialError(provider: ProviderName, apiKeyEnv?: string | string[], baseUrlEnv?: string): string | null
|
||||
```
|
||||
|
||||
resolveEndpoint 的规则按 credential 种类分五支,每支都是今天 getAIModel 里已有的逐行逻辑搬了位置,不新增判断:
|
||||
|
||||
1. 客户端 URL:resolveEndpoint 假定 `overrides.baseUrl` 已经由调用方用 normalizeBaseUrl 清理过,函数内部只做 `const clientUrl = overrides?.baseUrl || undefined`,空串视为没有,自己不再调用 normalizeBaseUrl。原因是 normalizeBaseUrl 每次只去掉一个结尾端点路径(".../v1/chat/completions/messages" 第一次得到 ".../v1/chat/completions",第二次得到 ".../v1"),归一化两次会让 getAIModel 和 usesServerCredentials 看到不同的地址。今天 getAIModel :746 到 :751 和 usesServerCredentials :1159 各归一化一次,重构后仍然各一次,位置不变。
|
||||
2. `aws`(bedrock):客户端带 apiKey,或同时带 awsAccessKeyId 和 awsSecretAccessKey,则 keySource 为 "client",否则为 "server"(包括 ADMIN_AWS_* 和 IAM 角色两种情况,今天在 usesServerCredentials 里都算服务器付费)。apiKey 和 baseURL 不填,Bedrock 的 SDK 参数在 bedrockModel() 里按今天的写法构造。
|
||||
3. `vertexKey`(vertexai):有客户端 URL 时 apiKey 只取 overrides.vertexApiKey(:951 到 :953 的规则);否则 vertexApiKey 优先,其次 GOOGLE_VERTEX_API_KEY。baseURL 调用 resolveBaseURL(vertexApiKey, 客户端 URL, GOOGLE_VERTEX_BASE_URL)。keySource 为有 vertexApiKey 时 "client",否则 "server"。
|
||||
4. `none`(edgeone):apiKey 固定为 "edgeone"(:1008 传的值),baseURL 为 `overrides.baseUrl || "/api/edgeai"`(:1009),headers 透传,keySource "none"。路由和连接测试在调用之前已经用 edgeOneEndpoint(req) 把站点地址放进 overrides.baseUrl,并且只在 `credential === "none"` 时这样做。
|
||||
5. `optionalKey`(ollama):有客户端 URL 时 key 只取 overrides.apiKey(:981 到 :983);否则按 resolveApiKey 的三级优先取 OLLAMA_API_KEY。baseURL 为客户端 URL,否则客户端有 key 时为 Ollama Cloud 的 defaultBaseUrl,否则为服务器变量(:988 到 :992)。keySource 在这一支单独写出,是用到 resolveApiKey 的分支里唯一没有从它的 source 取 keySource 的地方:有客户端 URL 时按 key 是否存在给 "client" 或 "none";无客户端 URL 时,有客户端 key 为 "client",否则 `overrides.apiKeyEnv || process.env.OLLAMA_API_KEY` 为真时 "server",否则 "none"(无 key 的本地 Ollama 不计费)。条件照抄 usesServerCredentials :1174 到 :1178,并在代码里注明为什么这里不能直接用 source:resolveApiKey 在变量为空时也会给 "server",而 Ollama 今天把"没有任何 key"算作不计费。apiKeyEnv 变量名给了但值为空的边角见待决问题 3。
|
||||
6. `apiKey`(其余 20 家):default 分支 :1015 到 :1072 的逻辑原样搬入,四处按名字的判断改为读字段。apiKey 和 keySource 来自同一次 resolveApiKey 调用:它改为返回 `{ value, source }`,source 在返回 overrides.apiKey 时为 "client",在读 apiKeyEnv 或默认变量时为 "server"(变量为空也算 "server",与今天 usesServerCredentials 的 `!overrides?.apiKey` 相同);默认变量名来自 envVarsOf。configuredBaseURL 为 resolveBaseURL(客户端 key, 客户端 URL, 服务器 URL 变量);baseURL 为 configuredBaseURL,否则 sdkKnowsEndpoint 且不是"`sdk === "openai"` 带客户端 key"时留空,否则 publicDefaultUrl(:1039 到 :1044)。authToken 在记录有 altKeyEnv 且无 apiKey 时取该变量的值(:1062 到 :1064 的 Anthropic 规则);resourceName 在记录有 resourceNameEnv 且无客户端 key 时取该变量的值(:1067 到 :1069 的 Azure 规则)。keySource 直接取 source,不另写判断。
|
||||
7. `guarded` 等于 `!!clientUrl && !overrides.trustedBaseUrl`,对所有 provider 相同(:855 到 :858)。
|
||||
|
||||
这个函数对合法 provider 名不抛异常的原因:连接测试在请求之前就要问 usesServerCredentials(lib/test-model-connection.ts:128),那时凭据可能不全,必须得到一个布尔值。provider 名本身由调用方先校验(见 3.4 表连接测试一行),chat 路由传的是 getAIModel 返回的 resolvedProvider(app/api/chat/route.ts:239、:245 到 :247),不受影响。所以缺 key、缺 URL 的三条抛错留在 getAIModel 里,表达在结果上,并且各自只对今天抛出它的那个分支对应的凭据类别生效:Vertex 缺 key 抛 :955 到 :960 的原话(credential === "vertexKey");"needs a base URL" 的条件保留 :1047 到 :1055 的原话,但只对 credential === "apiKey" 的 provider 检查,条件写成 `!baseURL && (!sdkKnowsEndpoint || (resourceNameEnv && 客户端 key))`,即今天 default 分支覆盖的 20 家加 custom。Bedrock、Vertex、Ollama、EdgeOne 的 baseURL 可以合法为空(Bedrock 不读 URL,Vertex 和 Ollama 用 SDK 内置默认),它们不经过这条检查;Azure 带客户端 key 却没有 URL 由 resourceNameEnv 那一半条件命中,仍在 apiKey 类别内。错误文案不变、位置不变。
|
||||
|
||||
调用方的变化:
|
||||
|
||||
- getAIModel:模型 id 选择、provider 选择、`if (!isClientOverride) validateProviderCredentials(...)` 三段不变。getAIModel :746 到 :751 把 clientOverrides.baseUrl 归一化成 overrides 的那段也保留,guard 读它,resolveEndpoint 收到的就是这个归一化后的 overrides。顶部 guard(:760 到 :772)的结构不变,三个例外改为读 `PROVIDERS[overrides.provider]?.credential`(见 2.2 第二行)。之后先判断 `PROVIDERS[provider].sdk === "bedrock"`,是则调用 bedrockModel(modelId, overrides);否则 `const e = resolveEndpoint(provider, overrides)`,按 PROVIDERS[provider].credential 检查上面三条抛错,然后 `createModel(provider, modelId, { ...e, fetch: e.guarded ? redirectGuardedFetch() : undefined })`。resolveEndpoint 的返回值不含 fetch,getAIModel 按 guarded 填入后再传给 createModel;两者共用 Endpoint 类型。数组 apiKeyEnv 的 `[API Key Routing] Selected…` 日志在这里按 e.selectedKeyEnv 打印。Bedrock 的 anthropic.claude beta 合并留在这里。
|
||||
- usesServerCredentials 变成四行:desktop 判断,`const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "")`(今天 :1159 的那一行保留),然后 `return resolveEndpoint(provider, { ...overrides, baseUrl }).keySource === "server"`。"Mirrors…"注释删除。
|
||||
- usesServerEndpoint(:1132 到 :1145)的三个名字判断改为 `credential === "none"`、`credential === "optionalKey" && !clientBaseUrl && !apiKey` 和 `sdk !== "bedrock"`,其余不动;第 5.1 节的 288 个组合证明答案相同。
|
||||
- detectProvider 和 validateProviderCredentials:共用 serverCredentialError,它读 keyEnv、altKeyEnv、resourceNameEnv,Anthropic 和 Azure 的 if 只写一次,四段检查的顺序照搬今天 :531 到 :577:数组有值即返回 null(不检查 Azure 地址);无自定义变量时 Anthropic 看 keyEnv 或 altKeyEnv;单个自定义变量为空直接返回单变量文案,不回退默认变量;最后才是 Azure 地址。文案从记录字段拼出:数组形式用 apiKeyEnv.join(", "),altKeyEnv 存在且无 apiKeyEnv 时用 `Either ${keyEnv} or ${altKeyEnv} ...`,通用用 `${requiredVar} environment variable is required for ${provider} provider ...`,resourceNameEnv 存在且 key 有值但地址缺失时用 `Azure requires either ${envVarsOf(provider).baseUrl} or ${resourceNameEnv} ...`,四段逐字与 lib/ai-providers.ts:535 到 :574 相同。validateProviderCredentials 抛出返回的文案,detectProvider 只看是否为 null。detectProvider 遍历 credential 为 apiKey 或 vertexKey 的 provider;新增测试断言这个集合等于改动前 PROVIDER_ENV_VARS 非 null 的键集合(21 家),Anthropic 和 Azure 的特例保留。
|
||||
- globalBaseUrl(:1097 到 :1118):函数体保留(它对 ollama 和 vertexai 用 getEnvFallback 读设置文件回退、对 bedrock 和 edgeone 返回空、对 azure 按资源名拼地址、对其余用 process.env 的差异是管理面板需要的,必须原样保留),只把 :1113 到 :1116 的变量名拼法换成 envVarsOf(provider).baseUrl。它是 lib/ai-providers.ts 里唯一仍按名字分支的函数。
|
||||
- lib/admin/providers.ts:92 credEnvNames 的前缀换成 `PROVIDERS[provider].envPrefix`,:88 的判断换成 `fixedCredentials || envPrefix === null`。
|
||||
- listFallbackUrl:`startsWith("https://")` 换成 publicDefaultUrl(provider),Ollama 那条改为 `credential === "optionalKey"`。
|
||||
- resolveApiKey 和 resolveBaseUrlEnv 成为 resolveEndpoint 的私有帮助函数,不再被别处调用。resolveApiKey 的返回值从 `string | undefined` 改为 `{ value?: string; source: "client" | "server"; selectedKeyEnv?: string }`,让 apiKey、authToken(Anthropic)和 keySource 出自同一个决定;第 4a 步的差分测试同时断言 source 与 legacy usesServerCredentials 一致。
|
||||
|
||||
### 3.4 界面和路由读能力字段
|
||||
|
||||
| 今天的分支 | 改为 |
|
||||
|---|---|
|
||||
| lib/provider-setup.ts:97 hasCredentials 按名字 switch(bedrock、vertexai、azure、edgeone、ollama) | 按 `credential` 种类 switch:aws 看 key 或 access key 对加 region;vertexKey 看 vertexApiKey;none 和 optionalKey 为真;apiKey 要求 `!!p.apiKey && (!!defaultBaseUrl || !!p.baseUrl)`。今天 apiKey 类里只有 Azure 没有可用的默认地址,去掉占位值后它和 custom 一样由这条通用规则覆盖,Azure 那个 case 消失。providerStatus 和 :925 的 testBlocker 在用户只填 key 时显示"未填完",与点 Test 后 getAIModel 报 "needs a base URL" 一致 |
|
||||
| lib/test-model-connection.ts:96 到 :124 按 bedrock、vertexai、ollama、edgeone 检查凭据 | 先在 :71 到 :76 的非空检查里加 `!Object.hasOwn(PROVIDERS, provider)`,命中时返回 400,文案沿用 getAIModel :803 的 "Invalid provider: <name>. Allowed providers: …";之后才 `switch (PROVIDERS[provider].credential)`,五种凭据各一句,错误文案不变。请求体的 TestModelRequest 只是类型断言,今天未知名字要到 getAIModel 才被拒,而改读记录后 :80、凭据 switch 和 usesServerCredentials 都会在那之前读 PROVIDERS[provider],没有这一步会抛 TypeError 并被 catch 转成 200。这是本方案对畸形请求的有意行为变化(今天无 key 返回 400 "API key is required",有 key 返回 200 加 "Invalid provider",之后统一为 400 "Invalid provider"),已记入 2.2 节表末。管理面板路由在调用之前已经用 ProviderNameSchema 校验,不受影响 |
|
||||
| 同文件 :80 `provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl` 和 :184 的 EdgeOne cookie、x-access-code 头 | `PROVIDERS[provider].credential === "none"`,两处共用一个局部变量 |
|
||||
| app/api/chat/route.ts:176 到 :178 isEdgeOne 及其在 :183、:203、:219 的三处使用 | `const onSiteFunction = PROVIDERS[name]?.credential === "none"`,name 是 serverModelConfig.provider、x-ai-provider 头、getServerProvider() 三者的第一个有值者(与今天 :177 相同);:203 回填的 provider 名改为这个 name。tests/unit/chat-route-edgeone.test.ts 的 4 个用例不改 |
|
||||
| 同文件 :423 `resolvedProvider === "openai"` | `PROVIDERS[resolvedProvider].sdk === "openai"`,见 2.2 的 SINGLE_SYSTEM_PROVIDERS 一行 |
|
||||
| app/api/provider-models/route.ts:20 NO_KEY_NEEDED | `PROVIDERS[provider].listNeedsNoKey` |
|
||||
| components/provider-credentials-fields.tsx:202 ollama 的"可选"标签 | `credential === "optionalKey"` |
|
||||
| 同文件 :251、:256 minimax、mimo 的地址提示 | `baseUrlHint && baseUrlHint[locale]` |
|
||||
| 同文件 :265 edgeone 不渲染凭据 | `credential === "none"` |
|
||||
| 同文件 :108 OPTIONS_OPEN.includes | `optionsOpen` |
|
||||
| 同文件 :270、:396、:426 到 :433、:448、:482 bedrock 与 vertexai 的两套表单 | `credential === "aws"` 与 `"vertexKey"`。这些仍是分支,但依据是凭据种类;再加一家 AWS 风格的服务商不用改表单 |
|
||||
| components/settings/provider-picker.tsx:96 PROVIDER_NOTE_KEYS[provider] 加字典查找 | `PROVIDERS[provider].note?.[locale]` |
|
||||
| components/provider-logo.tsx:14 到 :24 三个 if 加 PROVIDER_LOGO_MAP | 读 `icon`,有则渲染对应 Lucide 图标;否则 `logo ?? provider`。components/model-selector.tsx 已经通过 ProviderLogo 渲染(:21 import,:254、:333 使用),不用改 |
|
||||
| components/model-config-dialog.tsx:925 到 :932 testBlocker、:300 到 :306 errorHints | errorHints 按 `credential === "aws"`;testBlocker 按 `credential === "aws"` 给 needAws,`credential === "apiKey" && !defaultBaseUrl` 给新的通用文案 needUrl("先填 API key 和地址",四种语言),其余 needKey。needAzure 这个 key 删除,Azure 和 custom 共用 needUrl |
|
||||
| 同文件 :535 EdgeOne 的 origin 拼接、:987 EdgeOne 不渲染凭据表单 | `credential === "none"` |
|
||||
| 同文件 :376、:403、:414 读 SUGGESTED_MODELS | 第 7 步改读 `PROVIDERS[p].models ?? []`,见该步 |
|
||||
| lib/types/model-config.ts:634 到 :646 chatRequestUrl 的 anthropic、ollama 特殊路径和 ownPaths | `sdk === "anthropic"` 给 `/messages`,`sdk === "ollama"` 给 ollamaApiUrl 加 `/chat`,其余按 `ownRequestPath`。tests/unit/base-url.test.ts 不改 |
|
||||
| lib/admin/providers.ts:88、:92 credEnvNames | 读 fixedCredentials 和 envPrefix |
|
||||
| app/[lang]/admin/models-section.tsx:535 envBlocked 用 FIXED_CRED_PROVIDERS.includes(p) | `PROVIDERS[p].fixedCredentials && envProviders.some(...)` |
|
||||
| 同文件 :76 读 SUGGESTED_MODELS | 第 7 步改读 `PROVIDERS[p].models`,没有时不渲染"推荐"下拉框 |
|
||||
| lib/admin/providers.ts:173 validateAdminProviders 遍历 FIXED_CRED_PROVIDERS | 遍历 PROVIDER_NAMES 里 fixedCredentials 为真的 provider,两条错误文案不变;tests/unit 里 33 个管理面板测试不改 |
|
||||
| lib/server-model-config.ts:9 ProviderNameSchema 和 :84 | `Object.hasOwn(PROVIDERS, val)`(今天的 `val in PROVIDER_INFO` 会放行 "toString" 这类原型链上的名字;顺手改,一个词) |
|
||||
| lib/provider-models.ts:139 listFallbackUrl 的 ollama、:166 的 aihubmix | `credential === "optionalKey"`、`sdk === "aihubmix"` |
|
||||
|
||||
**保留按名字写的地方及理由。** 层外只剩 lib/admin/providers.ts:246 到 :262 和 :288 到 :302 里 bedrock、vertexai、ollama 三家写固定变量名的分支(两个函数各一份,共 6 处比较),它们属于非目标,所有者要求原样保留。层内保留两处:globalBaseUrl 的函数体(管理面板依赖它对各家的差异,见 3.3)和 bedrockModel(本身就是 Bedrock 专用函数,由 `sdk === "bedrock"` 进入)。getAIModel 顶部的 guard、usesServerEndpoint、EdgeOne 的站点端点判断原本打算按名字保留以便审计,所有者决定改读字段,审计依据改为第 0 步快照里"仅 URL 无 key"和 EdgeOne 的全部场景以及 5.1 节的 288 个 usesServerEndpoint 组合。
|
||||
|
||||
### 3.5 "custom" 预设
|
||||
|
||||
一条普通记录,没有新机制:
|
||||
|
||||
```ts
|
||||
custom: {
|
||||
label: "OpenAI-compatible",
|
||||
sdk: "openai-compatible",
|
||||
credential: "apiKey",
|
||||
group: "selfHosted",
|
||||
envPrefix: "CUSTOM",
|
||||
modelList: "openai",
|
||||
icon: "server",
|
||||
note: { en: "Any OpenAI-compatible URL and key", zh: "任何 OpenAI 兼容地址和 key", ja: "OpenAI 互換の URL とキー", "zh-Hant": "任何 OpenAI 相容位址和 key" },
|
||||
optionsOpen: true,
|
||||
singleSystem: true, // open-source chat templates reject several system messages
|
||||
},
|
||||
```
|
||||
|
||||
没有 defaultBaseUrl,所以 hasCredentials 在用户只填 key 时返回 false,状态显示"未填完"、Test 按钮给出 needUrl 提示(见 3.4);绕过界面直接请求时 getAIModel 用现有文案报 "needs a base URL",列模型报 "needs a base URL to list its models";chatRequestUrl 在地址下方显示 `…/chat/completions`,用户能看到请求去向。credential 填 apiKey,因为 getAIModel 顶部的 guard 要求自定义地址必须带 key;LM Studio 这类不校验 key 的服务填任意占位字符串即可,这也是 SGLang 用户今天的做法。服务器端对应 CUSTOM_API_KEY 和 CUSTOM_BASE_URL,管理面板自动得到 ADMIN_CUSTOM_API_KEY[_n]。需要同时加的内容:env.example 两行、三语文档一段;选择器提示文案在记录里。它和现有两个选项的差别:选 openai 再填自定义地址走的是 @ai-sdk/openai 的 chat 路径,没有 compatibleModel 的 `<think>` 提取中间件(lib/ai-providers.ts:640),开源模型的推理内容会混进正文;选 sglang 走 compatibleModel 但不在 SINGLE_SYSTEM_PROVIDERS 里(lib/ai-providers.ts:43),多条 system 消息不会合并。custom 同时具备这两点。
|
||||
|
||||
协议选择留在预设之外。Anthropic 协议的网关(magpie、New API 的 Anthropic 端点)今天选 Anthropic 再填地址就能用,resolveBaseURL 会把用户 key 发到用户地址;Gemini 协议的网关同理选 Google。为 custom 加协议下拉框要改表单、createModel、chatRequestUrl 三处和客户端存储格式,收益只是少选一次服务商。这里和 magpie 的取舍不同:magpie 的 Provider 为每种协议各存一个 URL(internal/provider/provider.go 的 Speaks()),并按模型名前缀(claude*、gpt-*)决定走哪个协议(Native())。那是网关需要的,本项目有 AI SDK 按 provider 选协议,一条记录一个 sdk 字段就够。
|
||||
|
||||
magpie 值得借的一点:internal/provider/detect.go:43 DetectBase 按协议清理粘贴的 URL(去掉 /chat/completions、/responses、/messages,Anthropic 去 /v1,裸主机加 /v1,Gemini 加 /v1beta)。本项目的 normalizeBaseUrl(lib/types/model-config.ts:611)已经做了去端点路径的部分。"裸主机加 /v1"会改变其他 provider 的行为,列入待决问题 7。
|
||||
|
||||
### 3.6 Bedrock、Vertex、Azure、Ollama、EdgeOne 各自变成什么
|
||||
|
||||
- **Bedrock**:记录 `sdk: "bedrock", credential: "aws", envPrefix: null, fixedCredentials, ownRequestPath, icon: "cloud", note("AWS 凭证"四语), models(今天的 22 个 id)`。getAIModel :864 到 :933 的凭据选择和 createAmazonBedrock 调用成为 bedrockModel(modelId, overrides),内容不动;:935 到 :943 的 anthropic.claude beta 选项合并留在 getAIModel。bedrockModel 由 getAIModel 直接调用,不经 createModel。resolveEndpoint 对它只回答 keySource。管理面板的 ADMIN_AWS_* 分支原样。
|
||||
- **Vertex**:记录 `sdk: "vertex", credential: "vertexKey", envPrefix: "GOOGLE_VERTEX", fixedCredentials, ownRequestPath, logo: "google", note("Express 模式 Key"四语), models(今天的 7 个 id)`。:951 到 :968 的 key 和 URL 规则进入 resolveEndpoint 第 3 条,缺 key 的抛错留在 getAIModel,createVertex 调用成为 createModel 的 case。buildProviderOptions 的 vertexai 分支不动。
|
||||
- **Azure**:记录 `sdk: "azure", credential: "apiKey", envPrefix: "AZURE", resourceNameEnv: "AZURE_RESOURCE_NAME", sdkKnowsEndpoint, ownRequestPath, optionsOpen, note("Key 和资源地址"四语), baseUrlHint(地址形状四语), models(今天的 10 个 id)`,没有 defaultBaseUrl。"服务器可用资源名"进 serverCredentialError 和 resolveEndpoint 的 resourceName;"带客户端 key 必须有 URL"的抛错留在 getAIModel,条件由 resourceNameEnv 表达;globalBaseUrl :1106 到 :1112 按资源名拼 URL 的分支原样。hasCredentials 的 Azure case、字典的 needAzure 和对话框 :929 的特判随占位地址一起消失。
|
||||
- **Ollama**:记录 `sdk: "ollama", credential: "optionalKey", envPrefix: "OLLAMA", fixedCredentials, defaultBaseUrl: "https://ollama.com/api", apiKeyUrl, modelList: "ollama", listNeedsNoKey, optionsOpen, note("Key 可不填"四语)`。:977 到 :1001 的 key 和 URL 规则进入 resolveEndpoint 第 5 条,ollamaApiUrl 和 Bearer 头的拼装进 createModel 的 case。guard 里"没配 OLLAMA_API_KEY 时允许无 key 自定义地址"的例外改为 `credential === "optionalKey"` 加 envVarsOf 给出的变量名。
|
||||
- **EdgeOne**:记录 `sdk: "openai-compatible", credential: "none", envPrefix: null, ownRequestPath, logo: "tencent-cloud", note("不用 Key"四语), models(今天的 1 个 id)`。:1003 到 :1013 的分支消失,由 resolveEndpoint 第 4 条给出 apiKey "edgeone" 和 baseURL,再走 compatibleModel。edgeOneEndpoint(req) 和路由、连接测试里的 cookie 透传不动,触发条件改为 `credential === "none"`。
|
||||
|
||||
### 3.7 models.dev 数据怎么用
|
||||
|
||||
所有者的倾向是让 buildProviderOptions 先查 getModelInfo().reasoning 再退回正则,并从目录生成推荐模型。用当前的 lib/model-catalog.json 核对后,本文的建议有两点不同。
|
||||
|
||||
**reasoning 判定:维持正则,不读目录。** 用目录里每个 provider 的全部模型对比今天的正则(openai 用 :313 的 `/^(o\d|gpt-([5-9]|[1-9]\d))/`,google 和 vertexai 共用 geminiThinkingOptions 的 `/gemini-?[23]/`):
|
||||
|
||||
| provider | 目录模型数 | 与正则不一致 | 不一致的内容 |
|
||||
|---|---|---|---|
|
||||
| openai | 40 | 3 | gpt-daybreak-blue-latest、gpt-daybreak-red-latest、gpt-realtime-2.1:目录说会推理,正则说不会 |
|
||||
| google | 31 | 7 | deep-research 两个、gemini-flash-latest、gemini-flash-lite-latest、gemma-4 两个:目录说会,正则说不会;gemini-3.5-live-translate-preview 反之 |
|
||||
| vertexai | 38 | 21 | 13 个 Claude 模型:目录说会推理,但 vertexai 分支设置的是 Google 的 thinkingConfig,对 Claude 无效;另 8 个:gemini-2.5-flash-image 目录说不推理而正则命中,gemini-flash-latest、gemini-flash-lite-latest、openai/gpt-oss-120b-maas、xai/grok-4.20-reasoning、xai/grok-4.3、xai/grok-4.6、zai-org/glm-5.2-maas 目录说会推理而正则不命中(其中两个 gemini-flash 别名接受 thinkingConfig) |
|
||||
| bedrock | 184 | 77 | Nova 微型模型目录说不推理但正则命中;DeepSeek 等第三方模型目录说推理,但 Bedrock 的 reasoningConfig 只接受 Claude 与 Nova 的格式;该分支只在设置 BEDROCK_REASONING_BUDGET_TOKENS 或 BEDROCK_REASONING_EFFORT 时才加 reasoningConfig |
|
||||
| azure | 75 | 30 | azure 分支今天不看 modelId,只在设置 AZURE_REASONING_EFFORT 或 AZURE_REASONING_SUMMARY 时对所有模型加同样的选项;此处的 30 是拿 openai 的正则比出来的:15 个 Claude 模型,另 15 个是 DeepSeek、Grok、Kimi、Phi-4-reasoning、Cohere、gpt-chat-latest |
|
||||
|
||||
结论是两者回答的问题不同:目录的 reasoning 标记回答"这个模型会不会推理",现有正则回答"该给 SDK 哪种格式的选项"(例如 Bedrock 的判断区分的是模型厂商家族,决定 reasoningConfig 用 budgetTokens 还是 maxReasoningEffort)。这一结论对 Claude 行成立;vertexai 的 gemini-flash-latest、gemini-flash-lite-latest 等非 Claude 行正是目录能补上正则遗漏的情况,属于待决问题 4 第二个选项的实际收益范围。"先查目录再退回正则"会在 Bedrock 和 Vertex 上发错选项格式,在 openai 和 google 上改变 10 个模型的请求参数(gemma 是否接受 thinkingConfig 没有验证过)。此外目录覆盖不全:sglang、edgeone、atlascloud 没有条目;google 下没有 gemini-2.0-flash(models.dev 已标弃用),而正则仍会为它开 includeThoughts;目录是发布前手动刷新的文件,新模型上线到目录收录之间有空窗,按命名规律写的正则对新模型即时有效。因此本方案不改 buildProviderOptions。可选的后续方向有两个,对应待决问题 4 的第二个和第三个选项。第二个选项是"正则说有就有;正则说没有时,目录明确说有也算有",这条规则只会增加开启思维链的模型,对 openai 和 google 的 9 个目录条目是行为变化;因为 getModelInfo 忽略大小写并做前缀匹配(lib/model-catalog.ts:25 到 :45),实际受影响的是这 9 个 id 以及以它们开头、后接连字符、冒号或点号的所有变体。第三个选项是在模型设置里让用户显式勾选"推理模型",根本不用猜。
|
||||
|
||||
**推荐模型:能在线列的不再手写,不能列的放进记录。** 今天 SUGGESTED_MODELS 给 19 家各一份手写列表,共 179 个 id、259 行,另 5 家(ollama、glm、qwen、qiniu、kimi)没有条目。它不能从目录生成:目录里 bedrock 有 184 个、openrouter 390 个、gateway 316 个、novita 107 个模型,而推荐列表每家 1 到 24 个、按新旧排序,是人工挑选的结果;lib/model-catalog.json 没有保留 release_date,没有热门度字段;azure 的模型 id 是用户自己的部署名。所有者的决定是按能否在线列模型分两类处理:
|
||||
|
||||
- 有 modelList 的 18 家(openai、anthropic、google、ollama、openrouter、aihubmix、deepseek、siliconflow、sglang、gateway、modelscope、glm、qwen、qiniu、kimi、novita、mimo、atlascloud)删除手写列表(其中 13 家今天有列表,共 126 个 id)。用户填好凭据后,设置对话框复用 components/model-config-dialog.tsx:309 handleFetchModels 的拉取逻辑自动拉取一次,用户从结果里挑;添加这些 provider 时不再自动加入第一个模型。服务商的真实列表永远比手写的新,手写列表下线模型的问题随之消失。
|
||||
- 没有 modelList 的 6 家(bedrock 22 个、azure 10 个、doubao 9 个、vertexai 7 个、minimax 4 个、edgeone 1 个,共 53 个 id)保留短列表,存在记录的 models 字段上(约 70 行),添加时仍自动加入第一个。管理面板的"推荐"下拉框只对这 6 家显示,其余由管理员手输 id;面板的拉取按钮列为可选后续工作(面板里的 key 打码存储,拉取要在服务器端解出真实 key)。
|
||||
|
||||
这是有意的界面行为变化,单独成第 7 步,带自己的 e2e 更新。
|
||||
|
||||
**models.dev 映射。** scripts/update-model-catalog.mjs 是直接用 node 运行的 .mjs,import TypeScript 记录需要 `node --experimental-strip-types`(本机 Node 24 支持)或 tsx。建议脚本保留自己的映射(:9,21 项),另加一个测试断言它的键集合是 ProviderName 的子集。是否把映射并入记录见待决问题 5。
|
||||
|
||||
## 4. 分步迁移
|
||||
|
||||
每一步一个 commit 或 PR。后一步依赖前一步(第 2 步依赖第 1 步的记录,第 5、6、7 步依赖第 2 步的字段),所以回滚的单位是"已合入的前缀":出问题时从最新一步开始按逆序 `git revert`,回到最近一个通过了基线门禁的提交。每步结束时运行"基线门禁":
|
||||
|
||||
```
|
||||
npx vitest run
|
||||
npx tsc --noEmit
|
||||
npm run check
|
||||
```
|
||||
|
||||
本文修订时全部单元测试为 63 个文件 723 个用例,约 13 秒跑完,与 .github/workflows/test.yml:33、:36、:39 的 CI 命令一致(CI 里的测试命令是 `npm run test -- --run`,等价于 `npx vitest run`)。核对时 tests/unit/canvas-import-boundary.test.ts 有一个用例在本机因 esbuild 打包环境失败,与 provider 层无关。
|
||||
|
||||
### 第 0 步:先把现状录下来(只加测试)
|
||||
|
||||
- 文件:新增 tests/unit/provider-matrix.test.ts 和它生成的 tests/unit/__snapshots__/provider-matrix.json;新增 tests/unit/provider-legacy.test.ts。约 300 行测试代码,快照约 3900 条记录(主体 24 × 15 × 9 = 3240 条,加下文的额外行约 650 条)。
|
||||
- 内容:
|
||||
1. 行为快照。矩阵主体是 24 个 provider × 15 种客户端 override 形状 × 9 种服务器环境。15 种形状是:无;仅 key;仅 URL;key 加 URL;URL 为 "/";URL 带叠加端点后缀 "https://u.example/v1/chat/completions/messages"(normalizeBaseUrl 归一化一次和两次结果不同,用来证明只归一化了一次);apiKeyEnv 指向有值的单个变量;apiKeyEnv 指向空变量;apiKeyEnv 为数组且一项有值一项缺失;apiKeyEnv 为数组且全部缺失;apiKeyEnv 加 baseUrlEnv;AWS 成对 key;AWS 只给 awsAccessKeyId;vertexApiKey 加 URL;trustedBaseUrl 加 URL。9 种环境是:空;`<P>_API_KEY`;key 加 `<P>_BASE_URL`;OLLAMA_API_KEY;AZURE_RESOURCE_NAME;ANTHROPIC_AUTH_TOKEN;ADMIN_AWS_*;NEXT_AI_DRAWIO_DESKTOP=1;ALLOW_PRIVATE_URLS=false(这一种走真实的 redirectGuardedFetch 路径作为对照)。Azure 数组形式且无 AZURE_RESOURCE_NAME 的组合由"空"和"`<P>_API_KEY`"两种环境自然覆盖。用 vitest 把全部 SDK 工厂 mock 掉(tests/unit/ai-providers-credentials.test.ts:17 到 :57 已这样做了 google-vertex、openai、azure、amazon-bedrock、ollama、openrouter 六个,再补 anthropic、google、deepseek、openai-compatible、aihubmix、gateway 和 ai 包的 wrapLanguageModel)。对每个组合记录:哪个工厂被调用、传入的选项、openai 走的是 chat 还是 responses、providerOptions、抛出的错误文本,以及 usesServerCredentials、usesServerEndpoint(isPrivateUrl 按主机名 mock)、globalBaseUrl、chatRequestUrl 的返回值。函数值记为标签:对 @/lib/ssrf-protection 做部分 mock,redirectGuardedFetch 固定返回一个带标记的哨兵函数,工厂收到该哨兵即把 fetch 记为 "guarded",否则记 "none"(若不这样 mock,前 8 种环境里 redirectGuardedFetch 恒返回 undefined,守卫参数的遗漏不会改变快照);Bedrock 的 credentialProvider 记为 "client-key-throw";fromNodeProviderChain 已被 mock 成字符串 "node-chain",原样记录。矩阵主体每个 provider 用的模型 id 写在测试文件自己的常量 MATRIX_MODEL_IDS 里(从今天 SUGGESTED_MODELS 的第一个 id 抄来,没有推荐列表的 provider 用 "test-model"),所以第 7 步删除 SUGGESTED_MODELS 时快照不受影响。Math.random 固定为 0;另加两个定向用例:数组 apiKeyEnv 全部有值时,Math.random 返回 0 和 0.999 分别选到首项和末项;数组全部缺失时回退到默认变量。整体用 toMatchFileSnapshot 存成一个 JSON。
|
||||
2. 额外行,约 650 条。(a)无 provider 的行:overrides 不带 provider、AI_PROVIDER 未设,环境分别为 21 家各只设自己的 key 变量(含只设 GOOGLE_VERTEX_API_KEY)、Vertex 与 OpenAI 同时设、OLLAMA_API_KEY 单独设、一个 key 都没有,共 24 行,记录 getServerProvider() 的返回值和 getAIModel 抛出的完整错误文本(包含 'No AI provider configured' 的变量列表和 'Multiple AI providers configured' 的括号内容);另加 AI_PROVIDER=<p> 且 overrides 无 provider、环境为空的 24 行,记录成功或错误文本(守住 ollama、bedrock、edgeone 无 key 不报错)。(b)模型 id 探针:openai 用 gpt-5 和 gpt-4o 各把它那 135 个组合再跑一遍,记录 chat 与 responses 的选择(270 行);doubao 用 deepseek-v3 和 doubao-seed 各再跑一遍(270 行);minimax 把带客户端 URL 的 4 种形状用含 /anthropic 的地址再跑一遍(36 行)。(c)providerOptions 探针:对 openai 和 google 各取"无 override、`<P>_API_KEY` 已设"这一个场景,分别对下列模型 id 各记一行 providerOptions:3.7 节表中的 9 个目录 id(openai 3 个、google 6 个),每家一个正则命中的 id(gpt-5、gemini-2.5-flash),一个目录没有的 id(gpt-unknown、gemini-unknown),以及一个大小写或后缀别名(GPT-Realtime-2.1、gemini-flash-latest-001)。共 24 行,列表写进测试文件的常量 REASONING_PROBE_IDS。
|
||||
3. 差分测试骨架:把 usesServerCredentials 和 usesServerEndpoint 的当前函数体原文复制到测试文件作为 legacy 版本,按第 5.1 节的等价类全部枚举逐一比对。此时新旧是同一份代码,测试必然通过;它的价值在第 1 到 4c 步。
|
||||
4. 表的一致性测试:13 张表里 `Record<ProviderName, …>` 类型的键必须齐全,列表类型的元素必须都在 ProviderName 内(tests/unit/provider-setup.test.ts:26 已检查 PROVIDER_GROUPS,复用)。
|
||||
- 测试门:基线门禁通过;新测试通过;快照文件进入版本库。
|
||||
- 行为检查:无生产代码改动,`git diff --stat -- lib app components` 为空。
|
||||
|
||||
### 第 1 步:样例,只迁移 Atlas Cloud、Novita、Qiniu
|
||||
|
||||
- 文件:新增 lib/providers.ts(Sdk、Credential、Copy、ProviderDef 类型,PROVIDERS 记录只含三条,约 120 行);新增 tests/unit/providers.test.ts 约 40 行。lib/types/model-config.ts、lib/ai-providers.ts、lib/provider-setup.ts 里的旧表一字不动。差异约 +160 / 0。
|
||||
- 做法:这一步 ProviderName 联合类型仍然手写,没有任何生产代码读取 PROVIDERS。测试 import 旧表,对三家逐项比对:PROVIDER_INFO 条目只比较 label、defaultBaseUrl、apiKeyUrl、modelList 四个字段(toMatchObject);logo 比较 `PROVIDERS[p].logo ?? p` 与旧 `PROVIDER_LOGO_MAP[p] || p`;PROVIDER_ENV_VARS、SINGLE_SYSTEM_PROVIDERS、PROVIDER_GROUPS 的条目与旧表深相等。难例的记录写法见 3.1(bedrock 完整字面值)、3.5(custom)和 3.6(Vertex、Azure、Ollama、EdgeOne),所有者在此处决策时一并对照。
|
||||
|
||||
- 给所有者看的对比,以 Qiniu 为例。改动前它出现在 5 处(Qiniu 没有 SUGGESTED_MODELS 条目,该表是 Partial 的,glm、qwen、kimi、ollama 也没有):
|
||||
|
||||
```
|
||||
lib/types/model-config.ts:22 | "qiniu"
|
||||
lib/types/model-config.ts:231 qiniu: { label: "Qiniu", defaultBaseUrl: "https://api.qnaigc.com/v1", apiKeyUrl: "https://www.qiniu.com/ai/models", modelList: "openai" }
|
||||
lib/ai-providers.ts:48 "qiniu", (SINGLE_SYSTEM_PROVIDERS)
|
||||
lib/ai-providers.ts:471 qiniu: "QINIU_API_KEY",
|
||||
lib/provider-setup.ts:41 "qiniu", (PROVIDER_GROUPS aggregators)
|
||||
```
|
||||
|
||||
改动后它出现在 2 处:ProviderName 联合类型里的成员 "qiniu"(本步仍手写在 lib/types/model-config.ts:22,第 2 步起由记录键推出),以及新记录:
|
||||
|
||||
```ts
|
||||
// lib/providers.ts
|
||||
qiniu: {
|
||||
label: "Qiniu",
|
||||
sdk: "openai-compatible",
|
||||
credential: "apiKey",
|
||||
group: "aggregators",
|
||||
envPrefix: "QINIU",
|
||||
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
||||
apiKeyUrl: "https://www.qiniu.com/ai/models",
|
||||
modelList: "openai",
|
||||
singleSystem: true,
|
||||
},
|
||||
```
|
||||
|
||||
Novita 同理(logo 与键相同,按 3.1 的规则不写 logo 字段),Atlas Cloud 同理(`logo: "openai"`,无 singleSystem)。随 PR 附一张表,列出三家在每张旧表里的旧值和新记录派生出的值,逐项相同。本步的目的只是证明三家的记录能派生出与旧表相同的值;此时 ProviderName 仍是手写联合类型,旧表也还在,新增 provider 仍要改旧表和联合类型。"新增一家只改一条记录加 env.example(第 3 行的 Options 列表和该家的两行配置)"要到第 2 步把 ProviderName 改为 `keyof typeof PROVIDERS`、十张表派生之后才成立,见第 6 步的新增 provider 文档。
|
||||
|
||||
- 测试门:第 0 步快照逐字节不变(本步没有任何生产代码读取 PROVIDERS,所以快照按构造不变,`git diff --stat -- app components` 为空);基线门禁全绿;tests/unit/ai-providers.test.ts 的 "Atlas Cloud provider" 3 个用例不改;新增 tests/unit/providers.test.ts 的比对通过。
|
||||
- 行为检查:`npx playwright test tests/e2e/settings-providers.spec.ts tests/e2e/settings-bedrock.spec.ts tests/e2e/provider-models.spec.ts tests/e2e/model-test.spec.ts` 通过;在设置里添加 Novita,拉模型列表,测一个模型。
|
||||
|
||||
所有者在此处决定是否继续。
|
||||
|
||||
### 第 2 步:其余 21 家进记录,十张表变成派生常量
|
||||
|
||||
- 文件:lib/providers.ts 补齐到 24 条并按选择器顺序书写,6 家带 models 列表,8 家带 note 或 baseUrlHint 文案对象(约 +350 行);lib/types/model-config.ts 删除 ProviderName 联合、PROVIDER_INFO、FIXED_CRED_PROVIDERS、PROVIDER_LOGO_MAP、ModelListStyle 的字面值,改为转发导出和派生常量,SUGGESTED_MODELS 原样留在原处(约 −200 / +30);lib/ai-providers.ts 的 SINGLE_SYSTEM_PROVIDERS、PROVIDER_ENV_VARS、SDK_KNOWS_ENDPOINT 改派生(约 −50 / +15);lib/provider-setup.ts 的 PROVIDER_GROUPS、OPTIONS_OPEN 改派生,POPULAR_PROVIDERS 和 PROVIDER_NOTE_KEYS 不动(约 −45 / +15);app/api/provider-models/route.ts 的 NO_KEY_NEEDED 改派生(−5 / +2)。ProviderName 改为 `keyof typeof PROVIDERS`。差异约 +410 / −300(第 1 步没有删任何旧表项,三家样例的旧表项也在本步删除)。
|
||||
- 新增测试:tests/unit/providers.test.ts 扩展,把第 2 步之前 13 张表的字面值冻结成一份 fixture(从第 1 步的代码直接复制)。FIXED_CRED_PROVIDERS、SINGLE_SYSTEM_PROVIDERS、PROVIDER_ENV_VARS、SDK_KNOWS_ENDPOINT、OPTIONS_OPEN、NO_KEY_NEEDED、ownPaths 断言派生常量与 fixture 深相等。PROVIDER_INFO 是 PROVIDERS 本身,多出 sdk、credential 等字段,只比较 label、defaultBaseUrl、apiKeyUrl、modelList 四个字段的投影(Azure 的 defaultBaseUrl 例外:fixture 里有占位值,记录里没有,这一处差异在测试里显式列为预期)。PROVIDER_LOGO_MAP 的派生值只有 8 条(logo 与键相异的那些),对 24 家逐个断言 `PROVIDERS[p].logo ?? p` 等于 fixture 的 `PROVIDER_LOGO_MAP[p] || p`。文案对象逐字核对:对 PROVIDER_NOTE_KEYS 里的 6 家,`PROVIDERS[p].note[lang]` 等于该语言字典 modelConfig 下对应 key 的值,四种语言都比;minimax、mimo 的 baseUrlHint 同样比 minimaxBaseUrlHint、mimoBaseUrlHint。models 字段逐字等于 SUGGESTED_MODELS 里那 6 家的数组。键顺序只在可观察的地方断言:PROVIDER_GROUPS 每组的 providers 数组与 fixture 逐项相等(toEqual 对数组比较顺序),POPULAR_PROVIDERS 不变;其余表的键序随 PROVIDERS 的书写顺序变化,见待决问题 2。另加两个长期保留的断言:credential 为 apiKey 或 vertexKey 的 provider 集合等于改动前 PROVIDER_ENV_VARS 非 null 的键集合(21 家);Copy 类型的键集合等于 lib/i18n/config.ts 的 i18n.locales。这份 fixture 在第 6 步删除旧表时一并删除,两个长期断言保留。
|
||||
- 测试门:快照不变;fixture 相等测试通过;tests/unit/provider-setup.test.ts 的 "hold every provider exactly once" 自然通过;`npx tsc --noEmit` 无错,特别关注 `keyof typeof PROVIDERS` 推导后所有 `Record<ProviderName, …>` 与 `Partial<Record<…>>` 的使用点;tsc 门禁覆盖 lib/types/model-config.ts 内部对 ProviderName、PROVIDER_INFO 的引用,以及 12 个读取点对 defaultBaseUrl、modelList 等可选字段的访问。
|
||||
- 行为检查:设置页打开 provider 选择器,分组和顺序与改动前的截图一致。管理面板"添加 provider"下拉框,以及 getAIModel 的四段错误文案(lib/ai-providers.ts:803 'Allowed providers'、:839 'Supported providers'、:817 到 :829 'No AI provider configured' 的变量清单、:832 'Multiple AI providers configured' 的列表)中 provider 的排列顺序会变为选择器顺序,这是本步明确接受的可见变化,见待决问题 2;为这四段文本新增 tests/unit/ai-providers-messages.test.ts(或在 provider-matrix 中增加一个非法 provider 名的场景),期望文本在本步 PR 内一并更新。
|
||||
|
||||
### 第 3 步:环境变量名与服务器凭据判断合一
|
||||
|
||||
- 文件:lib/ai-providers.ts(新增 envVarsOf 和 serverCredentialError;detectProvider、validateProviderCredentials、globalBaseUrl、default 分支 :1022 到 :1025 改为调用它们);lib/admin/providers.ts(credEnvNames 读 envPrefix 和 fixedCredentials,并加 export,它今天在 :84 未导出,测试要用)。差异约 +40 / −60。
|
||||
- 新增测试:credEnvNames 对 24 家的输出表(tests/unit/admin-providers.test.ts 已覆盖 openai、openrouter、bedrock、vertexai、ollama、gateway,补齐其余,期望值在改动前先生成)。serverCredentialError 八个用例,在改动前先对今天的 validateProviderCredentials 写好并通过:Anthropic 只有 ANTHROPIC_AUTH_TOKEN 返回 null;Anthropic 给了自定义 apiKeyEnv 且该变量为空时返回单变量文案,即使 ANTHROPIC_API_KEY 已设置;任一 provider 的单个自定义变量为空时不回退默认变量;Azure 只有 key 没有地址返回 Azure 地址文案;Azure 由服务器模型自己的 baseUrlEnv 提供地址返回 null;Azure 数组形式 apiKeyEnv 有一个有值且没有任何地址变量时返回 null(今天 :539 的提前返回);数组全部为空返回数组文案;Azure 的 key 和地址都缺时返回单变量文案(先检查 key,再检查地址的顺序)。若所有者希望收紧 Azure 数组配置也要求地址,作为独立 commit 的有意行为变更处理,并在快照里标注。自动检测补三个用例:只设 GOOGLE_VERTEX_API_KEY 时 getServerProvider() 返回 "vertexai";Vertex 与 OpenAI 同时配置时 getAIModel 报 "Multiple AI providers configured (openai, vertexai)";AI_PROVIDER=ollama 且无任何 key 时 validateProviderCredentials 不抛错。
|
||||
- 测试门:快照不变(快照包含错误文本,四段凭据错误文案(数组、Either、单变量、Azure 地址)逐字不变);基线门禁;credEnvNames 表。
|
||||
- 行为检查:管理面板添加一个 Qiniu 条目和一个 Gateway 条目并保存,检查 settings.json 旁写出的变量名仍是 ADMIN_QINIU_API_KEY 和 ADMIN_AI_GATEWAY_API_KEY。
|
||||
|
||||
### 第 4 步:resolveEndpoint 上线,getAIModel 的第二个 switch 消失
|
||||
|
||||
拆成三个小 PR,每个都有同样的门禁。
|
||||
|
||||
- 4a. 在 lib/ai-providers.ts 新增 resolveEndpoint(约 90 行),暂无调用方;resolveApiKey 按 3.3 改为返回 `{ value, source, selectedKeyEnv }`,它今天的两个调用方(:983、:1018)在同一 commit 里改读 .value。新增 tests/unit/resolve-endpoint.test.ts,差分测试只断言三件事:(1)对第 5.1 节按等价类枚举的全部 30720 个组合,`!isDesktop && resolveEndpoint(...).keySource === "server"` 与 legacy usesServerCredentials 相等(桌面判断留在包装函数里,4c 之前包装函数尚未改写,所以在这里显式补上),resolveApiKey 返回的 source 同时与 legacy 一致;(2)对快照矩阵里的每个场景,`guarded` 与快照记录的工厂是否收到哨兵 fetch 相等(AIHubMix 官方地址一例按现状例外);(3)openai 的 `configuredBaseURL` 与快照记录的 chat/responses 选择相等。apiKey、baseURL、authToken、resourceName 的正确性由 4b 的"快照不变"门禁证明:快照比较的是 createModel 之后送到 SDK 工厂的最终参数,而 Ollama 的 ollamaApiUrl 与 Authorization 头、MiniMax 的 normalizeMiniMaxBaseURL、AIHubMix 官方地址时省略 baseURL、Azure 有 URL 时省略 resourceName 这些变换都发生在 createModel 内,resolveEndpoint 的返回值不应也无法与工厂参数逐字段相等。矩阵的客户端 URL 列包含一个叠加端点后缀的值,4b 据此断言 SDK 工厂收到的 baseURL 与今天 getAIModel 的结果逐字相同。Bedrock 场景只参与第(1)项。门禁:差分测试通过。若发现任何不一致,以旧行为为准修改 resolveEndpoint,或列入待决问题。差异约 +90 / +120(测试)。
|
||||
- 4b. getAIModel 的 bedrock、vertexai、ollama、edgeone、default 五个分支换成一个 Bedrock 判断加一次 resolveEndpoint 调用加 createModel;createModel 的 switch 改为按 sdk,vertex、ollama 两个 case 加入,bedrockModel() 在 getAIModel 里按 sdk === "bedrock" 调用;三条抛错按 3.3 节保留;顶部 guard 的三个例外改读 credential(2.2 第二行)。差异约 +130 / −250(Bedrock 的 70 行是搬动)。门禁:快照不变(含"仅 URL 无 key"的全部 216 行);tests/unit/ai-providers-credentials.test.ts 的 26 个用例和 tests/unit/ai-providers.test.ts 的 "Ollama API key security" 7 个用例不改。
|
||||
- 4c. usesServerCredentials 改为读 keySource;usesServerEndpoint 改读 credential 和 sdk;listFallbackUrl 改用 publicDefaultUrl 和 credential;resolveApiKey、resolveBaseUrlEnv(今天在 lib/ai-providers.ts :156、:192,本来就没有导出,文件外无调用)此后只被 resolveEndpoint 调用,getAIModel 和 usesServerCredentials 里对它们的直接调用删除。差异约 +20 / −50。门禁:快照不变;差分测试通过后,本步最后一个 commit 删除 tests/unit/provider-legacy.test.ts 里的 legacy 副本和差分用例(usesServerEndpoint 的 288 组合改为对照快照里记录的返回值,保留在 provider-matrix 里);tests/unit/provider-models.test.ts 19 个用例不改。
|
||||
- 行为检查:对照 2.2 节的表逐条勾选;用一个真实 key 跑 chat 路由,确认服务器日志里 `[AI Provider] Initializing` 之后的请求 URL 与改前一致。
|
||||
|
||||
### 第 5 步:界面、连接测试与路由读能力字段
|
||||
|
||||
- 文件:lib/provider-setup.ts(hasCredentials)、lib/test-model-connection.ts(凭据检查、EdgeOne 判断和 Object.hasOwn 校验)、app/api/chat/route.ts(onSiteFunction 和 `sdk === "openai"`)、components/provider-credentials-fields.tsx、components/settings/provider-picker.tsx、components/provider-logo.tsx、components/model-config-dialog.tsx(:925、:300 到 :306、:535、:987)、app/[lang]/admin/models-section.tsx(:535 envBlocked)、lib/admin/providers.ts(:173 validateAdminProviders)、lib/types/model-config.ts(chatRequestUrl)、lib/provider-models.ts(:166)、lib/server-model-config.ts(:9、:84)、lib/i18n/dictionaries/{en,ja,zh,zh-Hant}.json(删除 8 个服务商专属 key 和 needAzure,加 needUrl)。按 3.4 节的表执行,一个文件一个 commit;picker 改读记录文案、删除 PROVIDER_NOTE_KEYS 和 8 个字典 key 在同一个 commit。差异约 +90 / −130。
|
||||
- 新增测试:hasCredentials 对 24 个 provider 的结果表(tests/unit/provider-setup.test.ts:57 已有 10 组输入,扩成全表,期望值改动前生成;Azure 两行按新规则写:只有 key 为 false,key 加任意地址为 true;第 8 步加入 custom 两行)。chatRequestUrl 已在第 0 步快照里,不另写表。tests/unit/validate-model-route.test.ts 加三个不可信输入用例:provider 为 "nope" 带 key、"nope" 不带 key、"toString" 带 key,都期望 400 且文案以 "Invalid provider" 开头;这三个用例在改连接测试的 commit 里一起加(它们记录的是该 commit 引入的新答案)。tests/unit/provider-setup.test.ts 的 "have their notes in every language" 删除,由 Copy 类型和第 2 步的键集合断言代替。
|
||||
- 有意变化:Azure 占位地址消失(2.2 表倒数第二行)。tests/e2e/settings-providers.spec.ts:552 "Azure needs its resource URL before it counts as connected" 今天把占位地址存进种子配置并断言 :579 的 needAzure 文案;改为种子不带地址、断言新的 needUrl 文案,与 hasCredentials 的 commit 同时提交,是本方案第一处有意修改的现有 e2e 断言。已存占位地址的旧条目从此被当作填好了地址,在该 commit 的说明里写明。
|
||||
- 测试门:快照不变(chatRequestUrl 在快照里);基线门禁;四个 e2e 文件(见 5.3)的 56 个用例通过(settings-providers.spec.ts 点击 `pick-provider-<name>` 按钮并检查凭据表单,provider-models.spec.ts 走完拉模型、加入模型、测试流程,含 :449 "Bedrock can be tested and used with an API key alone";tests/unit/chat-route-edgeone.test.ts 4 个和 tests/unit/validate-model-route.test.ts 里 "testing EdgeOne" 的 2 个用例证明 EdgeOne 的站点端点和 cookie 透传不变)。
|
||||
- 行为检查:设置页逐个打开 bedrock、vertexai、azure、ollama、edgeone、minimax、mimo 的凭据表单,与改动前截图对比字段、提示和图标;切换四种界面语言,选择器里 6 家的提示和 minimax、mimo 的地址提示与改前逐字相同。
|
||||
|
||||
### 第 6 步:删除过渡代码,写"如何新增 provider"
|
||||
|
||||
- 文件:lib/types/model-config.ts 删除已无读者的派生常量 FIXED_CRED_PROVIDERS 和 PROVIDER_LOGO_MAP(PROVIDER_INFO 的转发导出保留,见待决问题 1;SUGGESTED_MODELS 留到第 7 步);lib/provider-setup.ts 删除 OPTIONS_OPEN 派生常量(PROVIDER_GROUPS 保留,filterProviderGroups 用它);app/api/provider-models/route.ts 删除 NO_KEY_NEEDED;删除第 2 步的 fixture 测试,保留"参与自动检测的集合等于 21 家"和 Copy 键集合两个断言作为长期守卫;docs/{cn,en,ja}/ai-providers.md 末尾加一节"如何新增 provider"(三语各约 15 行:lib/providers.ts 加一条记录,需要提示文案时在记录里写四种语言,env.example 加两行,本文档加一段);新增一个测试断言每条记录的 envPrefix 或 keyEnv 推出的变量名都出现在 env.example 里(bedrock 和 edgeone 除外),env.example 本身继续手写。约 −110 行。
|
||||
- 测试门:快照不变;全部单元测试与 e2e 通过;`npx tsc --noEmit`、`npm run check` 无错。
|
||||
|
||||
### 第 7 步(有意的界面行为变化):推荐模型列表改为在线拉取
|
||||
|
||||
- 文件:lib/providers.ts(ProviderDef 的 models 字段在第 2 步已经存在,本步没有改动);lib/types/model-config.ts 删除 SUGGESTED_MODELS(−260 行);components/model-config-dialog.tsx(:376、:403、:414 三处改读 `PROVIDERS[p].models ?? []`;新增一个 effect:当前 provider 有 modelList、hasCredentials 为真、且没有用当前凭据拉过列表时,调用一次 handleFetchModels;handleAddProvider 只在记录有 models 时自动加入第一个);app/[lang]/admin/models-section.tsx:76 改读 `PROVIDERS[p].models`,没有时不渲染"推荐"下拉框。差异约 +40 / −280。
|
||||
- 用户看到的变化:添加 OpenAI 这类服务商后,第二步"模型"为空,填好 key 的瞬间列表自动拉取并打开选择器;拉取失败时显示现有的错误提示,手输模型 id 的输入框(:478 handleAddModel)照旧可用。添加 Bedrock 这类服务商与今天相同,第一个推荐模型自动加入。管理面板里 OpenAI 这类服务商的"推荐"下拉框消失,管理员手输 id。
|
||||
- 新增测试:tests/unit/model-config-dialog.test.tsx 三个用例:添加有 modelList 的 provider 不自动加模型、填好凭据后恰好发出一次 /api/provider-models 请求(凭据不变时切回该 provider 不重复拉)、添加 bedrock 自动加入记录 models 的第一个。e2e 三处改动与本步同一个 commit:tests/e2e/settings-providers.spec.ts:60 "The first suggested model is added"(DeepSeek 添加后改为 mock /api/provider-models、填 key 后从选择器挑 deepseek-v4-pro),:656 "OpenAI has suggested models: they come back once the key changes"(改为换 key 后自动拉取一次并显示新列表),tests/e2e/settings-tabs-sync.spec.ts:78 "GLM has no suggested models"(改为另一标签页换 key 后自动重新拉取)。这是本方案第二处有意修改的现有 e2e 断言。
|
||||
- 测试门:快照不变(矩阵用的是测试自己的 MATRIX_MODEL_IDS;自动拉取发生在浏览器里,对服务器端没有影响);基线门禁;56 个 e2e 用例加 tests/e2e/settings-tabs-sync.spec.ts 的 8 个用例通过(含上面三处更新后的版本)。
|
||||
- 行为检查:设置页添加 OpenAI,填入 key,确认列表自动拉取并能挑选;添加 Bedrock,确认第一个推荐模型已加入且下拉里是记录里的 22 个;管理面板添加 OpenAI 条目,确认没有"推荐"下拉框但能手输 id;添加 Bedrock 条目,确认"推荐"下拉框仍在。
|
||||
|
||||
### 第 8 步(可选,待所有者按待决问题 6 决定):custom 预设
|
||||
|
||||
- 文件:lib/providers.ts(+14 行,含四语 note);env.example(+3 行);docs/{cn,en,ja}/ai-providers.md 各一段。差异约 +40 行,全是数据和文档。
|
||||
- 测试门:第 6 步的 env.example 覆盖测试和 Copy 类型自动覆盖它;快照新增 custom 的 135 行,其余行不变;一个安全用例确认 custom 带 URL 不带 key 被 guard 拒绝;hasCredentials 表加两行(只有 key 为 false,key 加 URL 为 true);e2e 新增一个用例:选 custom、填本地地址和占位 key、看到请求地址提示。tests/e2e/settings-providers.spec.ts:116 到 :117 的 `toHaveCount(24)` 改为 25,与 custom 记录在同一个 commit 里,是本方案第三处有意修改的现有 e2e 断言。
|
||||
- 行为检查:用本机的 LM Studio 或一个 vLLM 容器填 URL 和 key,拉模型、测试、对话。
|
||||
|
||||
### 第 9 步(可选,行为变更,需所有者单独批准):目录辅助 reasoning 判定
|
||||
|
||||
- 只在所有者选择待决问题 4 的第二个选项(目录只能增加开启思维链的模型)时做。文件:lib/ai-providers.ts buildProviderOptions 的 openai 正则(:313)和 geminiThinkingOptions 的 google 调用抽成 isReasoningModel(provider, modelId),规则为"正则说有就有;正则说没有时,目录明确说有也算有"(约 +15 / −6);本步的范围是 openai 和 google 两个分支,vertexai 的 gemini-flash-latest、gemini-flash-lite-latest 若要纳入,另提;tests/unit/model-catalog.test.ts 新增一个用例打印目录与正则的全部不一致模型 id,让每次目录刷新时的差异可见。
|
||||
- 测试门:矩阵主体逐字节不变;REASONING_PROBE_IDS 那 24 行里,providerOptions 发生变化的必须恰好是 9 个目录 id 加 2 个别名行(别名经 getModelInfo 的大小写和前缀匹配落到目录条目上),正则命中行和目录没有的行不变。另加 tests/unit/model-catalog.test.ts 的参数化用例:isReasoningModel 对 9 个目录 id 返回 true,对两个正则命中 id 返回 true,对两个未知 id 返回 false,对两个别名返回 true。
|
||||
- 行为检查:对受影响的模型用真实 key 各试一个(至少 gemini-flash-latest 和一个 gemma),确认 thinkingConfig 和 reasoningSummary 不被拒绝。
|
||||
|
||||
## 5. 测试与验证
|
||||
|
||||
### 5.1 两个配额函数的同一性证明
|
||||
|
||||
- usesServerCredentials 的输入按等价类枚举:provider 24 个;overrides 里 apiKey 有或无、baseUrl 取无、"/"、"https://u.example"、"https://u.example/v1/chat/completions/messages"(归一化一次和两次结果不同,用来证明只归一化了一次)四种、awsAccessKeyId 与 awsSecretAccessKey 各自有或无(四种组合)、vertexApiKey 有或无、apiKeyEnv 取无、有值的单个变量、无值的单个变量、数组一项有值一项缺失、数组全部缺失五种(Math.random 固定为 0);环境里 OLLAMA_API_KEY 有或无、NEXT_AI_DRAWIO_DESKTOP 为 "1" 或无。共 24 × 2 × 4 × 4 × 2 × 5 × 2 × 2 = 30720 个组合,按上述等价类全部枚举,新旧实现逐一比对,运行时间毫秒级。第 3.3 节第 5 条提到的 Ollama 边角在这里会被原样守住。这个域只含 24 个合法名字,所以它证明不了未知名字的行为;未知名字由连接测试的 Object.hasOwn 检查和第 5 步的三个用例覆盖,差分测试不扩域。
|
||||
- usesServerEndpoint 的输入域:provider 24 个、clientBaseUrl 取空、公网地址、私网地址三种(isPrivateUrl 按主机名 mock)、apiKey 有或无、桌面标记有或无,共 288 个组合。第 4c 步把它的三个名字判断改为读字段,这份测试证明每个输入的答案相同;第 4c 步起这 288 个组合改为对照第 0 步快照里记录的返回值,保留在 provider-matrix 里。
|
||||
- resolveBaseURL 本身不改,现有 9 个用例保留。
|
||||
- 以上测试在第 0 步写好,第 4c 步之前每一步运行。4c 之后 legacy 副本删除:若保留,以后任何有意的配额规则调整都要同时改生产函数和测试里的副本,这正是本方案要消除的"手工镜像"(第 7 节);两个函数对每个场景的答案已记录在第 0 步快照里,有意改规则时更新快照即可。
|
||||
|
||||
### 5.2 单元测试
|
||||
|
||||
每一步运行基线门禁。现有 723 个用例的断言不修改;若某个用例因为 import 了被删除的常量而编译失败,改成 import 派生常量,断言不变。其中 tests/unit/validate-model-route.test.ts(12 个,同时导入 validate-model 和 admin/test-model 两个路由)、admin-test-model.test.ts(5 个)、chat-route-quota.test.ts(13 个)、chat-route-edgeone.test.ts(4 个)、validate-model-bedrock.test.ts(3 个)、bedrock-api-key.test.ts(2 个)直接导入真实的 chat 路由、两个连接测试路由或 getAIModel,是第 4c 和第 5 步路由改动的直接证据。新增文件:provider-matrix(快照)、provider-legacy(差分,第 4c 步删除)、providers(记录派生值与 fixture,第 6 步删除 fixture 部分)、resolve-endpoint(只含 keySource、source、guarded 和 openai 的 configuredBaseURL 的差分,字段正确性见 4b 快照)、model-config-dialog(第 7 步的自动拉取),加 admin-providers 和 provider-setup 里的两张 24 行表、env.example 覆盖测试。预计重构完成后净增约 45 个用例。
|
||||
|
||||
### 5.3 e2e
|
||||
|
||||
`npx playwright test tests/e2e/settings-providers.spec.ts tests/e2e/settings-bedrock.spec.ts tests/e2e/provider-models.spec.ts tests/e2e/model-test.spec.ts`,56 个用例(26、8、20、2),在第 1、2、5、6、7、8 步之后各跑一次。settings-providers.spec.ts 点 `pick-provider-*` 按钮、检查选择器搜索和已添加标记、"更多选项"自动展开、Azure 地址、拉取模型列表的错误处理;settings-bedrock.spec.ts 检查 Bedrock 两种登录方式和多条 Bedrock;provider-models.spec.ts 覆盖预置 provider 的拉模型、测试按钮、切换时迟到响应不串台、多语言文案(它用 page.route 替换 validate-model 响应,不验证路由层);model-test.spec.ts 检查 Test 按钮一次测完所有模型并逐个显示结果,以及 key 链接和地址清理。CI 已经分 6 片跑 e2e(.github/workflows/test.yml:142 到 :147),不需要改。
|
||||
|
||||
### 5.4 管理面板(手动,第 3、4c、6、7 步之后)
|
||||
|
||||
添加一个 OpenAI 条目和一个 Qiniu 条目,点 Test,保存;检查 settings.json 旁写出的变量名仍是 ADMIN_OPENAI_API_KEY、ADMIN_QINIU_API_KEY、ADMIN_QINIU_BASE_URL;添加第二个 Qiniu 条目,检查后缀 _2;添加 Bedrock 条目,检查 ADMIN_AWS_*;添加 Ollama 和 Vertex 各一条,检查 OLLAMA_API_KEY、GOOGLE_VERTEX_API_KEY;设为默认,检查 AI_PROVIDER / AI_MODEL 被写入;一个无 URL 的条目点 Test,确认 globalBaseUrl 返回的服务器 URL 被当作可信 URL(app/api/admin/test-model/route.ts:56 到 :69);删除条目后检查环境变量被清理;在聊天页用服务器模型发一条消息。
|
||||
|
||||
### 5.5 Electron 桌面版(第 2 和第 6 步之后)
|
||||
|
||||
`npm run electron:build` 通过(主进程用 esbuild 单独打包,与 lib/ 没有导入关系,这一步主要确认 `next build` 在类型改动后仍通过,且 lib/providers.ts 没有引入 Node 专有模块导致浏览器端构建失败)。设置 NEXT_AI_DRAWIO_DESKTOP=1 启动,确认 usesServerCredentials 与 usesServerEndpoint 对任意输入都返回 false(快照里的桌面环境列已覆盖),并用 Ollama 和一个 OpenAI 条目各发一条消息。
|
||||
|
||||
### 5.5a Docker 与 Cloudflare(第 6 步之后)
|
||||
|
||||
Docker:.github/workflows/docker-build.yml 在每个 PR 上已经构建 standalone 镜像(Dockerfile:45 的 `npm run build`,next.config.ts:6 的 `output: "standalone"`),不新增步骤;第 6 步 PR 的该 workflow 必须通过。Cloudflare:项目没有 open-next 的 CI 检查,第 6 步合并前在本机跑一次 `npx opennextjs-cloudflare build`,确认打包不报错;重构没有新增运行时 import(lib/providers.ts 无运行时 import,lib/ai-providers.ts 的依赖不变),所以这一步只是确认。没有 Cloudflare 账号时不做部署,在待决问题里标注"Cloudflare 部署未实测"。
|
||||
|
||||
### 5.6 真实 key 的手动检查(每种线路协议至少一家)
|
||||
|
||||
| 线路协议或 SDK | 用哪家 | 检查什么 |
|
||||
|---|---|---|
|
||||
| OpenAI Responses API | openai 官方 key,无 base URL | 返回 reasoning 片段(gpt-5 系列);快照里 openai 无配置 URL 时走 responses |
|
||||
| OpenAI Chat Completions(兼容) | qiniu 或 novita,或 custom 指向本机 vLLM | 正常流式回复,`<think>` 内容出现在 reasoning 部分,正文里没有它 |
|
||||
| DeepSeek 风格 reasoning_content | kimi 或 deepseek | 多轮工具调用不报 400 |
|
||||
| Anthropic Messages | anthropic 官方 key(同时验证 ANTHROPIC_AUTH_TOKEN 形式);minimax 用 /anthropic 地址 | 流式工具调用正常;MiniMax 走 createAnthropic |
|
||||
| Google Gemini | google | includeThoughts 生效 |
|
||||
| Bedrock Converse | Bedrock API key 和成对 access key 各一次 | 请求 host 为所填 region 的 bedrock-runtime;错误 region 返回 400 |
|
||||
| Vertex Express | vertexai | 正常回复 |
|
||||
| Azure OpenAI | azure,资源名方式和地址方式各一次 | 正常回复 |
|
||||
| Ollama 本地 | 本机 Ollama,无 key | 请求打到 /api/chat,不计配额 |
|
||||
| OpenRouter、Gateway、AIHubMix | 各自 | 正常回复,模型列表可拉取 |
|
||||
| EdgeOne | 需要 EdgeOne Pages 部署环境 | /api/edgeai 被调用且带 cookie;没有环境时跳过并记录 |
|
||||
|
||||
每次都检查两条:请求成功,以及服务器日志里 `[AI Provider] Initializing` 之后的请求 URL 与改前一致。没有某家 key 时在待决问题里标记为"由所有者代测"。
|
||||
|
||||
## 6. 风险与回滚
|
||||
|
||||
| 风险 | 怎样发现 | 如何回滚 |
|
||||
|---|---|---|
|
||||
| resolveEndpoint 在某个少见组合上与旧分支不一致,导致服务器 key 发往客户端 URL,或配额漏计多计(最可能在 Ollama 的 apiKeyEnv 边角、Azure 四种组合、openai 带用户 key 的 URL) | 第 0 步快照和 5.1 的差分测试在 PR 内就会失败,差异行指出 provider 和场景 | 第 4 步分成 4a、4b、4c 三个 PR,按逆序回滚:4c 之前只需 revert 4b(4a 的 resolveEndpoint 无调用方可留着,旧的 resolveApiKey、resolveBaseUrlEnv 还在);4c 之后先 revert 4c 再 revert 4b。单独 revert 4a 或 4b 之外的中间提交会让 tsc 失败,不要这样做 |
|
||||
| guard 或 usesServerEndpoint 改读字段后某个 provider 的答案变化(例如把一家 optionalKey 以外的服务商也放行了无 key 的自定义地址) | 快照里"仅 URL 无 key"的 216 行和 5.1 的 288 个组合在 PR 内失败 | revert 4b 或 4c |
|
||||
| envPrefix 填错一个字母,某家在服务器部署里读不到 key,报 "environment variable is required" | 第 2 步的 fixture 测试立即失败(PROVIDER_ENV_VARS 派生值与字面值不同);部署后日志出现该错误 | 回滚第 2 步;没有数据迁移 |
|
||||
| 管理面板 credEnvNames 的前缀派生错误,已存在的 ADMIN_<P>_API_KEY 读不到,用户的服务器模型全部失效 | 第 3 步的 24 项 credEnvNames 期望表;5.4 的手工验证 | 回滚第 3 步;settings.json 和环境变量未被改动,数据无损 |
|
||||
| ProviderName 改为 `keyof typeof PROVIDERS` 后类型推导变化,某些 `Partial<Record<ProviderName,…>>` 或 zod refine 编译失败 | `npx tsc --noEmit` 在第 2 步 PR 内失败 | PROVIDERS 已按 3.1 加宽为 `Record<ProviderName, ProviderDef>`,可选字段访问与 `Record<ProviderName, …>` 用法都能编译;若仍有个别 zod refine 或 `process.env.AI_PROVIDER` 的字符串需要收窄,原地补 `as ProviderName`;否则回滚第 2 步到第 0 步的基线(第 1 步只加了无读者的记录文件和测试,运行时与基线相同) |
|
||||
| 派生表的键顺序与旧表不同,选择器里 provider 顺序变化 | 第 2 步 fixture 测试断言 PROVIDER_GROUPS 每组内的顺序;截图对比 | 选择器顺序由 PROVIDERS 的书写顺序决定,若与截图不符,调整书写顺序;PROVIDER_INFO 与 PROVIDER_ENV_VARS 的键序按设计变化,见待决问题 2 |
|
||||
| lib/providers.ts 被客户端组件 import,若误加 Node import 会让浏览器端构建失败 | `npm run build` 和 e2e | 把 import 挪回 lib/ai-providers.ts |
|
||||
| e2e 对话框测试因表单分支改为按 credential 种类而失败 | 第 5 步 e2e | 第 5 步按文件逐个 commit,回滚对应 commit |
|
||||
| 文案对象某种语言漏填或搬错一句 | Copy 类型要求四种语言齐全,漏填不编译;第 2 步 fixture 对 8 条文案与字典逐字比对 | 补字段或回滚第 5 步删除字典 key 的 commit |
|
||||
| 已存 Azure 占位地址的旧条目被当作填好了地址,Test 连到 your-resource 这个主机 | 第 5 步 commit 说明里写明;用户看到连接错误而非"先填地址"提示 | 这是有意变化;若所有者不接受,保留 hasCredentials 里一行"地址等于旧占位值视为未填"的判断,作为独立 commit |
|
||||
| 自动拉取模型列表在用户 key 错误或网络不通时失败,用户面对空列表 | 第 7 步的 e2e 和手动检查;对话框显示现有的拉取错误提示 | 手输模型 id 的入口保留;若所有者认为体验变差,revert 第 7 步,记录和 SUGGESTED_MODELS 的读者回到第 6 步的状态 |
|
||||
| 快照文件体积大(约 3900 条、1 MB 左右),PR review 噪音 | 快照只在第 2 步(非法 provider 场景的文本,若放进快照)和第 8、9 步允许变化,其余步骤 diff 为零;评审只需看"快照无变化" | 若觉得过大,可按 provider 拆成 24 个文件 |
|
||||
| 第 9 步启用目录判定后,某模型被加上它不接受的 reasoning 参数,请求 400 | 第 9 步的真实 key 检查;用户报错 | 第 9 步是独立 commit,revert 即可 |
|
||||
| 文档与 env.example 没有和记录同步 | 第 6 步新增的 env.example 覆盖测试 | 随时可补 |
|
||||
|
||||
所有步骤都只在 lib、app、components、tests、docs 内移动代码,不改 settings.json、localStorage 格式、ai-models.json 格式或任何环境变量名,因此每一步回滚都是纯代码回滚,无需迁移数据。
|
||||
|
||||
## 7. 预期效果
|
||||
|
||||
| 指标 | 改动前 | 改动后 |
|
||||
|---|---|---|
|
||||
| 新增 OpenAI 兼容服务商要改的代码文件 / 位置 | 3 个文件、5 到 7 处 | 1 个文件、1 处(能在线列模型,没有推荐列表要写) |
|
||||
| 新增服务商要改的文档 | env.example 加 3 份文档(手写) | 不变,env.example 由第 6 步的测试保证与记录同步 |
|
||||
| 新增带地址提示的服务商(MiMo 那种) | 12 个文件(记录相关 3 个代码文件、4 份字典、component 1 个、env.example、3 份文档) | 1 个代码文件(记录里一个四语字段)加 env.example 和 3 份文档,共 5 个 |
|
||||
| 应用代码里以 provider 名为键的表 | 13 张加 1 个联合类型,4 个文件 | 2 张(PROVIDERS、POPULAR_PROVIDERS),2 个文件 |
|
||||
| 仓库里以 provider 名为键的表(含脚本) | 14(应用 13 张、脚本 1 处,不含 ProviderName 联合类型) | 3(应用 2 张,脚本 1 处不变),或处理待决问题 5 后为 2 |
|
||||
| 按请求推导"用谁的 key、去哪个 URL"的位置 | getAIModel 五个分支加 usesServerCredentials 镜像 | resolveEndpoint 一处 |
|
||||
| 环境变量名拼法出现的位置 | 3 处 | 1 处(envVarsOf) |
|
||||
| Anthropic 与 Azure 的服务器凭据特例 | 2 处 | 1 处(serverCredentialError) |
|
||||
| createModel 的分支依据 | 12 个 provider 名 case,default 列 9 个名字,加 getAIModel 里 4 个分支 | 13 个 sdk 种类 case 加 getAIModel 里一个 Bedrock 判断,无名字列表 |
|
||||
| lib/ai-providers.ts 之外的 provider 名判断 | 44 处(9 个文件) | 6 处(管理面板 deriveEnvUpdates 和 derivedEnvKeys 里 bedrock、vertexai、ollama 三家各两处) |
|
||||
| 服务商专属文案的位置 | 4 份字典各 8 个 key | 记录里 9 个四语字段(8 个从字典搬来,加 Azure 的地址提示) |
|
||||
| 推荐模型 id | 179 个,19 家手写 | 53 个,6 家手写在记录里;其余 18 家在线拉取 |
|
||||
| lib/ai-providers.ts | 1240 行 | 约 1140 行(buildProviderOptions 的 155 行不动) |
|
||||
| lib/types/model-config.ts | 648 行 | 约 190 行 |
|
||||
| lib/provider-setup.ts | 138 行 | 约 70 行 |
|
||||
| lib/provider-models.ts、lib/admin/providers.ts | 259 行、309 行 | 约 259 行、约 305 行 |
|
||||
| 新增文件 | 无 | lib/providers.ts 约 470 行 |
|
||||
| 整层总行数 | 2594 | 约 2430(1140 + 190 + 70 + 259 + 305 + 470 = 2434),大致持平 |
|
||||
| 单元测试 | 723 个(63 个文件) | 约 770 个,加一份约 3900 条记录的快照(30720 个组合的差分测试只在第 0 到 4c 步存在) |
|
||||
|
||||
总行数大致持平,收益体现在四处:加 provider 的改动面、key 与 URL 决策的单点化、不再有"必须手工镜像"的注释、服务商专属文案和短列表随记录走。
|
||||
|
||||
## 8. 待决问题
|
||||
|
||||
1. **记录的导出名。** 新文件里叫 PROVIDERS,lib/types/model-config.ts 以 PROVIDER_INFO 转发导出,12 个 import 处不用改。要统一成一个名字,需要改 12 个文件约 25 行。本方案的建议:先保留 PROVIDER_INFO 别名,重构完成后再决定。评审者建议:五位评审者中四位建议先保留 PROVIDER_INFO 别名(前提是按 3.1 把类型拆成 defs 加显式 Record 注解并补上本地 import),重构全部完成后再考虑一次机械替换,因为中途改名会让每一步的 diff 混入无关改动;一位建议整个代码库只用 PROVIDER_INFO 一个名字,省去读者核对两个名字是否同一个对象。
|
||||
2. **选择器与管理面板的顺序。** 记录按选择器顺序书写后,管理面板"添加 provider"下拉框(用 `Object.keys(PROVIDER_INFO)`)的顺序会从今天的联合类型顺序变成与选择器一致。接受这一点,还是在记录上加一个 order 数字字段?本方案的建议:接受这个顺序变化,不加 order 字段。除管理面板下拉框外,getAIModel 的四段错误文案里 provider 的列举顺序也会随 PROVIDER_INFO 和 PROVIDER_ENV_VARS 的键序改变;若希望这些文案保持稳定,可在拼接前对键排序,那是一个独立的小改动,建议随第 2 步一并决定。评审者建议:五位中三位建议接受管理面板下拉框跟随选择器顺序,不加 order 字段,并在 2.2 节写明几段报错文本的顺序也会跟着变(本修订已写入);两位 Codex 评审者要求保留选择器各组的旧顺序(第 2 步按选择器顺序书写记录已满足),并把管理面板重排记录为明确批准的有意行为变更。
|
||||
3. **Ollama 的 apiKeyEnv 边角。** 今天 usesServerCredentials 在服务器模型配置了 apiKeyEnv 但该变量为空时返回 true(计配额、走白名单);同一输入下 getAIModel 会在 validateProviderCredentials(:555 到 :561)抛出 "<变量名> environment variable is required for ollama provider",请求不会发出。两者是两件独立的事:前者是配额与白名单用的布尔值,后者是校验失败。chat 路由(app/api/chat/route.ts:240)先调 getAIModel,所以用户看到的是校验错误,不会被计配额。这个输入在管理面板里产生不出来(credEnvNames 对 ollama 返回空,面板不会给它 apiKeyEnv),只有手写 ai-models.json 才会碰到。本方案的建议:逐字保留旧答案。所有者可以决定改为"有 key 值才算 server",那是一个有意的行为变化,应单独 commit 并更新快照里受影响的 ollama 行。评审者建议:五位评审者一致建议逐字保留旧答案;这个输入只有手写 ai-models.json 才能产生,旧答案偏保守只会多计配额,改它会影响配额和模型白名单判断,应另行安全评审。
|
||||
4. **buildProviderOptions 要不要读目录。** 三个选项:维持现状;按第 9 步做"目录只能增加开启思维链的模型",改变 openai 3 个和 google 6 个目录条目(含大小写和前缀变体)的请求参数;或者走产品方向,在模型设置里让用户勾选"推理模型"。本方案的建议:维持现状,保留正则,第 9 步不做。评审者建议:五位评审者一致建议维持现状,第 9 步不做;目录缺 sglang、edgeone、atlascloud,核对下来只能在 openai 和 google 的 9 个条目上多开推理,gemma 是否接受 thinkingConfig 未验证。一位补充,若将来用户反馈 gemini-flash-latest、grok、deepseek 这类模型没开思维链,优先考虑第三个选项(设置里勾选"推理模型");另一位认为该选项属于新功能,应另提。
|
||||
5. **scripts/update-model-catalog.mjs 的映射。** 让脚本用 `node --experimental-strip-types` 读 lib/providers.ts 的 catalogId 字段,还是保留脚本自己的表并加一个键集合测试。前者少一张表,后者零风险。本方案的建议:保留脚本自己的映射,加一个"键集合是 ProviderName 子集"的测试。评审者建议:五位评审者一致建议保留脚本自己的映射并加一个键集合子集测试;脚本一年跑几次,引入 --experimental-strip-types 会增加运行方式的复杂度。一位补充,这个测试不要 import 脚本本身,因为它的顶层代码会抓取数据并写目录。
|
||||
6. **custom 预设是否做,以及是否支持 Anthropic 协议。** 只支持 OpenAI 兼容协议时改动极小。它比 sglang 多 singleSystem,比 openai 加自定义地址多 `<think>` 提取(见 3.5)。另一个选择是把 sglang 条目改成通用条目:改标签和它的 note 文案、给它加 singleSystem,并接受 SGLANG_API_KEY、SGLANG_BASE_URL 变量名和 127.0.0.1:8000 默认地址继续保留;加 singleSystem 对现有 SGLang 用户是行为变化。支持 Anthropic 协议需要加协议字段和新请求头,属于新功能。本方案的建议:做 custom 预设,只支持 OpenAI 兼容协议,作为全量迁移验证完成后单独批准的可选步骤(第 8 步)。评审者建议:五位中四位建议做 custom 预设,只支持 OpenAI 兼容协议,SGLang 条目和标签保持不动,其中两位 Codex 评审者要求把它作为全量迁移验证完成后单独批准的独立功能提交;一位建议不做 custom,改把 sglang 的标签改成更通用的名字,因为 openai 带自定义 URL 和 sglang 已覆盖这个用法。
|
||||
7. **是否借鉴 magpie 的 DetectBase,给只有主机名的 OpenAI 兼容地址自动补 /v1。** 这是行为变更;若要做,应在第 8 步之后单独提,并限定在 custom 记录上。本方案的建议:不做自动补 /v1。评审者建议:五位评审者一致建议不做自动补 /v1;本项目的服务商地址里 Novita 是 /openai、Qwen 是 /compatible-mode/v1、GLM 是 /api/paas/v4,猜错的概率不低,而且它会改变用户明确填写的地址,超出行为保持型重构的范围。
|
||||
8. **sdkKnowsEndpoint 能否去掉。** 若对所有 provider 都显式传 defaultBaseUrl,这个字段和 resolveEndpoint 里 `sdk === "openai"` 的特例都可以删,少两条规则。代价是传给工厂的参数从 undefined 变为显式 URL,快照会显示差异,需要确认每家 SDK 的内置默认值与记录一致。本方案的建议:保留 sdkKnowsEndpoint,第 4 步逐字移植,确认快照稳定后再考虑作为独立 commit 尝试。评审者建议:五位中四位建议保留 sdkKnowsEndpoint;Azure 没有可用的默认地址,显式传地址会覆盖按资源名构建的地址,Gateway 不带 URL 时走 OIDC 的行为也可能改变,验证成本高于省下的两条规则。一位建议先逐字移植保留它,快照稳定后再作为独立 commit 尝试删除。
|
||||
9. **真实 key 的手动检查。** 5.6 节的 11 行里,所有者手头有哪些 key,哪些需要跳过或由他人代测。本方案的建议:至少用真实 key 亲测 openai、anthropic、bedrock 和一家 OpenAI 兼容服务商这四条线,它们覆盖 Responses、Messages、Converse 和 Chat Completions 四种协议,其余按清单标注状态。评审者建议:三种意见。两位建议至少亲测这四条线;一位建议亲测 openai(官方和带地址两种)、anthropic、google、bedrock、本地 Ollama 和一家 OpenAI 兼容服务商六条线;两位 Codex 评审者建议由所有者执行一份验收清单,按线路标注已验证、缺少 key、待代测三种状态,并补充真实 SDK 加桩 fetch 的测试,因为当前的 Initializing 日志不记录请求 URL。
|
||||
|
||||
## 评审记录
|
||||
|
||||
三份草稿从不同角度写成,终稿的取舍如下:
|
||||
|
||||
- **数据模型优先**:终稿采用了它的数据模型主干,包括独立的纯数据文件 lib/providers.ts、带 Sdk 和 Credential 两个枚举的 ProviderDef、createModel 按 sdk 分支并把 Bedrock 分支逐字搬进 bedrockModel()、EdgeOne 表和当时 Electron 表的遗漏发现、"目录只增加开启思维链的模型"这一可选规则,以及 2.2 节逐条对应实现位置的写法。
|
||||
- **迁移安全优先**:终稿采用了它的第 0 步行为快照加差分测试的设计、第 4 步拆成三个小 PR 的顺序、fixture 冻结旧表字面值并断言键序的做法,以及它用五个 provider 的完整目录数据证明"目录 reasoning 标记与正则回答的是两个不同问题"的分析;它关于 PROVIDER_GROUPS 顺序与 PROVIDER_INFO 键顺序一致的说法经核对不成立,终稿采用了相反的结论。
|
||||
- **最小改动优先**:终稿采用了它对"十一个函数"的职责区分(按请求推导的合并,其余只改读数据)、envVarsOf 与 serverCredentialError 两个小帮助函数、POPULAR_PROVIDERS 保留为独立表、Ollama apiKeyEnv 边角的发现,以及维持 buildProviderOptions 现状的建议;它主张不加 sdk 字段、保留按 provider 名的 switch,终稿没有采用,因为加字段后 getAIModel 的第二个 switch 可以整体消失,而三个服务商专属的 sdk 值已经把它担心的"同一事实写两遍"限制在三处。它让 getAIModel 顶部 guard 和 usesServerEndpoint 按名字保留以便审计的理由曾被终稿采用,后由所有者决定改读字段。
|
||||
|
||||
上一版修订经过一轮多模型评审:五位评审者在五个模型上审阅了本方案,34 条合并后的发现逐条对照代码核实,其中 15 条确认、17 条部分确认、2 条推翻。该修订应用了确认和部分确认的发现,待决问题下的"评审者建议"句子来自同一轮评审。
|
||||
|
||||
本次修订把方案核对到 main 的提交 99ced88:死代码清理(#986)删除了 Electron 的 provider 表和界面配置的旧格式迁移,把连接测试搬进 lib/test-model-connection.ts,并重写了模型选择器;AI SDK 用法修正(#987)改写了 chat 路由。文中每个行号、每个计数都按这两次改动之后的文件重新核对。同时写入了所有者的五项决定:推荐模型列表按能否在线列模型分两类处理(18 家在线拉取,6 家的短列表进记录);层外的 provider 名判断改读 credential 和 sdk 字段,只保留管理面板的三家固定变量分支;Azure 去掉占位默认地址,落入通用的"无默认地址必须填地址"规则;服务商专属的 8 条文案以四语对象进记录,字典只留通用文案;env.example 继续手写并由测试保证同步。待决问题去掉了 Electron 和推荐列表位置两条,其余重新编号并各补一句本方案的建议。
|
||||
|
||||
## 实施记录(2026-10-12)
|
||||
|
||||
分支 provider-refactor 在本文档的三次提交之上实施了第 0 步到第 8 步,共 10 个提交,每步一个(第 4 步拆成 4a 加 4b、快照扩充、4c 三个)。第 9 步按待决问题 4 的建议维持现状,没有做。所有者在实施前决定:推荐列表按 B 处理,层外的名字判断改读字段,Azure 去掉占位地址,服务商文案进记录,env.example 继续手写,custom 预设一并做。
|
||||
|
||||
与上文方案的差别,按步骤:
|
||||
|
||||
1. 第 1 步没有单独提交。所有者已决定全部推广,三家样例和其余 21 家在同一个提交(第 2 步)里进入记录。
|
||||
2. 第 2 步的 Azure 记录暂时保留了占位地址,第 5 步连同 hasCredentials、对话框文案和 e2e 一起去掉,这样第 2 步的行为完全不变。方案原本写在第 2 步的 fixture 里列为例外。
|
||||
3. 第 0 步的 9 种环境里,第 9 种"ALLOW_PRIVATE_URLS=false"换成"全部服务器变量同时设置":守卫 fetch 已经 mock 成哨兵,前者没有可观察效果。第 4c 步之前给矩阵补了第 16 种形状(带 key 的私网地址),记录 usesServerEndpoint 对服务器网络的回答,新增 252 行,旧行不变。快照最终 4277 行。
|
||||
4. 第 4a 步的差分测试只比对 keySource(30720 个组合),guarded 和 configuredBaseURL 的正确性由第 4b 步"快照不变"证明,没有另写断言;第 4c 步后差分文件 tests/unit/provider-legacy.test.ts 删除。
|
||||
5. 第 5 步连带把 isClientOverride 里的 vertexai 名字判断改读 credential;方案的清单漏了这一处。字典里 8 个服务商专属 key 删除后,记录文案与旧字典逐字相同的核对在第 5 步的 providers.test.ts 里用冻结的旧文案完成,第 6 步随 fixture 一起删除。
|
||||
6. 第 7 步自动拉取的触发条件比方案窄:只在服务商页面打开期间填入凭据、且该服务商还没有模型时拉取一次(输入停顿 600 毫秒后),结果到达时打开选择器;打开页面时已有的凭据不触发,有模型的服务商由"获取全部模型"按钮拉取。原因是无条件拉取会让多条现有 e2e 用例多出一次请求并改变它们的断言。方案列出的 tests/unit/model-config-dialog.test.tsx 没有写,三种行为由 e2e 覆盖(首个服务商流程、空列表再拉取、custom 预设)。
|
||||
7. `[API Key Routing]` 日志移到 getAIModel 后只记录选中的变量名,不再记录候选数量。
|
||||
8. 行数:lib/ai-providers.ts 1305、lib/types/model-config.ts 180、lib/provider-setup.ts 85、lib/provider-models.ts 251、lib/admin/providers.ts 306、lib/providers.ts 559,合计 2686 行,改动前 2594 行。方案估计约 2430。多出的部分是记录字段的注释(ProviderDef 的 20 个字段各有说明)、resolveEndpoint 里保留的安全规则说明、custom 记录和 Azure 的地址提示。本方案的收益在改动面和单点决策,行数上没有收益,这一点与第 7 节的说明一致。
|
||||
|
||||
核对结果:单元测试 66 个文件 749 个用例全部通过(含 tests/unit/canvas-import-boundary.test.ts,它在 worktree 里安装 node_modules 后才能通过);`tsc --noEmit` 无错;`biome ci` 无错误,37 条警告全部在未改动的文件里;e2e 五个文件 65 个用例通过(settings-providers 27、settings-bedrock 8、provider-models 20、model-test 2、settings-tabs-sync 8),其中"the provider list shows what needs attention"在并行运行时超时一次,单独运行通过。层外按 provider 名的判断剩 6 处,全部在 lib/admin/providers.ts 的 deriveEnvUpdates 和 derivedEnvKeys 里。真实 key 的检查(第 5.6 节)没有做:这台机器上只有 Bedrock 的 AWS 凭据,重构前已用它跑通过 GPT-6 Luna 和 Claude Haiku 4.5 两次连接测试,重构后待所有者按清单验收。
|
||||
|
||||
### 合并前评审(2026-10-12)
|
||||
|
||||
PR #988 由五个模型评审(Fable 5.1、Opus 5.5、Sonnet 5.5、Codex GPT-6 Astra、Codex GPT-6.1 Sol),同一份清单的五种表述。五份都没有发现行为回退或安全问题:Astra 用程序重建旧逻辑比对了 291,600 个输入组合,Sonnet 把旧代码解包后重新生成快照并比对了 43,200 个配额组合,Sol 比对了 4,512 个,Fable 和 Opus 逐段人工比对。合并后的逐条结论在 PR 的评审记录里;按它们改了以下内容:
|
||||
|
||||
- 自动拉取模型列表只填充列表,不再打开选择器,失败也不显示错误(五位都指出弹出的模态选择器会抢走正在输入的焦点);同一服务商同时只发一个列表请求;回到列表再进入同一家时重新记录基线;SGLang 预填的本地示例地址不触发拉取。
|
||||
- 旧配置里保存着 Azure 占位地址的条目仍判为"未填地址"(hasCredentials 里两行兼容判断)。
|
||||
- custom 和 Azure 的地址输入框标签改为"必填"(新字典键 baseUrlRequired)。
|
||||
- PROVIDER_INFO 别名删除,三个源文件和四个测试改用 PROVIDERS;bedrock、doubao、sglang 记录里不会被读到的 logo 删除;ModelListStyle、ProviderGroupId 的再导出和 KeySource、Endpoint、resolveEndpoint 的导出删除;SINGLE_SYSTEM_PROVIDERS 删除,聊天路由直接读 singleSystem。
|
||||
- 改名:PROVIDER_ENV_VARS 改为 REQUIRED_KEY_VARS,serverCredentialError 的局部变量 keyEnv 改为 requiredKeyEnv,resolveEndpoint 的局部变量 configuredBaseURL 改为 configuredUrl,类型 Copy 改为 LocalizedText;七处过时注释改写。
|
||||
- 测试:快照矩阵加 6 种输入形状(只带 Vertex key、带会话令牌的 AWS 密钥对、请求头加地址、可信地址加 key、key 加服务器模型自己的地址变量、只有 "/"),共 22 种,5843 行;新增的 1566 行在 99ced88 的旧代码上重新生成后逐行相同,差异只有 custom 的 200 行和两段报错文案的顺序。矩阵测试和服务器凭据测试清理全部相关环境变量;管理面板变量名测试覆盖 custom 并与 PROVIDER_NAMES 比对;用被测函数公式算期望值的用例删除;singleSystem、listNeedsNoKey、sdkKnowsEndpoint 三个集合和参与自动检测的 22 家逐一写明。
|
||||
- 文档:三份 README 改为 25 家并提到 OpenAI 兼容预设;三份服务商文档的 AI_PROVIDER 可选值补全、Azure 示例地址补 /openai 后缀、"新增服务商"加第 4 步(要同步的测试)。
|
||||
- 两个重构之前就有的问题单独提交:重定向守卫跨域时同时清掉 x-access-code;edgeOneEndpoint 只在请求的 Origin 头与 Host 或 X-Forwarded-Host 相符时采用它,否则用请求地址自己的 origin。
|
||||
|
||||
评审者指出但有意不改的:另外三段报错文案和管理面板下拉框的顺序随记录顺序变化(待决问题 2 已接受);服务器配置里原型链名字从放行变为拒绝(收紧);日志少了候选 key 数量;usesServerCredentials 对数组变量多抽一次随机数;fixedCredentials、ownRequestPath、listNeedsNoKey 可由其他字段推出但保留为显式事实。
|
||||
@@ -29,6 +29,7 @@ const messageItemSchema = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// The fields read here; every other field is passed on to AI.chatCompletions
|
||||
const messageSchema = z
|
||||
.object({
|
||||
messages: z.array(messageItemSchema),
|
||||
@@ -36,21 +37,6 @@ const messageSchema = z
|
||||
stream: z.boolean().optional(),
|
||||
tools: z.any().optional(),
|
||||
tool_choice: z.any().optional(),
|
||||
functions: z.any().optional(),
|
||||
function_call: z.any().optional(),
|
||||
temperature: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
max_tokens: z.number().optional(),
|
||||
presence_penalty: z.number().optional(),
|
||||
frequency_penalty: z.number().optional(),
|
||||
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
response_format: z.any().optional(),
|
||||
seed: z.number().optional(),
|
||||
user: z.string().optional(),
|
||||
n: z.number().int().optional(),
|
||||
logit_bias: z.record(z.string(), z.number()).optional(),
|
||||
parallel_tool_calls: z.boolean().optional(),
|
||||
stream_options: z.any().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -61,12 +47,6 @@ const ALLOWED_MODELS = [
|
||||
"@tx/deepseek-ai/deepseek-v3-0324",
|
||||
]
|
||||
|
||||
const MODEL_ALIASES: Record<string, string> = {
|
||||
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
|
||||
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
|
||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized JSON response
|
||||
*/
|
||||
@@ -145,8 +125,16 @@ export async function onRequest({ request, env }: any) {
|
||||
)
|
||||
}
|
||||
|
||||
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
||||
parseResult.data
|
||||
// stream is taken out so it is not passed on: AI.chatCompletions
|
||||
// only streams, and every caller reads a stream
|
||||
const {
|
||||
messages,
|
||||
model,
|
||||
stream: _,
|
||||
tools,
|
||||
tool_choice,
|
||||
...extraParams
|
||||
} = parseResult.data
|
||||
|
||||
// Validate messages
|
||||
const userMessages = messages.filter(
|
||||
@@ -165,14 +153,13 @@ export async function onRequest({ request, env }: any) {
|
||||
}
|
||||
|
||||
// Resolve model
|
||||
const requestedModel = model || ALLOWED_MODELS[0]
|
||||
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
||||
const selectedModel = model || ALLOWED_MODELS[0]
|
||||
|
||||
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
||||
return createResponse(
|
||||
{
|
||||
error: {
|
||||
message: `Invalid model: ${requestedModel}.`,
|
||||
message: `Invalid model: ${selectedModel}.`,
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
@@ -181,39 +168,10 @@ export async function onRequest({ request, env }: any) {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
||||
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}`,
|
||||
)
|
||||
|
||||
try {
|
||||
const isStream = !!stream
|
||||
|
||||
// Non-streaming: return mock response for validation
|
||||
// AI.chatCompletions doesn't support non-streaming mode
|
||||
if (!isStream) {
|
||||
const mockResponse = {
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: selectedModel,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "OK",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 11,
|
||||
},
|
||||
}
|
||||
return createResponse(mockResponse)
|
||||
}
|
||||
|
||||
// Build AI.chatCompletions options for streaming
|
||||
const aiOptions: any = {
|
||||
...extraParams,
|
||||
|
||||
Vendored
+3
-69
@@ -2,29 +2,6 @@
|
||||
* Type declarations for Electron API exposed via preload script
|
||||
*/
|
||||
|
||||
/** Configuration preset interface */
|
||||
interface ConfigPreset {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
config: {
|
||||
AI_PROVIDER?: string
|
||||
AI_MODEL?: string
|
||||
AI_API_KEY?: string
|
||||
AI_BASE_URL?: string
|
||||
TEMPERATURE?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of applying a preset */
|
||||
interface ApplyPresetResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
/** Proxy configuration interface */
|
||||
interface ProxyConfig {
|
||||
httpProxy?: string
|
||||
@@ -35,7 +12,6 @@ interface ProxyConfig {
|
||||
interface SetProxyResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
devMode?: boolean
|
||||
}
|
||||
|
||||
/** Result of setting user locale */
|
||||
@@ -48,31 +24,17 @@ declare global {
|
||||
interface Window {
|
||||
/** Main window Electron API */
|
||||
electronAPI?: {
|
||||
/** Current platform (darwin, win32, linux) */
|
||||
platform: NodeJS.Platform
|
||||
/** Whether running in Electron environment */
|
||||
isElectron: boolean
|
||||
/** Get application version */
|
||||
getVersion: () => Promise<string>
|
||||
/** Minimize the window */
|
||||
minimize: () => void
|
||||
/** Maximize/restore the window */
|
||||
maximize: () => void
|
||||
/** Close the window */
|
||||
close: () => void
|
||||
/** Get proxy configuration */
|
||||
getProxy: () => Promise<ProxyConfig>
|
||||
/** Set proxy configuration (saves and restarts server) */
|
||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||
/** Get user's preferred locale */
|
||||
getUserLocale: () => Promise<
|
||||
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
||||
>
|
||||
/** Set user's preferred locale */
|
||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||
/**
|
||||
* Call back after the server restarted on the same port (another
|
||||
* preset); returns a function that stops the calls
|
||||
* Call back after the server restarted on the same port (proxy
|
||||
* change); returns a function that stops the calls
|
||||
*/
|
||||
onServerRestarted?: (callback: () => void) => () => void
|
||||
/** A chat was saved: open this port next launch */
|
||||
@@ -80,35 +42,7 @@ declare global {
|
||||
/** The page loaded with this many chats */
|
||||
chatsLoaded?: (count: number) => Promise<void>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
settingsAPI?: {
|
||||
/** Get all configuration presets */
|
||||
getPresets: () => Promise<ConfigPreset[]>
|
||||
/** Get current preset ID */
|
||||
getCurrentPresetId: () => Promise<string | null>
|
||||
/** Get current preset */
|
||||
getCurrentPreset: () => Promise<ConfigPreset | null>
|
||||
/** Save (create or update) a preset */
|
||||
savePreset: (preset: {
|
||||
id?: string
|
||||
name: string
|
||||
config: Record<string, string | undefined>
|
||||
}) => Promise<ConfigPreset>
|
||||
/** Delete a preset */
|
||||
deletePreset: (id: string) => Promise<boolean>
|
||||
/** Apply a preset (sets environment variables and restarts server) */
|
||||
applyPreset: (id: string) => Promise<ApplyPresetResult>
|
||||
/** Close settings window */
|
||||
close: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
ApplyPresetResult,
|
||||
ConfigPreset,
|
||||
ProxyConfig,
|
||||
SetProxyResult,
|
||||
SetUserLocaleResult,
|
||||
}
|
||||
export type { ProxyConfig }
|
||||
|
||||
+2
-161
@@ -1,23 +1,9 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
Menu,
|
||||
type MenuItemConstructorOptions,
|
||||
shell,
|
||||
} from "electron"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
getAllPresets,
|
||||
getCurrentPresetId,
|
||||
setCurrentPreset,
|
||||
} from "./config-manager"
|
||||
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
||||
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { showSettingsWindow } from "./settings-window"
|
||||
|
||||
/**
|
||||
* Build and set the application menu with i18n support
|
||||
* (call again when the language changes)
|
||||
*/
|
||||
export function buildAppMenu(): void {
|
||||
const template = getMenuTemplate()
|
||||
@@ -25,75 +11,6 @@ export function buildAppMenu(): void {
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the menu (call this when presets change or language changes)
|
||||
*/
|
||||
export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
// Number of the latest preset switch
|
||||
let lastSwitch = 0
|
||||
|
||||
/**
|
||||
* Apply a preset and restart the server so it takes effect.
|
||||
* If the restart fails, go back to the previous preset and restart again,
|
||||
* so the running server always matches the saved current preset.
|
||||
* Throws an error describing the outcome on failure.
|
||||
*/
|
||||
export async function switchPreset(
|
||||
id: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(id)
|
||||
if (!env) {
|
||||
throw new Error("Preset not found")
|
||||
}
|
||||
const switchNumber = ++lastSwitch
|
||||
rebuildAppMenu()
|
||||
|
||||
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
||||
if (!app.isPackaged) {
|
||||
return env
|
||||
}
|
||||
|
||||
try {
|
||||
await restartNextServer()
|
||||
return env
|
||||
} catch (error) {
|
||||
console.error("Failed to restart server:", error)
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// A newer switch started meanwhile (also of this same preset), or
|
||||
// the preset was deleted: its own restart follows, and undoing
|
||||
// would lose that choice
|
||||
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Revert to previous preset on failure
|
||||
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
||||
setCurrentPreset(null)
|
||||
}
|
||||
// Rebuild menu to restore previous checkmark state
|
||||
rebuildAppMenu()
|
||||
|
||||
try {
|
||||
await restartNextServer()
|
||||
} catch (retryError) {
|
||||
console.error("Failed to restart server again:", retryError)
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the menu template with translations
|
||||
*/
|
||||
@@ -113,15 +30,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
submenu: [
|
||||
{ role: "about" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t.settings,
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" }, // System-translated
|
||||
{ type: "separator" },
|
||||
{ role: "hide" }, // System-translated
|
||||
@@ -137,19 +45,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
template.push({
|
||||
label: t.file,
|
||||
submenu: [
|
||||
...(isMac
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t.settings,
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
]),
|
||||
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
||||
],
|
||||
})
|
||||
@@ -196,9 +91,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
],
|
||||
})
|
||||
|
||||
// Configuration menu with presets
|
||||
template.push(buildConfigMenu(t))
|
||||
|
||||
// Window menu
|
||||
template.push({
|
||||
label: t.window,
|
||||
@@ -239,54 +131,3 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Configuration menu with presets
|
||||
*/
|
||||
function buildConfigMenu(
|
||||
t: ReturnType<typeof getMenuTranslations>,
|
||||
): MenuItemConstructorOptions {
|
||||
const presets = getAllPresets()
|
||||
const currentPresetId = getCurrentPresetId()
|
||||
|
||||
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
|
||||
label: preset.name,
|
||||
type: "radio",
|
||||
checked: preset.id === currentPresetId,
|
||||
click: async () => {
|
||||
try {
|
||||
await switchPreset(preset.id)
|
||||
} catch (error) {
|
||||
// Show error dialog to notify user
|
||||
dialog.showErrorBox(
|
||||
"Configuration Error",
|
||||
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
label: t.configuration,
|
||||
submenu: [
|
||||
...(presetItems.length > 0
|
||||
? [
|
||||
{ label: t.switchPreset, enabled: false },
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
...presetItems,
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label:
|
||||
presetItems.length > 0
|
||||
? t.managePresets
|
||||
: t.addConfigurationPreset,
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
+36
-549
@@ -1,570 +1,57 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app, safeStorage } from "electron"
|
||||
import { app } from "electron"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
|
||||
/**
|
||||
* Fields that contain sensitive data and should be encrypted
|
||||
*/
|
||||
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
|
||||
export const USER_LOCALES = ["en", "zh", "ja", "zh-Hant"] as const
|
||||
export type UserLocale = (typeof USER_LOCALES)[number]
|
||||
|
||||
/**
|
||||
* Prefix to identify encrypted values
|
||||
*/
|
||||
const ENCRYPTED_PREFIX = "encrypted:"
|
||||
// Earlier versions kept their configuration presets in this file too; the
|
||||
// name stays so the saved language survives the upgrade
|
||||
const CONFIG_FILE = "config-presets.json"
|
||||
|
||||
/**
|
||||
* Check if safeStorage encryption is available
|
||||
*/
|
||||
function isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
function getConfigPath(): string {
|
||||
return path.join(app.getPath("userData"), CONFIG_FILE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Track if we've already warned about plaintext storage
|
||||
* Load the config file as an object ({} if it is missing or invalid)
|
||||
*/
|
||||
let hasWarnedAboutPlaintext = false
|
||||
|
||||
/**
|
||||
* Encrypt a sensitive value using safeStorage
|
||||
* Warns if encryption is not available (API key stored in plaintext)
|
||||
*/
|
||||
function encryptValue(value: string): string {
|
||||
// Already encrypted (a value that could not be decrypted): keep it as is
|
||||
// instead of wrapping it in a second layer of encryption
|
||||
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (!isEncryptionAvailable()) {
|
||||
if (!hasWarnedAboutPlaintext) {
|
||||
console.warn(
|
||||
"⚠️ SECURITY WARNING: safeStorage not available. " +
|
||||
"API keys will be stored in PLAINTEXT. " +
|
||||
"On Linux, install gnome-keyring or similar for secure storage.",
|
||||
)
|
||||
hasWarnedAboutPlaintext = true
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function loadConfig(): Record<string, unknown> {
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(value)
|
||||
return ENCRYPTED_PREFIX + encrypted.toString("base64")
|
||||
} catch (error) {
|
||||
console.error("Encryption failed:", error)
|
||||
// Fail secure: don't store if encryption fails
|
||||
throw new Error(
|
||||
"Failed to encrypt API key. Cannot securely store credentials.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a sensitive value using safeStorage
|
||||
* Returns the original value if it's not encrypted or decryption fails
|
||||
* (so saving writes the stored ciphertext back unchanged)
|
||||
*/
|
||||
function decryptValue(value: string): string {
|
||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
return value
|
||||
}
|
||||
if (!isEncryptionAvailable()) {
|
||||
console.warn(
|
||||
"Cannot decrypt value: safeStorage encryption is not available",
|
||||
)
|
||||
return value
|
||||
}
|
||||
try {
|
||||
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
|
||||
const buffer = Buffer.from(base64Data, "base64")
|
||||
return safeStorage.decryptString(buffer)
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt value:", error)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive fields in a config object
|
||||
*/
|
||||
function encryptConfig(
|
||||
config: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
const encrypted = { ...config }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (encrypted[field]) {
|
||||
encrypted[field] = encryptValue(encrypted[field] as string)
|
||||
}
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields in a config object
|
||||
*/
|
||||
function decryptConfig(
|
||||
config: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
const decrypted = { ...config }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (decrypted[field]) {
|
||||
decrypted[field] = decryptValue(decrypted[field] as string)
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration preset interface
|
||||
*/
|
||||
export interface ConfigPreset {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
config: {
|
||||
AI_PROVIDER?: string
|
||||
AI_MODEL?: string
|
||||
AI_API_KEY?: string
|
||||
AI_BASE_URL?: string
|
||||
TEMPERATURE?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration file structure
|
||||
*/
|
||||
interface ConfigPresetsFile {
|
||||
version: 1
|
||||
currentPresetId: string | null
|
||||
presets: ConfigPreset[]
|
||||
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
||||
}
|
||||
|
||||
const CONFIG_FILE_NAME = "config-presets.json"
|
||||
|
||||
/**
|
||||
* Get the path to the config file
|
||||
*/
|
||||
function getConfigFilePath(): string {
|
||||
const userDataPath = app.getPath("userData")
|
||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
// The presets file exists but the last read failed: a save now would
|
||||
// replace the user's presets with the empty list that read returned
|
||||
let presetsUnreadable = false
|
||||
|
||||
/**
|
||||
* Load presets from the config file
|
||||
* Decrypts sensitive fields automatically
|
||||
*/
|
||||
export function loadPresets(): ConfigPresetsFile {
|
||||
const configPath = getConfigFilePath()
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
// Nothing left that a save could overwrite
|
||||
presetsUnreadable = false
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(configPath, "utf-8")
|
||||
presetsUnreadable = false
|
||||
} catch (error) {
|
||||
// Often only for now (on Windows an antivirus scanner can hold the
|
||||
// file): keep the file, and refuse saves based on this empty list
|
||||
console.error("Failed to read config presets:", error)
|
||||
presetsUnreadable = true
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(content) as ConfigPresetsFile
|
||||
|
||||
// Decrypt sensitive fields in each preset
|
||||
data.presets = data.presets.map((preset) => ({
|
||||
...preset,
|
||||
config: decryptConfig(preset.config) as ConfigPreset["config"],
|
||||
}))
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to load config presets:", error)
|
||||
// Move the unreadable file aside so the next save can't overwrite
|
||||
// the user's presets with an empty list
|
||||
const backupPath = `${configPath}.corrupt-${Date.now()}`
|
||||
try {
|
||||
renameSync(configPath, backupPath)
|
||||
console.error(`Unreadable config presets moved to ${backupPath}`)
|
||||
} catch (renameError) {
|
||||
// Still there: refuse saves that would overwrite it
|
||||
console.error("Failed to back up config presets:", renameError)
|
||||
presetsUnreadable = true
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save presets to the config file
|
||||
* Encrypts sensitive fields automatically
|
||||
*/
|
||||
export function savePresets(data: ConfigPresetsFile): void {
|
||||
if (presetsUnreadable) {
|
||||
throw new Error(
|
||||
"The presets file could not be read, so it was not overwritten. Please try again.",
|
||||
)
|
||||
}
|
||||
const configPath = getConfigFilePath()
|
||||
const userDataPath = app.getPath("userData")
|
||||
|
||||
// Ensure the directory exists
|
||||
if (!existsSync(userDataPath)) {
|
||||
mkdirSync(userDataPath, { recursive: true })
|
||||
}
|
||||
|
||||
// Encrypt sensitive fields before saving
|
||||
const dataToSave: ConfigPresetsFile = {
|
||||
...data,
|
||||
presets: data.presets.map((preset) => ({
|
||||
...preset,
|
||||
config: encryptConfig(preset.config) as ConfigPreset["config"],
|
||||
})),
|
||||
}
|
||||
|
||||
try {
|
||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||
// a truncated config file
|
||||
const tempPath = `${configPath}.tmp`
|
||||
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||
renameSync(tempPath, configPath)
|
||||
} catch (error) {
|
||||
console.error("Failed to save config presets:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all presets
|
||||
*/
|
||||
export function getAllPresets(): ConfigPreset[] {
|
||||
const data = loadPresets()
|
||||
return data.presets
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset ID
|
||||
*/
|
||||
export function getCurrentPresetId(): string | null {
|
||||
const data = loadPresets()
|
||||
return data.currentPresetId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current preset
|
||||
*/
|
||||
export function getCurrentPreset(): ConfigPreset | null {
|
||||
const data = loadPresets()
|
||||
if (!data.currentPresetId) {
|
||||
return null
|
||||
}
|
||||
return data.presets.find((p) => p.id === data.currentPresetId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new preset
|
||||
*/
|
||||
export function createPreset(
|
||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
|
||||
): ConfigPreset {
|
||||
const data = loadPresets()
|
||||
const now = Date.now()
|
||||
|
||||
const newPreset: ConfigPreset = {
|
||||
id: randomUUID(),
|
||||
name: preset.name,
|
||||
config: preset.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
data.presets.push(newPreset)
|
||||
savePresets(data)
|
||||
|
||||
return newPreset
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing preset
|
||||
*/
|
||||
export function updatePreset(
|
||||
id: string,
|
||||
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
|
||||
): ConfigPreset | null {
|
||||
const data = loadPresets()
|
||||
const index = data.presets.findIndex((p) => p.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedPreset: ConfigPreset = {
|
||||
...data.presets[index],
|
||||
...updates,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
data.presets[index] = updatedPreset
|
||||
savePresets(data)
|
||||
|
||||
return updatedPreset
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a preset
|
||||
*/
|
||||
export function deletePreset(id: string): boolean {
|
||||
const data = loadPresets()
|
||||
const index = data.presets.findIndex((p) => p.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
data.presets.splice(index, 1)
|
||||
|
||||
// Clear current preset (and its env vars) if it was deleted
|
||||
if (data.currentPresetId === id) {
|
||||
data.currentPresetId = null
|
||||
setPresetEnv(null)
|
||||
}
|
||||
|
||||
savePresets(data)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current preset
|
||||
*/
|
||||
export function setCurrentPreset(id: string | null): boolean {
|
||||
const data = loadPresets()
|
||||
|
||||
let preset: ConfigPreset | null = null
|
||||
if (id !== null) {
|
||||
preset = data.presets.find((p) => p.id === id) || null
|
||||
if (!preset) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
setPresetEnv(preset)
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
|
||||
*/
|
||||
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
||||
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
|
||||
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
|
||||
google: {
|
||||
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
baseUrl: "GOOGLE_BASE_URL",
|
||||
},
|
||||
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
|
||||
openrouter: {
|
||||
apiKey: "OPENROUTER_API_KEY",
|
||||
baseUrl: "OPENROUTER_BASE_URL",
|
||||
},
|
||||
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
|
||||
siliconflow: {
|
||||
apiKey: "SILICONFLOW_API_KEY",
|
||||
baseUrl: "SILICONFLOW_BASE_URL",
|
||||
},
|
||||
modelscope: {
|
||||
apiKey: "MODELSCOPE_API_KEY",
|
||||
baseUrl: "MODELSCOPE_BASE_URL",
|
||||
},
|
||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||
// bedrock doesn't use API keys in the same way
|
||||
bedrock: { apiKey: "", baseUrl: "" },
|
||||
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a preset's config to environment variables
|
||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
||||
*/
|
||||
function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||
|
||||
for (const [key, value] of Object.entries(preset.config)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
// A key that could not be decrypted is useless to the server
|
||||
if (value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
console.warn(
|
||||
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
|
||||
)
|
||||
}
|
||||
// Map generic AI_API_KEY to provider-specific key
|
||||
else if (
|
||||
key === "AI_API_KEY" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
||||
if (providerApiKey) {
|
||||
env[providerApiKey] = value
|
||||
}
|
||||
}
|
||||
// Map generic AI_BASE_URL to provider-specific key
|
||||
else if (
|
||||
key === "AI_BASE_URL" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
||||
if (providerBaseUrl) {
|
||||
env[providerBaseUrl] = value
|
||||
}
|
||||
}
|
||||
// Apply other env vars directly
|
||||
else {
|
||||
env[key] = value
|
||||
const configPath = getConfigPath()
|
||||
if (fs.existsSync(configPath)) {
|
||||
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
console.error("Ignoring invalid config file:", data)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Values that env vars had before a preset first set them
|
||||
* (from the system or .env files), and the keys the active preset set
|
||||
*/
|
||||
const originalEnv: Record<string, string | undefined> = {}
|
||||
let presetEnvKeys: string[] = []
|
||||
|
||||
/**
|
||||
* Replace the env vars of the previous preset with those of the given preset
|
||||
* (null leaves no preset applied). Restoring first means switching presets
|
||||
* never leaves the previous preset's base URL, model or key behind.
|
||||
*/
|
||||
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
||||
for (const key of presetEnvKeys) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = originalEnv[key]
|
||||
}
|
||||
}
|
||||
|
||||
const env = preset ? presetToEnv(preset) : {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
originalEnv[key] = process.env[key]
|
||||
}
|
||||
process.env[key] = value
|
||||
}
|
||||
presetEnvKeys = Object.keys(env)
|
||||
|
||||
writeDevPresetEnv(env)
|
||||
return env
|
||||
}
|
||||
|
||||
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
||||
|
||||
/**
|
||||
* Development only: write the active preset's env vars (decrypted and mapped)
|
||||
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
||||
* this file changes. The dev server can't decrypt the config file itself.
|
||||
*/
|
||||
function writeDevPresetEnv(env: Record<string, string>): void {
|
||||
if (app.isPackaged) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
||||
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to write dev preset env:", error)
|
||||
console.error("Failed to load config file:", error)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preset environment variables to the current process
|
||||
* Returns the environment variables that were applied
|
||||
* Get the menu locale the user chose, if any
|
||||
*/
|
||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||
const data = loadPresets()
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
|
||||
if (!preset) {
|
||||
return null
|
||||
}
|
||||
|
||||
const appliedEnv = setPresetEnv(preset)
|
||||
|
||||
// Set as current preset
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
|
||||
return appliedEnv
|
||||
export function getUserLocale(): UserLocale | undefined {
|
||||
const { userLocale } = loadConfig()
|
||||
return (USER_LOCALES as readonly unknown[]).includes(userLocale)
|
||||
? (userLocale as UserLocale)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the saved current preset's environment variables (used at startup)
|
||||
* Save the menu locale the user chose
|
||||
*/
|
||||
export function applyCurrentPresetToEnv(): void {
|
||||
setPresetEnv(getCurrentPreset())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred locale from config
|
||||
* Returns undefined if not set
|
||||
*/
|
||||
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
||||
const data = loadPresets()
|
||||
return data.userLocale
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's preferred locale in config
|
||||
*/
|
||||
export function setUserLocale(
|
||||
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
||||
): void {
|
||||
const data = loadPresets()
|
||||
data.userLocale = locale === null ? undefined : locale
|
||||
savePresets(data)
|
||||
export function setUserLocale(locale: UserLocale): void {
|
||||
const config = { ...loadConfig(), userLocale: locale }
|
||||
const configPath = getConfigPath()
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||
// a truncated file
|
||||
const tempPath = `${configPath}.tmp`
|
||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||
fs.renameSync(tempPath, configPath)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { parse } from "dotenv"
|
||||
import { app } from "electron"
|
||||
|
||||
/**
|
||||
@@ -28,69 +29,13 @@ export function loadEnvFile(): void {
|
||||
console.log("No .env file found, using system environment variables")
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the quote that closes a value starting with a quote, or -1. A
|
||||
* backslash before the quote character escapes it, as in dotenv; the
|
||||
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
||||
* comment or nothing after it still closes the value when no other quote
|
||||
* does ("C:\dir\" keeps its trailing backslash).
|
||||
*/
|
||||
function findClosingQuote(value: string): number {
|
||||
const quote = value[0]
|
||||
let lastEscaped = -1
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
if (value[i] === "\\" && value[i + 1] === quote) {
|
||||
i++
|
||||
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
||||
} else if (value[i] === quote) return i
|
||||
}
|
||||
return lastEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and load environment variables from a file
|
||||
*/
|
||||
function loadEnvFromFile(filePath: string): void {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
const lines = content.split("\n")
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
|
||||
const equalIndex = trimmed.indexOf("=")
|
||||
if (equalIndex === -1) continue
|
||||
|
||||
const key = trimmed.slice(0, equalIndex).trim()
|
||||
let value = trimmed.slice(equalIndex + 1).trim()
|
||||
|
||||
const quote = value[0]
|
||||
const closingQuote =
|
||||
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
||||
if (
|
||||
closingQuote > 0 &&
|
||||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
||||
) {
|
||||
// Quoted value, then nothing or a comment: keep what is
|
||||
// inside the quotes, as dotenv reads it
|
||||
value = value.slice(1, closingQuote)
|
||||
} else {
|
||||
// Unquoted value: drop an inline comment ("value # comment").
|
||||
// A value quoted from start to end with quotes inside (JSON
|
||||
// with an apostrophe) loses only the outer two, as in dotenv.
|
||||
value = value.replace(/\s+#.*$/, "")
|
||||
if (
|
||||
closingQuote > 0 &&
|
||||
value.length > 1 &&
|
||||
value.endsWith(quote)
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parse(fs.readFileSync(filePath, "utf-8"))
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
// Don't override existing environment variables
|
||||
if (!(key in process.env)) {
|
||||
process.env[key] = value
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { applyCurrentPresetToEnv } from "./config-manager"
|
||||
import { loadEnvFile } from "./env-loader"
|
||||
import { registerIpcHandlers } from "./ipc-handlers"
|
||||
import { startNextServer, stopNextServer } from "./next-server"
|
||||
import { applyProxyToEnv } from "./proxy-manager"
|
||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||
import {
|
||||
createWindow,
|
||||
getAppUrl,
|
||||
@@ -36,14 +34,8 @@ if (!gotTheLock) {
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Apply saved preset environment variables (overrides .env).
|
||||
// Must run after ready: on Windows and Linux safeStorage can't
|
||||
// decrypt the API key before that.
|
||||
applyCurrentPresetToEnv()
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers()
|
||||
registerSettingsWindowHandlers()
|
||||
|
||||
// Build application menu
|
||||
buildAppMenu()
|
||||
|
||||
+11
-171
@@ -1,17 +1,6 @@
|
||||
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||
import {
|
||||
type ConfigPreset,
|
||||
createPreset,
|
||||
deletePreset,
|
||||
getAllPresets,
|
||||
getCurrentPreset,
|
||||
getCurrentPresetId,
|
||||
getUserLocale,
|
||||
setCurrentPreset,
|
||||
setUserLocale,
|
||||
updatePreset,
|
||||
} from "./config-manager"
|
||||
import { app, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { setUserLocale, USER_LOCALES, type UserLocale } from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||
import {
|
||||
@@ -23,37 +12,9 @@ import {
|
||||
import { isAppUrl } from "./window-manager"
|
||||
|
||||
/**
|
||||
* Allowed configuration keys for presets
|
||||
* This whitelist prevents arbitrary environment variable injection
|
||||
*/
|
||||
const ALLOWED_CONFIG_KEYS = new Set([
|
||||
"AI_PROVIDER",
|
||||
"AI_MODEL",
|
||||
"AI_API_KEY",
|
||||
"AI_BASE_URL",
|
||||
"TEMPERATURE",
|
||||
])
|
||||
|
||||
/**
|
||||
* Sanitize preset config to only include allowed keys
|
||||
*/
|
||||
function sanitizePresetConfig(
|
||||
config: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
const sanitized: Record<string, string | undefined> = {}
|
||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
||||
if (key in config && typeof config[key] === "string") {
|
||||
sanitized[key] = config[key]
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an IPC handler that only answers the app's own pages
|
||||
* (the main window on the app server, or the local settings page).
|
||||
* A main window that somehow ends up on an external site still gets the
|
||||
* preload API, so its calls must be rejected here.
|
||||
* Register an IPC handler that only answers the app's own pages (on the app
|
||||
* server). A main window that somehow ends up on an external site still
|
||||
* gets the preload API, so its calls must be rejected here.
|
||||
*/
|
||||
function handle<Args extends unknown[]>(
|
||||
channel: string,
|
||||
@@ -61,7 +22,7 @@ function handle<Args extends unknown[]>(
|
||||
): void {
|
||||
ipcMain.handle(channel, (event, ...args) => {
|
||||
const url = event.senderFrame?.url
|
||||
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
||||
if (!isAppUrl(url)) {
|
||||
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
||||
}
|
||||
return listener(event, ...(args as Args))
|
||||
@@ -72,12 +33,6 @@ function handle<Args extends unknown[]>(
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(): void {
|
||||
// ==================== App Info ====================
|
||||
|
||||
handle("get-version", () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
// ==================== Where the chats are ====================
|
||||
|
||||
// The page saved a chat, or loaded without any: decides which port
|
||||
@@ -87,116 +42,6 @@ export function registerIpcHandlers(): void {
|
||||
if (count === 0) noteNoChats()
|
||||
})
|
||||
|
||||
// ==================== Window Controls ====================
|
||||
|
||||
ipcMain.on("window-minimize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.minimize()
|
||||
})
|
||||
|
||||
ipcMain.on("window-maximize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win?.isMaximized()) {
|
||||
win.unmaximize()
|
||||
} else {
|
||||
win?.maximize()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on("window-close", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.close()
|
||||
})
|
||||
|
||||
// ==================== Config Presets ====================
|
||||
|
||||
handle("config-presets:get-all", () => {
|
||||
return getAllPresets()
|
||||
})
|
||||
|
||||
handle("config-presets:get-current", () => {
|
||||
return getCurrentPreset()
|
||||
})
|
||||
|
||||
handle("config-presets:get-current-id", () => {
|
||||
return getCurrentPresetId()
|
||||
})
|
||||
|
||||
handle(
|
||||
"config-presets:save",
|
||||
async (
|
||||
_event,
|
||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||
id?: string
|
||||
},
|
||||
) => {
|
||||
// Validate preset name
|
||||
if (typeof preset?.name !== "string" || !preset.name.trim()) {
|
||||
throw new Error("Invalid preset name")
|
||||
}
|
||||
|
||||
// Sanitize config to only allow whitelisted keys
|
||||
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
|
||||
|
||||
if (preset.id) {
|
||||
// Update existing preset
|
||||
const updated = updatePreset(preset.id, {
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
// Re-apply the active preset so the edit takes effect
|
||||
if (updated && updated.id === getCurrentPresetId()) {
|
||||
await switchPreset(updated.id)
|
||||
} else {
|
||||
rebuildAppMenu()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
// Create new preset
|
||||
const created = createPreset({
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
rebuildAppMenu()
|
||||
return created
|
||||
},
|
||||
)
|
||||
|
||||
handle("config-presets:delete", async (_event, id: string) => {
|
||||
const wasCurrent = id === getCurrentPresetId()
|
||||
// Deleting the active preset also clears its env vars
|
||||
const deleted = deletePreset(id)
|
||||
rebuildAppMenu()
|
||||
|
||||
// Restart so the server stops using the deleted preset
|
||||
if (deleted && wasCurrent && app.isPackaged) {
|
||||
await restartNextServer()
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
|
||||
handle("config-presets:apply", async (_event, id: string) => {
|
||||
try {
|
||||
const env = await switchPreset(id)
|
||||
// In development mode, electron-dev.mjs restarts Next.js
|
||||
return app.isPackaged
|
||||
? { success: true, env }
|
||||
: { success: true, env, devMode: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to restart server",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
handle("config-presets:set-current", (_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
})
|
||||
|
||||
// ==================== Proxy Settings ====================
|
||||
|
||||
handle("get-proxy", () => {
|
||||
@@ -228,7 +73,7 @@ export function registerIpcHandlers(): void {
|
||||
if (!app.isPackaged) {
|
||||
// In development, env vars are already applied
|
||||
// Next.js dev server may need manual restart
|
||||
return { success: true, devMode: true }
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Production: restart Next.js server to pick up new env vars
|
||||
@@ -247,20 +92,15 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// ==================== User Locale ====================
|
||||
|
||||
handle("get-user-locale", () => {
|
||||
return getUserLocale()
|
||||
})
|
||||
|
||||
handle("set-user-locale", (_event, locale: string) => {
|
||||
// Validate locale is one of the supported values
|
||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
||||
if (!(USER_LOCALES as readonly string[]).includes(locale)) {
|
||||
return { success: false, error: "Invalid locale" }
|
||||
}
|
||||
|
||||
try {
|
||||
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
||||
setUserLocale(locale as UserLocale)
|
||||
// Rebuild the menu to reflect the new locale
|
||||
rebuildAppMenu()
|
||||
buildAppMenu()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
+27
-113
@@ -3,71 +3,35 @@
|
||||
* Translations for menu labels that don't use Electron's built-in roles
|
||||
*/
|
||||
|
||||
import { getUserLocale } from "./config-manager"
|
||||
import { getUserLocale, type UserLocale } from "./config-manager"
|
||||
|
||||
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
||||
|
||||
export interface MenuTranslations {
|
||||
// App menu (macOS only)
|
||||
settings: string
|
||||
export type MenuLocale = UserLocale
|
||||
|
||||
const en = {
|
||||
// File menu
|
||||
file: string
|
||||
file: "File",
|
||||
|
||||
// Edit menu
|
||||
edit: string
|
||||
edit: "Edit",
|
||||
|
||||
// View menu
|
||||
view: string
|
||||
|
||||
// Configuration menu
|
||||
configuration: string
|
||||
switchPreset: string
|
||||
managePresets: string
|
||||
addConfigurationPreset: string
|
||||
view: "View",
|
||||
|
||||
// Window menu
|
||||
window: string
|
||||
window: "Window",
|
||||
|
||||
// Help menu
|
||||
help: string
|
||||
documentation: string
|
||||
reportIssue: string
|
||||
help: "Help",
|
||||
documentation: "Documentation",
|
||||
reportIssue: "Report Issue",
|
||||
}
|
||||
|
||||
type MenuTranslations = typeof en
|
||||
|
||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
en: {
|
||||
// App menu
|
||||
settings: "Settings...",
|
||||
|
||||
// File menu
|
||||
file: "File",
|
||||
|
||||
// Edit menu
|
||||
edit: "Edit",
|
||||
|
||||
// View menu
|
||||
view: "View",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "Configuration",
|
||||
switchPreset: "Switch Preset",
|
||||
managePresets: "Manage Presets...",
|
||||
addConfigurationPreset: "Add Configuration Preset...",
|
||||
|
||||
// Window menu
|
||||
window: "Window",
|
||||
|
||||
// Help menu
|
||||
help: "Help",
|
||||
documentation: "Documentation",
|
||||
reportIssue: "Report Issue",
|
||||
},
|
||||
en,
|
||||
|
||||
zh: {
|
||||
// App menu
|
||||
settings: "设置...",
|
||||
|
||||
// File menu
|
||||
file: "文件",
|
||||
|
||||
@@ -77,12 +41,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
// View menu
|
||||
view: "查看",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "配置",
|
||||
switchPreset: "切换预设",
|
||||
managePresets: "管理预设...",
|
||||
addConfigurationPreset: "添加配置预设...",
|
||||
|
||||
// Window menu
|
||||
window: "窗口",
|
||||
|
||||
@@ -93,9 +51,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
},
|
||||
|
||||
ja: {
|
||||
// App menu
|
||||
settings: "設定...",
|
||||
|
||||
// File menu
|
||||
file: "ファイル",
|
||||
|
||||
@@ -105,12 +60,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
// View menu
|
||||
view: "表示",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "設定",
|
||||
switchPreset: "プリセット切り替え",
|
||||
managePresets: "プリセット管理...",
|
||||
addConfigurationPreset: "設定プリセットを追加...",
|
||||
|
||||
// Window menu
|
||||
window: "ウインドウ",
|
||||
|
||||
@@ -121,9 +70,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
},
|
||||
|
||||
"zh-Hant": {
|
||||
// App menu
|
||||
settings: "設定...",
|
||||
|
||||
// File menu
|
||||
file: "檔案",
|
||||
|
||||
@@ -133,12 +79,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
// View menu
|
||||
view: "檢視",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "配置",
|
||||
switchPreset: "切換預設",
|
||||
managePresets: "管理預設...",
|
||||
addConfigurationPreset: "新增配置預設...",
|
||||
|
||||
// Window menu
|
||||
window: "視窗",
|
||||
|
||||
@@ -150,33 +90,22 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get menu translations for a given locale
|
||||
* Falls back to English if locale is not supported
|
||||
* Get menu translations for a locale (English for an unknown one)
|
||||
*/
|
||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
||||
// Check for zh-Hant before normalizing
|
||||
if (
|
||||
locale === "zh-Hant" ||
|
||||
locale.toLowerCase().startsWith("zh-hant") ||
|
||||
locale.toLowerCase().startsWith("zh-tw") ||
|
||||
locale.toLowerCase().startsWith("zh-hk")
|
||||
) {
|
||||
return translations["zh-Hant"]
|
||||
}
|
||||
|
||||
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
||||
const normalized = locale.toLowerCase().split("-")[0]
|
||||
|
||||
if (normalized === "zh") return translations.zh
|
||||
if (normalized === "ja") return translations.ja
|
||||
return translations.en
|
||||
export function getMenuTranslations(locale: MenuLocale): MenuTranslations {
|
||||
return translations[locale] ?? translations.en
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect system locale from Electron app
|
||||
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
||||
* Get locale from stored preference or system default
|
||||
* Checks config file for user's language preference first
|
||||
*/
|
||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
||||
const savedLocale = getUserLocale()
|
||||
if (savedLocale) {
|
||||
return savedLocale
|
||||
}
|
||||
|
||||
const lower = appLocale.toLowerCase()
|
||||
|
||||
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
||||
@@ -188,24 +117,9 @@ export function detectSystemLocale(appLocale: string): MenuLocale {
|
||||
return "zh-Hant"
|
||||
}
|
||||
|
||||
const normalized = lower.split("-")[0]
|
||||
const language = lower.split("-")[0]
|
||||
|
||||
if (normalized === "zh") return "zh"
|
||||
if (normalized === "ja") return "ja"
|
||||
if (language === "zh") return "zh"
|
||||
if (language === "ja") return "ja"
|
||||
return "en"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale from stored preference or system default
|
||||
* Checks config file for user's language preference first
|
||||
*/
|
||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
||||
// Try to get from saved preference first
|
||||
const savedLocale = getUserLocale()
|
||||
if (savedLocale) {
|
||||
return savedLocale
|
||||
}
|
||||
|
||||
// Fall back to system locale
|
||||
return detectSystemLocale(appLocale)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { setAppUrl } from "./window-manager"
|
||||
let serverProcess: UtilityProcess | null = null
|
||||
|
||||
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
||||
// preset switches) can't leave two servers running
|
||||
// proxy changes) can't leave two servers running
|
||||
let serverQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||
@@ -21,18 +21,6 @@ function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the standalone server resources
|
||||
* In packaged app: resources/standalone
|
||||
* In development: .next/standalone
|
||||
*/
|
||||
function getResourcePath(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "standalone")
|
||||
}
|
||||
return path.join(app.getAppPath(), ".next", "standalone")
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the server to be ready by polling the health endpoint
|
||||
*/
|
||||
@@ -61,7 +49,8 @@ export function startNextServer(): Promise<string> {
|
||||
}
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const resourcePath = getResourcePath()
|
||||
// The standalone server shipped in the packaged app's resources
|
||||
const resourcePath = path.join(process.resourcesPath, "standalone")
|
||||
const serverPath = path.join(resourcePath, "server.js")
|
||||
|
||||
console.log(`Starting Next.js server from: ${resourcePath}`)
|
||||
@@ -75,7 +64,7 @@ async function startServer(): Promise<string> {
|
||||
)
|
||||
}
|
||||
|
||||
// Find an available port (random in production, fixed in development)
|
||||
// Find an available port (the fixed ports 61337 / 13370, then fallbacks)
|
||||
const port = await findAvailablePort()
|
||||
console.log(`Using port: ${port}`)
|
||||
|
||||
@@ -86,7 +75,7 @@ async function startServer(): Promise<string> {
|
||||
HOSTNAME: "127.0.0.1",
|
||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
// The preset keys are the user's own, not a server's
|
||||
// The local server's env keys are the user's own, not a shared server's
|
||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||
}
|
||||
|
||||
@@ -97,10 +86,7 @@ async function startServer(): Promise<string> {
|
||||
|
||||
// Set cache directory to a writable location (user's app data folder)
|
||||
// This is necessary because the packaged app might be on a read-only volume
|
||||
if (app.isPackaged) {
|
||||
const cacheDir = path.join(app.getPath("userData"), "cache")
|
||||
env.NEXT_CACHE_DIR = cacheDir
|
||||
}
|
||||
env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache")
|
||||
|
||||
// Copy existing environment variables
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
|
||||
@@ -9,8 +9,6 @@ import { app } from "electron"
|
||||
* (localStorage is origin-specific, so changing ports loses all saved data)
|
||||
*/
|
||||
const PORT_CONFIG = {
|
||||
// Development mode uses fixed port for hot reload compatibility
|
||||
development: 6002,
|
||||
// Legacy production port — tried first to preserve localStorage for existing users
|
||||
legacyProduction: 61337,
|
||||
// New production port below the ephemeral range (49152-65535)
|
||||
@@ -108,9 +106,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses the legacy port (61337), then 13370, to preserve
|
||||
* localStorage; 13370 first when only it has saved chats
|
||||
* - Uses the legacy port (61337), then 13370, to preserve localStorage;
|
||||
* 13370 first when only it has saved chats
|
||||
* - Falls back to sequential ports if preferred port is unavailable
|
||||
* - Last resort: lets the OS assign a port (port 0)
|
||||
*
|
||||
@@ -118,10 +115,7 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
* @returns Promise<number> The available port
|
||||
*/
|
||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
const isDev = !app.isPackaged
|
||||
const preferredPort = isDev
|
||||
? PORT_CONFIG.development
|
||||
: PORT_CONFIG.production
|
||||
const preferredPort = PORT_CONFIG.production
|
||||
|
||||
// Try to reuse cached port if requested and available
|
||||
if (reuseExisting && allocatedPort !== null) {
|
||||
@@ -135,19 +129,18 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// In production, first the port where a chat was last saved. Without
|
||||
// one, the legacy port first to preserve existing users' data, unless
|
||||
// only the new port has data: their app started on 13370 while Windows
|
||||
// reserved 61337, and 61337 being free now would hide it
|
||||
const chatPort = isDev ? null : readChatPort()
|
||||
const candidates = isDev
|
||||
? [preferredPort]
|
||||
: chatPort !== null
|
||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||
: hasStoredData(PORT_CONFIG.production) &&
|
||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
// First the port where a chat was last saved. Without one, the legacy
|
||||
// port first to preserve existing users' data, unless only the new port
|
||||
// has data: their app started on 13370 while Windows reserved 61337,
|
||||
// and 61337 being free now would hide it
|
||||
const chatPort = readChatPort()
|
||||
const candidates =
|
||||
chatPort !== null
|
||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||
: hasStoredData(PORT_CONFIG.production) &&
|
||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
for (const port of candidates) {
|
||||
if (await isPortAvailable(port)) {
|
||||
allocatedPort = port
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import path from "node:path"
|
||||
import { app, BrowserWindow, ipcMain } from "electron"
|
||||
|
||||
let settingsWindow: BrowserWindow | null = null
|
||||
|
||||
/**
|
||||
* Create and show the settings window
|
||||
*/
|
||||
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
|
||||
// If settings window already exists, focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Determine path to settings preload script
|
||||
// In compiled output: dist-electron/preload/settings.js
|
||||
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
|
||||
|
||||
// Determine path to settings HTML
|
||||
// In packaged app: app.asar/dist-electron/settings/index.html
|
||||
// In development: electron/settings/index.html
|
||||
const settingsHtmlPath = app.isPackaged
|
||||
? path.join(__dirname, "..", "settings", "index.html")
|
||||
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
|
||||
|
||||
settingsWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 700,
|
||||
minWidth: 500,
|
||||
minHeight: 500,
|
||||
parent: parentWindow,
|
||||
modal: false,
|
||||
show: false,
|
||||
title: "Settings - Next AI Draw.io",
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
settingsWindow.loadFile(settingsHtmlPath)
|
||||
|
||||
settingsWindow.once("ready-to-show", () => {
|
||||
settingsWindow?.show()
|
||||
})
|
||||
|
||||
settingsWindow.on("closed", () => {
|
||||
settingsWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the settings window if it exists
|
||||
*/
|
||||
export function closeSettingsWindow(): void {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.close()
|
||||
settingsWindow = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if settings window is open
|
||||
*/
|
||||
export function isSettingsWindowOpen(): boolean {
|
||||
return settingsWindow !== null && !settingsWindow.isDestroyed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings window IPC handlers
|
||||
*/
|
||||
export function registerSettingsWindowHandlers(): void {
|
||||
ipcMain.on("settings:close", () => {
|
||||
closeSettingsWindow()
|
||||
})
|
||||
}
|
||||
@@ -107,9 +107,9 @@ export function getAppUrl(): string | null {
|
||||
|
||||
/**
|
||||
* Point the main window at the restarted app server (it can come up on a
|
||||
* different port). On the same port the page fetches the new preset's
|
||||
* server models instead of sending the old preset's choice; it is not
|
||||
* reloaded, which would drop unsent attachments.
|
||||
* different port). On the same port the page is told so it can fetch the
|
||||
* server's models again; it is not reloaded, which would drop unsent
|
||||
* attachments.
|
||||
*/
|
||||
export function setAppUrl(url: string): void {
|
||||
if (url === appUrl) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user