Compare commits

...
Author SHA1 Message Date
dayuan.jiang 92e785bcb5 fix(chat): use the AI SDK's own mechanisms for quota, retries, stops and notices
Server
- Record quota tokens in onStepFinish and await the write, so a step that
  fails or is stopped later still counts; drop the onFinish/onAbort copies
- validate-diagram and the connection test end their model call with the
  request (req.signal) and use the SDK's timeout option; validate-diagram
  reports to Langfuse (functionId "validate-diagram")
- The connection test no longer counts an invalid tool call as support
- Validate incoming messages with safeValidateUIMessages (400 otherwise),
  drop client system messages explicitly, prune empty model messages with
  pruneMessages
- Chat tools move to lib/chat-tools.ts with an output schema for the
  client-side tools

Client
- Every automatic retry sends the canvas as it is then
  (prepareSendMessagesRequest): a drawing is committed before its
  screenshot check can fail, so the model must go on from it
- A notice when the output budget ran out before any tool call
  (finishReason "length")
- Error notices are assistant messages with a data-notice part instead of
  role "system" messages (lib/chat-message.ts: ChatUIMessage with metadata,
  data part and InferUITools types)
- regenerate, edit and retry use sendMessage({ messageId }); isResending
  and flushSync go
- isToolUIPart/getToolName instead of slicing "tool-"; ToolPartLike derives
  from the SDK's part types; addToolOutput typed with ChatAddToolOutputFunction

Tests: step failure after a finished step, abort of the screenshot check,
invalid tool call in the connection test, malformed messages.
2026-10-12 12:36:45 +09:00
Dayuan Jiang 60105ddb51 refactor: remove dead code and over-engineering found by two audits (#986)
## Problem

Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.

## Change

Every item below was either approved one by one or covered by "fix all of the others".

- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.

Source lines: 74,983 before, 67,418 after (excluding lockfiles).

The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).

## Testing

- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
2026-10-12 12:35:55 +09:00
233 changed files with 6530 additions and 19315 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
-42
View File
@@ -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
-3
View File
@@ -4,14 +4,11 @@ on:
push: push:
branches: branches:
- main - main
- master
- dev
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
branches: branches:
- main - main
- master
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
+12 -2
View File
@@ -6,6 +6,13 @@ on:
push: push:
branches: [main] branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
lint-and-unit: lint-and-unit:
name: Lint & Unit Tests name: Lint & Unit Tests
@@ -16,12 +23,15 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Run lint - name: Run lint
run: npm run check run: npm run check
@@ -141,7 +151,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
-336
View File
@@ -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&apos;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>
)
}
-350
View File
@@ -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&apos;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
View File
@@ -1,23 +1,420 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import Link from "next/link" 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 Image from "@/components/image-with-basepath"
import type { Locale } from "@/lib/i18n/config"
import { hasLocale } from "@/lib/i18n/dictionaries"
export const metadata: Metadata = { type ExampleId = "resnet50" | "rag" | "auth" | "scrum" | "inno"
title: "About - Next AI Draw.io",
description: type AboutTexts = {
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.", title: string
keywords: [ description: string
"AI diagram", keywords: string[]
"draw.io", nav: { editor: string; about: string; github: string }
"AWS architecture", tagline: string
"GCP diagram", byok: { title: string; body: string; note: string }
"Azure diagram", intro: string
"LLM", // 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&apos;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 ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* 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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link <Link
href="/" href={`/${lang}`}
className="text-xl font-bold text-gray-900 hover:text-gray-700" className="text-xl font-bold text-gray-900 hover:text-gray-700"
> >
Next AI Draw.io Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link <Link
href="/" href={`/${lang}`}
className="text-gray-600 hover:text-gray-900 transition-colors" className="text-gray-600 hover:text-gray-900 transition-colors"
> >
Editor {t.nav.editor}
</Link> </Link>
<Link <Link
href="/about" href={`/${lang}/about`}
className="text-blue-600 font-semibold" className="text-blue-600 font-semibold"
> >
About {t.nav.about}
</Link> </Link>
<a <a
href="https://github.com/DayuanJiang/next-ai-draw-io" href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors" 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> </a>
</nav> </nav>
</div> </div>
@@ -66,8 +463,7 @@ export default function About() {
Next AI Draw.io Next AI Draw.io
</h1> </h1>
<p className="text-xl text-gray-600 font-medium"> <p className="text-xl text-gray-600 font-medium">
AI-Powered Diagram Creation Tool - Chat, Draw, {t.tagline}
Visualize
</p> </p>
</div> </div>
@@ -77,92 +473,53 @@ export default function About() {
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
Bring Your Own API Key {t.byok.title}
</h4> </h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto"> <p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
You can also use your own API key with any {t.byok.body}
supported provider. Click the Settings icon
in the chat panel to configure your provider
and API key.
</p> </p>
<p className="text-xs text-gray-500 max-w-md mx-auto"> <p className="text-xs text-gray-500 max-w-md mx-auto">
Your key is stored locally in your browser {t.byok.note}
and is never stored on the server.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">{t.intro}</p>
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>
{/* Features */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Features {t.featuresHeading}
</h2> </h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li> {t.features.map((feature) => (
<strong>LLM-Powered Diagram Creation</strong>: <li key={feature.title}>
Leverage Large Language Models to create and <strong>{feature.title}</strong>
manipulate draw.io diagrams directly through natural {t.colon}
language commands {feature.text}
</li> </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>
</ul> </ul>
{/* Examples */} {/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Examples {t.examplesHeading}
</h2> </h2>
<p className="text-gray-700 mb-6"> <p className="text-gray-700 mb-6">{t.examplesIntro}</p>
Here are some example prompts and their generated
diagrams:
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* ResNet50 Architecture */} {/* First example on its own */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Animated ResNet50 Model Architecture {t.examples[hero.id].title}
</h3> </h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "} <strong>Prompt:</strong> {hero.prompt}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block"> <div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image <Image
src="/resnet50.svg" src={hero.src}
alt="Architecture diagram for ResNet50 model" alt={t.examples[hero.id].alt}
width={480} width={480}
height={360} height={360}
className="mx-auto" className="mx-auto"
@@ -172,125 +529,67 @@ export default function About() {
{/* Diagram Grid */} {/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> {grid.map((example) => (
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <div key={example.id} className="text-center">
RAG Technique Diagram <h3 className="text-lg font-semibold text-gray-900 mb-2">
</h3> {t.examples[example.id].title}
<p className="text-gray-600 text-sm mb-4"> </h3>
<strong>Prompt:</strong> Generate a RAG <p className="text-gray-600 text-sm mb-4">
architecture diagram for{" "} <strong>Prompt:</strong>{" "}
<strong>chat application</strong>. Use {example.prompt}
connected diagram for data ingestion </p>
</p> <div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]"> <Image
<Image src={example.src}
src="/rag_prod.svg" alt={t.examples[example.id].alt}
alt="RAG Architecture Diagram" width={480}
width={480} height={360}
height={360} className="max-w-full max-h-full object-contain"
className="max-w-full max-h-full object-contain" />
/> </div>
</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&apos;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>
</div> </div>
{/* How It Works */} {/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
How It Works {t.howHeading}
</h2> </h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">{t.howIntro}</p>
The application uses the following technologies:
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li> <li>
<strong>Next.js</strong>: For the frontend framework <strong>Next.js</strong>
and routing {t.colon}
{t.tech.nextjs}
</li> </li>
<li> <li>
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "} <strong>Vercel AI SDK</strong>
<code>@ai-sdk/*</code>): For streaming AI responses {t.parenOpen}
and multi-provider support <code>ai</code> + <code>@ai-sdk/*</code>
{t.parenClose}
{t.colon}
{t.tech.aiSdk}
</li> </li>
<li> <li>
<strong>draw.io</strong>: The editor, bundled with <strong>draw.io</strong>
the app so it can be driven directly {t.colon}
{t.tech.drawio}
</li> </li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">{t.howOutro}</p>
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>
{/* Multi-Provider Support */} {/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Multi-Provider Support {t.providersHeading}
</h2> </h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>ByteDance Doubao</li> <li>{t.doubao}</li>
<li>AWS Bedrock (default)</li> <li>{t.bedrock}</li>
<li> <li>
OpenAI / OpenAI-compatible APIs (via{" "} {t.openai.before}
<code>OPENAI_BASE_URL</code>) <code>OPENAI_BASE_URL</code>
{t.openai.after}
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
@@ -303,48 +602,47 @@ export default function About() {
<li>ModelScope</li> <li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on {t.modelNote.before}
draw.io diagrams with AWS logos, so if you want to <code>claude-sonnet-4-5</code>
create AWS architecture diagrams, this is the best {t.modelNote.after}
choice.
</p> </p>
{/* Support */} {/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Support &amp; Contact {t.supportHeading}
</h2> </h2>
<p className="text-gray-700"> <p className="text-gray-700">
If you find this project useful, please consider{" "} {t.sponsor.before}{" "}
<a <a
href="https://github.com/sponsors/DayuanJiang" href="https://github.com/sponsors/DayuanJiang"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
> >
sponsoring {t.sponsor.link}
</a>{" "} </a>{" "}
to help host the live demo site! {t.sponsor.after}
</p> </p>
<p className="text-gray-700 mt-2"> <p className="text-gray-700 mt-2">
For support or inquiries, please open an issue on the{" "} {t.contact.before}{" "}
<a <a
href="https://github.com/DayuanJiang/next-ai-draw-io" href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
> >
GitHub repository {t.contact.link}
</a>{" "} </a>{" "}
or contact: me[at]jiang.jp {t.contact.after}
</p> </p>
{/* CTA */} {/* CTA */}
<div className="mt-12 text-center"> <div className="mt-12 text-center">
<Link <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" 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> </Link>
</div> </div>
</article> </article>
@@ -354,8 +652,7 @@ export default function About() {
<footer className="bg-white border-t border-gray-200 mt-16"> <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"> <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"> <p className="text-center text-gray-600 text-sm">
Next AI Draw.io - Open Source AI-Powered Diagram {t.footer}
Generator
</p> </p>
</div> </div>
</footer> </footer>
+6 -17
View File
@@ -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 { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config" 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 ───────────────────────────────────────────────────── // ── 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 { export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v 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 ?? "") : "" 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 // Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider { export interface EnvProvider {
name: string name: string
-27
View File
@@ -3,13 +3,6 @@ import { useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry" import type { SettingDef } from "@/lib/admin/settings-registry"
@@ -206,26 +199,6 @@ export function SettingField({
) )
break 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": case "secret":
control = ( control = (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
+13 -16
View File
@@ -41,6 +41,13 @@ export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale })) 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 // Generate metadata per locale
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -48,22 +55,19 @@ export async function generateMetadata({
params: Promise<{ lang: string }> params: Promise<{ lang: string }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { lang: rawLang } = await params const { lang: rawLang } = await params
const lang = ( const lang: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
// Default to English metadata
const titles: Record<Locale, string> = { const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator", en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator", zh: "Next AI Draw.io - AI 驱动的图表生成器",
ja: "Next AI Draw.io - AI-powered diagram generator", ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器", "zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
} }
const descriptions: Record<Locale, string> = { 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.", 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.", zh: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
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.", ja: "AI で AWS アーキテクチャ図、フローチャート、技術図を作成。draw.io と AI アシスタントを統合した無料オンラインツールで、プロ品質の図を簡単に作成できます。",
"zh-Hant": "zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。", "使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
} }
@@ -92,14 +96,7 @@ export async function generateMetadata({
type: "website", type: "website",
url: "https://next-ai-drawio.jiang.jp", url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io", siteName: "Next AI Draw.io",
locale: locale: OG_LOCALE[lang],
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
images: [ images: [
{ {
url: "/architecture.png", url: "/architecture.png",
-4
View File
@@ -53,10 +53,6 @@ function validateValue(def: SettingDef, value: string): string | null {
return value === "true" || value === "false" return value === "true" || value === "false"
? null ? null
: 'Must be "true" or "false"' : 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default: default:
return null return null
} }
+19 -32
View File
@@ -1,4 +1,3 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth" import { checkAdminAuth } from "@/lib/admin/auth"
import { import {
AdminProviderSchema, AdminProviderSchema,
@@ -6,6 +5,7 @@ import {
mergeSecrets, mergeSecrets,
} from "@/lib/admin/providers" } from "@/lib/admin/providers"
import { globalBaseUrl } from "@/lib/ai-providers" import { globalBaseUrl } from "@/lib/ai-providers"
import { testModelConnection } from "@/lib/test-model-connection"
export const runtime = "nodejs" export const runtime = "nodejs"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
sameEndpoint && stored ? [stored] : [], 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) const serverUrl = globalBaseUrl(resolved.provider)
return validateModel( return testModelConnection(
new Request(new URL("/api/validate-model", req.url), { req,
method: "POST", {
headers: { provider: resolved.provider,
"Content-Type": "application/json", apiKey: resolved.apiKey,
// Checked again there, in place of an access code baseUrl: resolved.baseUrl || serverUrl,
"x-admin-password": req.headers.get("x-admin-password") || "", modelId: body.modelId,
// The EdgeOne function checks the access code and Pages awsAccessKeyId: resolved.awsAccessKeyId,
// cookies, and its URL is built from the page's origin awsSecretAccessKey: resolved.awsSecretAccessKey,
"x-access-code": req.headers.get("x-access-code") || "", awsRegion: resolved.awsRegion,
cookie: req.headers.get("cookie") || "", vertexApiKey: resolved.vertexApiKey,
...(req.headers.get("origin") && { },
origin: req.headers.get("origin") as string, !resolved.baseUrl && !!serverUrl,
}),
},
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,
}),
}),
) )
} }
+76 -318
View File
@@ -1,15 +1,15 @@
import { import {
APICallError, APICallError,
convertToModelMessages, convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError, InvalidToolInputError,
isFileUIPart,
isTextUIPart,
pruneMessages,
safeValidateUIMessages,
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai" } from "ai"
import { jsonrepair } from "jsonrepair" import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { import {
CACHE_POINT, CACHE_POINT,
@@ -21,14 +21,14 @@ import {
usesServerCredentials, usesServerCredentials,
usesServerEndpoint, usesServerEndpoint,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import { import {
dropInvalidToolCalls, dropInvalidToolCalls,
fixToolInputJson, fixToolInputJson,
formatSelectionContext, formatSelectionContext,
replaceHistoricalToolInputs,
validateFileParts, validateFileParts,
} from "@/lib/chat-helpers" } from "@/lib/chat-helpers"
import type { ChatUIMessage } from "@/lib/chat-message"
import { chatTools } from "@/lib/chat-tools"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params" import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
import { import {
checkAndIncrementRequest, checkAndIncrementRequest,
@@ -56,50 +56,11 @@ import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config" import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts" 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 // 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, // 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. // 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 // Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>() const modelStreamResponses = new WeakSet<Response>()
// A Request's signal follows the client's disconnect only while the Request // 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 // Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true" 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> { async function handleChatRequest(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req) const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite if (crossSite) return crossSite
@@ -119,7 +95,19 @@ async function handleChatRequest(req: Request): Promise<Response> {
if (accessDenied) return accessDenied if (accessDenied) return accessDenied
const body = await req.json() 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 = const customSystemMessage =
typeof body.customSystemMessage === "string" typeof body.customSystemMessage === "string"
? body.customSystemMessage.slice(0, 5000) ? 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) // Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
const lastUserMessage = [...messages] const lastUserMessage = [...messages]
.reverse() .reverse()
.find((m: any) => m.role === "user") .find((m) => m.role === "user")
const userInputText = const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || ""
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user // Update Langfuse trace with input, session, and user
setTraceInput({ setTraceInput({
@@ -156,23 +143,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
} }
// === FILE VALIDATION END === // === 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 // Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider") const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url") let baseUrl = req.headers.get("x-ai-base-url")
@@ -348,16 +318,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`, `[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 systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}` ? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
: systemMessage : systemMessage
// Extract file parts (images) from the last user message // Extract file parts (images) from the last user message
const fileParts = const fileParts = lastUserMessage?.parts.filter(isFileUIPart) ?? []
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Note: we used to pre-emptively reject images for models we guessed were // Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models // text-only (by name matching). That heuristic misfired on newer models
@@ -370,98 +338,37 @@ async function handleChatRequest(req: Request): Promise<Response> {
${userInputText} ${userInputText}
"""` """`
// Convert UIMessages to ModelMessages and add system message. A tool // Convert UIMessages to ModelMessages. A tool call that never got its
// call that never got its result (the user stopped while it ran) is // result (the user stopped while it ran) is left out: the SDK would
// left out: the SDK would refuse this and every later request of the // refuse this and every later request of the chat
// chat (MissingToolResultsError) // (MissingToolResultsError). Messages left without content (a notice
const modelMessages = await convertToModelMessages(messages, { // the chat shows is a data part, which the model never sees) are
ignoreIncompleteToolCalls: true, // dropped: Bedrock rejects empty content.
const modelMessages = pruneMessages({
messages: await convertToModelMessages(messages, {
ignoreIncompleteToolCalls: true,
}),
}) })
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure // DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if (DEBUG_LLM_PAYLOAD) { if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Incoming messages count:", messages.length) console.log(
messages.forEach((msg: any, idx: number) => { "[route.ts] incoming",
console.log( JSON.stringify(describeMessages(messages)),
`[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,
})
}
})
}
})
} }
// 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) // 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 // 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. // 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 // DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) { if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Model messages count:", enhancedMessages.length) console.log(
enhancedMessages.forEach((msg: any, idx: number) => { "[route.ts] to model",
console.log( JSON.stringify(describeMessages(enhancedMessages)),
`[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",
})
}
})
}
})
} }
// Update the last message with user input only (XML moved to separate cached system message) // Update the last message with user input only (XML moved to separate cached system message)
@@ -566,13 +473,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const allMessages = [...systemMessages, ...enhancedMessages] const allMessages = [...systemMessages, ...enhancedMessages]
// Set by onAbort, which records the finished steps' tokens itself
let stopped = false
const result = streamText({ const result = streamText({
model, model,
// The system messages carry cache points, so they go in messages. // The system messages carry cache points, so they go in messages
// A client's own system messages have string content and were // (the client's own system messages were dropped above)
// dropped by the empty-content filter above.
allowSystemInMessages: true, allowSystemInMessages: true,
abortSignal: req.signal, abortSignal: req.signal,
// Must be sent: unset means the provider's own default, and Bedrock's is // Must be sent: unset means the provider's own default, and Bedrock's is
@@ -619,166 +523,31 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
}, },
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs ...(providerOptions && { providerOptions }), // This now includes all reasoning configs
// Langfuse telemetry config (returns undefined if not configured) // Langfuse telemetry (undefined when not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && { experimental_telemetry: getTelemetryConfig({
experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId,
sessionId: validSessionId, userId,
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 // AI SDK 6 telemetry auto-reports token usage on its spans
setTraceOutput(text) 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 // onFinish is skipped when the stream fails or is aborted, so end the trace here
onError: ({ error }) => { onError: ({ error }) => {
console.error(error) // what AI SDK does without an onError console.error(error) // what AI SDK does without an onError
endTrace() endTrace()
}, },
onAbort: ({ steps }) => { onAbort: () => endTrace(),
stopped = true tools: chatTools,
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: &lt; &gt; &amp; &quot;
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 &lt;br&gt; for a line break, never \\n; a literal < or > is written &amp;lt; or &amp;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
},
},
},
...(process.env.TEMPERATURE !== undefined && { ...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE), temperature: parseFloat(process.env.TEMPERATURE),
}), }),
@@ -790,17 +559,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 // Keyless endpoints keep theirs: the desktop app's Ollama is the
// user's own, and EdgeOne's text is our function's explanation. // user's own, and EdgeOne's text is our function's explanation.
onError: (error) => streamErrorText(error, onServerCredentials), 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) modelStreamResponses.add(response)
requestOfResponse.set(response, req) requestOfResponse.set(response, req)
@@ -840,7 +598,7 @@ async function safeHandler(req: Request): Promise<Response> {
} catch (error) { } catch (error) {
response = handleError(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 // so their Langfuse trace has to be ended here
if (!modelStreamResponses.has(response)) endTrace() if (!modelStreamResponses.has(response)) endTrace()
return response return response
-321
View File
@@ -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
View File
@@ -3,8 +3,5 @@ import { NextResponse } from "next/server"
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({
accessCodeRequired: !!process.env.ACCESS_CODE_LIST, 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,
}) })
} }
+16 -68
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "crypto"
import { z } from "zod" import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse" import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({ const feedbackSchema = z.object({
@@ -37,75 +36,24 @@ export async function POST(req: Request) {
const userId = getUserIdFromRequest(req) const userId = getUserIdFromRequest(req)
try { try {
// Find the most recent chat trace for this session to attach the score to // Score the session's latest chat trace; without one, a standalone
const tracesResponse = await langfuse.api.trace.list({ // feedback trace carries the score
await scoreLatestTrace(langfuse, {
sessionId, 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 }) return Response.json({ success: true, logged: true })
} catch (error) { } catch (error) {
console.error("Langfuse feedback error:", error) console.error("Langfuse feedback error:", error)
+8 -32
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "crypto"
import { z } from "zod" import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse" import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
const saveSchema = z.object({ const saveSchema = z.object({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
} }
try { try {
const timestamp = new Date().toISOString() // Flag the session's latest chat trace as saved; a session without a
// trace (no chat yet) is skipped
// Find the most recent chat trace for this session to attach the save flag const logged = await scoreLatestTrace(langfuse, {
const tracesResponse = await langfuse.api.trace.list({
sessionId, sessionId,
limit: 1, name: "diagram-saved",
value: 1,
comment: `User saved diagram as ${filename}.${format}`,
}) })
const traces = tracesResponse.data || [] return Response.json({ success: true, logged })
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 })
} catch (error) { } catch (error) {
console.error("Langfuse save error:", error) console.error("Langfuse save error:", error)
return Response.json( return Response.json(
+1 -4
View File
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
export async function GET() { export async function GET() {
const models = await loadFlattenedServerModels() const models = await loadFlattenedServerModels()
return NextResponse.json({ return NextResponse.json({ models })
models,
hasConfig: models.length > 0,
})
} }
+25 -31
View File
@@ -1,9 +1,9 @@
/** /**
* API endpoint for VLM-based diagram validation. * 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 { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers" import { getValidationModel } from "@/lib/ai-providers"
import { import {
@@ -11,6 +11,7 @@ import {
isQuotaEnabled, isQuotaEnabled,
recordTokenUsage, recordTokenUsage,
} from "@/lib/dynamo-quota-manager" } from "@/lib/dynamo-quota-manager"
import { getTelemetryConfig } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts" import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import { import {
@@ -35,13 +36,6 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [], 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> { export async function POST(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req) const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite if (crossSite) return crossSite
@@ -53,7 +47,7 @@ export async function POST(req: Request): Promise<Response> {
// Check if VLM validation is enabled (default: true) // Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false" const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
if (!enableValidation) { if (!enableValidation) {
return createStreamingResponse(DEFAULT_VALID_RESULT) return Response.json(DEFAULT_VALID_RESULT)
} }
const body: ValidateDiagramRequest = await req.json() const body: ValidateDiagramRequest = await req.json()
@@ -123,7 +117,7 @@ export async function POST(req: Request): Promise<Response> {
error, error,
) )
// Return valid if no vision model is configured // 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) // 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), parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000 ) || 10000
// Stream the VLM response for useObject consumption const { output, totalUsage } = await generateText({
const result = streamText({
model, model,
output: Output.object({ schema: ValidationResultSchema }), output: Output.object({ schema: ValidationResultSchema }),
system: VALIDATION_SYSTEM_PROMPT, system: VALIDATION_SYSTEM_PROMPT,
@@ -154,24 +147,25 @@ export async function POST(req: Request): Promise<Response> {
}, },
], ],
maxOutputTokens: 1024, maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout), // Ends with the request: the user pressed Stop, or left
onFinish: ({ output, totalUsage }) => { abortSignal: req.signal,
if (countsQuota && totalUsage) { timeout,
recordTokenUsage( // Its usage and cost show in Langfuse next to the chat's
userId, experimental_telemetry: getTelemetryConfig({
(totalUsage.inputTokens || 0) + functionId: "validate-diagram",
(totalUsage.outputTokens || 0), sessionId,
) userId,
} }),
if (sessionId && output) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
)
}
},
}) })
if (countsQuota) {
return result.toTextStreamResponse() 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) { } catch (error) {
// Log with session context if available // Log with session context if available
const errorMessage = const errorMessage =
@@ -179,6 +173,6 @@ export async function POST(req: Request): Promise<Response> {
console.error("[validate-diagram] Error:", errorMessage) console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user // On error, return valid to not block the user
return createStreamingResponse(DEFAULT_VALID_RESULT) return Response.json(DEFAULT_VALID_RESULT)
} }
} }
+13 -233
View File
@@ -1,249 +1,29 @@
import { streamText, tool } from "ai"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import { import {
edgeOneEndpoint, type TestModelRequest,
getAIModel, testModelConnection,
globalBaseUrl, } from "@/lib/test-model-connection"
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"
export const runtime = "nodejs" export const runtime = "nodejs"
interface ValidateRequest { // The Settings dialog's Test button: a model on the user's own credentials
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."
export async function POST(req: Request) { export async function POST(req: Request) {
const crossSite = rejectCrossSite(req) const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite if (crossSite) return crossSite
// Lets the server send requests to arbitrary URLs, so require the access // Lets the server send requests to arbitrary URLs, so require the access code
// code, or the admin password (the admin panel's Test button)
const accessError = checkAccessCode(req) const accessError = checkAccessCode(req)
if (accessError && checkAdminAuth(req)) return accessError if (accessError) return accessError
let body: TestModelRequest
try { try {
const body: ValidateRequest = await req.json() body = await req.json()
const { } catch {
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)
return NextResponse.json( return NextResponse.json(
{ valid: false, code, error: message }, { valid: false, error: "Invalid JSON body" },
{ status: 200 }, // Return 200 so client can read error message { status: 400 },
) )
} }
// A URL in the request is one the user typed, never the server's own
return testModelConnection(req, body, false)
} }
+9 -22
View File
@@ -98,9 +98,7 @@
--shadow-pop: --shadow-pop:
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22); 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-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 { .shadow-pop {
box-shadow: var(--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 { @keyframes fadeIn {
@@ -284,17 +275,13 @@
} }
} }
@keyframes check-pop { /* A highlight sweeps across "Thinking..." text (Shimmer) */
0% { @keyframes shimmer {
transform: scale(0.8); from {
opacity: 0; background-position: 100% center;
} }
50% { to {
transform: scale(1.1); background-position: 0% center;
}
100% {
transform: scale(1);
opacity: 1;
} }
} }
@@ -307,15 +294,15 @@
.animate-panel-in { .animate-panel-in {
animation: panelIn 0.32s var(--ease-out) both; animation: panelIn 0.32s var(--ease-out) both;
} }
.animate-check-pop { .animate-shimmer {
animation: check-pop 0.25s var(--ease-spring) forwards; animation: shimmer var(--shimmer-duration, 2s) linear infinite;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-fade-in, .animate-fade-in,
.animate-message-in, .animate-message-in,
.animate-panel-in, .animate-panel-in,
.animate-check-pop { .animate-shimmer {
animation: none; animation: none;
} }
} }
-238
View File
@@ -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>
)
+59 -162
View File
@@ -1,186 +1,83 @@
"use client" "use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { BrainIcon, ChevronDownIcon } from "lucide-react" import { BrainIcon, ChevronDownIcon } from "lucide-react"
import type { ComponentProps, ReactNode } from "react" import type { ReactNode } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react" import { useEffect, useState } from "react"
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible" } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Shimmer } from "./shimmer"
type ReasoningContextValue = { export type ReasoningProps = {
isStreaming: boolean className?: string
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> & {
isStreaming?: boolean isStreaming?: boolean
open?: boolean /** Open while streaming, then folds once about a second after it ends */
defaultOpen?: boolean defaultOpen?: boolean
onOpenChange?: (open: boolean) => void getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
duration?: number children: string
} }
const AUTO_CLOSE_DELAY = 1000 const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000 const MS_IN_S = 1000
export const Reasoning = memo( export function Reasoning({
({ className,
className, isStreaming = false,
isStreaming = false, defaultOpen = true,
open, getThinkingMessage,
defaultOpen = true, children,
onOpenChange, }: ReasoningProps) {
duration: durationProp, const [isOpen, setIsOpen] = useState(defaultOpen)
children, const [duration, setDuration] = useState<number>()
...props const [hasAutoClosed, setHasAutoClosed] = useState(false)
}: ReasoningProps) => { const [startTime, setStartTime] = useState<number | null>(null)
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
})
const [hasAutoClosed, setHasAutoClosed] = useState(false) // Track duration when streaming starts and ends
const [startTime, setStartTime] = useState<number | null>(null) useEffect(() => {
if (isStreaming) {
// Track duration when streaming starts and ends if (startTime === null) {
useEffect(() => { setStartTime(Date.now())
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now())
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
setStartTime(null)
} }
}, [isStreaming, startTime, setDuration]) } else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
// Auto-open when streaming starts, auto-close when streaming ends (once only) setStartTime(null)
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)
} }
}, [isStreaming, startTime])
return ( // Auto-close once when streaming ends, after a short delay so the user
<ReasoningContext.Provider // sees the content
value={{ isStreaming, isOpen, setIsOpen, duration }} useEffect(() => {
> if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
<Collapsible const timer = setTimeout(() => {
className={cn("not-prose mb-4", className)} setIsOpen(false)
onOpenChange={handleOpenChange} setHasAutoClosed(true)
open={isOpen} }, AUTO_CLOSE_DELAY)
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
)
},
)
export type ReasoningTriggerProps = ComponentProps< return () => clearTimeout(timer)
typeof CollapsibleTrigger }
> & { }, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
}
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { return (
if (isStreaming || duration === 0) { <Collapsible
return <Shimmer duration={1}>Thinking...</Shimmer> className={cn("not-prose mb-4", className)}
} onOpenChange={setIsOpen}
if (duration === undefined) { open={isOpen}
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}
> >
<div className="whitespace-pre-wrap">{children}</div> <CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
</CollapsibleContent> <BrainIcon className="size-4" />
), {getThinkingMessage(isStreaming, duration)}
) <ChevronDownIcon
className={cn(
Reasoning.displayName = "Reasoning" "size-4 transition-transform",
ReasoningTrigger.displayName = "ReasoningTrigger" isOpen ? "rotate-180" : "rotate-0",
ReasoningContent.displayName = "ReasoningContent" )}
/>
</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>
)
}
+5 -22
View File
@@ -1,13 +1,6 @@
"use client" "use client"
import { motion } from "motion/react" import { type CSSProperties, type ElementType, memo, useMemo } from "react"
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type TextShimmerProps = { export type TextShimmerProps = {
@@ -25,39 +18,29 @@ const ShimmerComponent = ({
duration = 2, duration = 2,
spread = 2, spread = 2,
}: TextShimmerProps) => { }: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements,
)
const dynamicSpread = useMemo( const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread, () => (children?.length ?? 0) * spread,
[children, spread], [children, spread],
) )
return ( return (
<MotionComponent <Component
animate={{ backgroundPosition: "0% center" }}
className={cn( 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]", "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className, className,
)} )}
initial={{ backgroundPosition: "100% center" }}
style={ style={
{ {
"--spread": `${dynamicSpread}px`, "--spread": `${dynamicSpread}px`,
"--shimmer-duration": `${duration}s`,
backgroundImage: backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))", "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties } as CSSProperties
} }
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
> >
{children} {children}
</MotionComponent> </Component>
) )
} }
-36
View File
@@ -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 -2
View File
@@ -155,8 +155,7 @@ export function CanvasStage({
key={src} key={src}
ref={drawioRef} ref={drawioRef}
src={src} src={src}
configCss={DRAWIO_CSS} css={DRAWIO_CSS}
editorCss={DRAWIO_CSS}
dark={isDark} dark={isDark}
onLoad={onDrawioLoad} onLoad={onDrawioLoad}
onAutoSave={handleDiagramAutoSave} onAutoSave={handleDiagramAutoSave}
+11 -21
View File
@@ -30,10 +30,11 @@ export interface DrawioFrameHandle {
interface DrawioFrameProps { interface DrawioFrameProps {
src: string src: string
/** CSS sent with the configure message (works cross-origin too) */ /**
configCss: string * Theme CSS: sent with the configure message (works cross-origin too)
/** CSS injected once the editor is reachable directly (same origin) */ * and, same origin, injected as a <style> so it updates without a reload
editorCss: string */
css: string
/** draw.io's dark mode, switched in place when the editor is reachable */ /** draw.io's dark mode, switched in place when the editor is reachable */
dark: boolean dark: boolean
onLoad?: () => void onLoad?: () => void
@@ -52,25 +53,14 @@ const STYLE_ID = "next-ai-drawio-theme"
*/ */
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>( export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
function DrawioFrame( function DrawioFrame(
{ { src, css, dark, onLoad, onAutoSave, onExport, onEditorAccess },
src,
configCss,
editorCss,
dark,
onLoad,
onAutoSave,
onExport,
onEditorAccess,
},
ref, ref,
) { ) {
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null)
const editorUiRef = useRef<any>(null) const editorUiRef = useRef<any>(null)
const attachedRef = useRef(false) const attachedRef = useRef(false)
const configCssRef = useRef(configCss) const cssRef = useRef(css)
configCssRef.current = configCss cssRef.current = css
const editorCssRef = useRef(editorCss)
editorCssRef.current = editorCss
const darkRef = useRef(dark) const darkRef = useRef(dark)
darkRef.current = dark darkRef.current = dark
// Handlers change on every render; the message listener reads refs // Handlers change on every render; the message listener reads refs
@@ -111,7 +101,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
style.id = STYLE_ID style.id = STYLE_ID
doc.head.appendChild(style) doc.head.appendChild(style)
} }
style.textContent = editorCssRef.current style.textContent = cssRef.current
} catch { } catch {
// Cross-origin: the configure message already applied it // Cross-origin: the configure message already applied it
} }
@@ -119,7 +109,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
useEffect(() => { useEffect(() => {
applyThemeCss() applyThemeCss()
}, [editorCss]) }, [css])
// Same-origin only: draw.io's own dark mode, without a reload // Same-origin only: draw.io's own dark mode, without a reload
const applyDarkMode = () => { const applyDarkMode = () => {
@@ -209,7 +199,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
send("configure", { send("configure", {
config: { config: {
...DRAWIO_CONFIG, ...DRAWIO_CONFIG,
css: configCssRef.current, css: cssRef.current,
}, },
}) })
break break
-206
View File
@@ -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" "use client"
import { Bookmark, Edit2 } from "lucide-react" import { Bookmark, Edit2, Plus } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useId, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -16,22 +16,32 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary" 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 open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
template: Template | null
onSuccess: () => void 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, open,
onOpenChange, onOpenChange,
template,
onSuccess, onSuccess,
}: TemplateEditDialogProps) { template,
initialPrompt = "",
}: TemplateFormDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const id = useId()
const isEdit = !!template
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("") const [prompt, setPrompt] = useState("")
@@ -39,32 +49,22 @@ export function TemplateEditDialog({
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) 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(() => { useEffect(() => {
if (open && template) { if (!open) return
setTitle(template.title || "") setTitle(template?.title || "")
setDescription(template.description || "") setDescription(template?.description || "")
setPrompt(template.prompt || "") setPrompt(template ? template.prompt || "" : initialPrompt)
setPinned(template.pinned || false) setPinned(template?.pinned || false)
setError(null) setError(null)
} }, [open, template, initialPrompt])
}, [open, template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null)
}
onOpenChange(newOpen)
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
// React submit events bubble through the portal; keep them away from // 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() e.stopPropagation()
if (!template) return
const trimmedPrompt = prompt.trim() const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) { if (!trimmedPrompt) {
setError(dict.templates.promptRequired) setError(dict.templates.promptRequired)
@@ -74,40 +74,59 @@ export function TemplateEditDialog({
setIsSubmitting(true) setIsSubmitting(true)
setError(null) setError(null)
const failed = isEdit
? dict.templates.updateFailed
: dict.templates.createFailed
try { try {
const updates: Partial<Omit<Template, "id" | "createdAt">> = { const fields = {
prompt: trimmedPrompt, prompt: trimmedPrompt,
title: title.trim() || template.title,
description: description.trim() || undefined, description: description.trim() || undefined,
pinned, pinned,
} }
// An emptied title keeps the old one when editing; a new template
const updated = await updateTemplate(template.id, updates) // gets a default title from its prompt
if (updated) { const result = template
? await updateTemplate(template.id, {
...fields,
title: title.trim() || template.title,
})
: await createTemplate({
...fields,
title: title.trim() || undefined,
})
if (result) {
onSuccess() onSuccess()
onOpenChange(false) onOpenChange(false)
} else { } else {
setError(dict.templates.updateFailed) setError(failed)
} }
} catch (err) { } catch (err) {
console.error("Failed to update template:", err) console.error("Failed to save template:", err)
setError(dict.templates.updateFailed) setError(failed)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden"> <DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Edit2 className="w-5 h-5" /> {isEdit ? (
{dict.templates.editTitle} <Edit2 className="w-5 h-5" />
) : (
<Plus className="w-5 h-5" />
)}
{isEdit
? dict.templates.editTitle
: dict.templates.createTitle}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{dict.templates.editDescription} {isEdit
? dict.templates.editDescription
: dict.templates.createDescription}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -115,14 +134,14 @@ export function TemplateEditDialog({
{/* Prompt field - required */} {/* Prompt field - required */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="edit-prompt" htmlFor={`${id}-prompt`}
className="text-foreground" className="text-foreground"
> >
{dict.templates.promptLabel} {dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span> <span className="text-destructive ml-1">*</span>
</Label> </Label>
<Textarea <Textarea
id="edit-prompt" id={`${id}-prompt`}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder} placeholder={dict.templates.promptPlaceholder}
@@ -134,13 +153,13 @@ export function TemplateEditDialog({
{/* Title field - optional */} {/* Title field - optional */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="edit-title" htmlFor={`${id}-title`}
className="text-foreground" className="text-foreground"
> >
{dict.templates.titleLabel} {dict.templates.titleLabel}
</Label> </Label>
<Input <Input
id="edit-title" id={`${id}-title`}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder} placeholder={dict.templates.titlePlaceholder}
@@ -153,13 +172,13 @@ export function TemplateEditDialog({
{/* Description field - optional */} {/* Description field - optional */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="edit-description" htmlFor={`${id}-description`}
className="text-foreground" className="text-foreground"
> >
{dict.templates.descriptionLabel} {dict.templates.descriptionLabel}
</Label> </Label>
<Textarea <Textarea
id="edit-description" id={`${id}-description`}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder={ placeholder={
@@ -173,7 +192,7 @@ export function TemplateEditDialog({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
htmlFor="edit-pinned" htmlFor={`${id}-pinned`}
className="flex items-center gap-2 text-foreground" className="flex items-center gap-2 text-foreground"
> >
<Bookmark className="w-4 h-4" /> <Bookmark className="w-4 h-4" />
@@ -184,7 +203,7 @@ export function TemplateEditDialog({
</p> </p>
</div> </div>
<Switch <Switch
id="edit-pinned" id={`${id}-pinned`}
checked={pinned} checked={pinned}
onCheckedChange={setPinned} onCheckedChange={setPinned}
/> />
@@ -208,7 +227,9 @@ export function TemplateEditDialog({
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting {isSubmitting
? dict.common.loading ? dict.common.loading
: dict.common.save} : isEdit
? dict.common.save
: dict.templates.createButton}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
+27 -81
View File
@@ -11,6 +11,7 @@ import {
Trash2, Trash2,
Upload, Upload,
} from "lucide-react" } from "lucide-react"
import { useParams } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { import {
AlertDialog, AlertDialog,
@@ -23,63 +24,33 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatRelativeTime } from "@/lib/relative-time"
import { import {
deleteTemplate, deleteTemplate,
duplicateTemplate, duplicateTemplate,
exportTemplates, exportTemplates,
getAllTemplates, getAllTemplates,
importTemplates, importTemplates,
incrementClickCount,
incrementRunCount, incrementRunCount,
searchTemplates, searchTemplates,
type Template, type Template,
updateTemplate, updateTemplate,
validateImportData, validateImportData,
} from "@/lib/template-storage" } from "@/lib/template-storage"
import { TemplateCreateDialog } from "./TemplateCreateDialog" import { TemplateFormDialog } from "./TemplateFormDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps { interface TemplatePanelProps {
setInput: (input: string) => void
/** Whether it was sent */ /** Whether it was sent */
onSendTemplate?: (template: Template) => Promise<boolean> onSendTemplate: (template: Template) => Promise<boolean>
currentInput?: string 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({ export function TemplatePanel({
setInput,
onSendTemplate, onSendTemplate,
currentInput = "", currentInput = "",
}: TemplatePanelProps) { }: TemplatePanelProps) {
const dict = useDictionary() const dict = useDictionary()
const params = useParams<{ lang: string }>()
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false)
@@ -111,10 +82,6 @@ export function TemplatePanel({
useEffect(() => { useEffect(() => {
loadTemplates() loadTemplates()
// Reload when a template is saved elsewhere, e.g. from the chat input
window.addEventListener("templatesChanged", loadTemplates)
return () =>
window.removeEventListener("templatesChanged", loadTemplates)
}, [loadTemplates]) }, [loadTemplates])
const handleCreateSuccess = () => { const handleCreateSuccess = () => {
@@ -133,7 +100,7 @@ export function TemplatePanel({
const handleDuplicate = async (template: Template) => { const handleDuplicate = async (template: Template) => {
const duplicated = await duplicateTemplate( const duplicated = await duplicateTemplate(
template.id, template.id,
dict.templates.copySuffix || "(copy)", dict.templates.copySuffix,
) )
if (duplicated) { if (duplicated) {
loadTemplates() loadTemplates()
@@ -179,23 +146,13 @@ export function TemplatePanel({
// Actually send the template // Actually send the template
const sendTemplate = async (template: Template) => { const sendTemplate = async (template: Template) => {
if (onSendTemplate) { // A run only when it was sent (not while a message is still being
// Sent first, while the chat is the one it was clicked in (the // sent, or attachments read)
// counts wait for storage). A run only when it was sent (not if (await onSendTemplate(template)) {
// while a message is still being sent, or attachments read). // Increment run count and update lastUsedAt
const sending = onSendTemplate(template) await incrementRunCount(template.id)
// Increment click count only when actually sending // Reload to show updated stats
await incrementClickCount(template.id) loadTemplates()
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)
} }
setConfirmSendDialogOpen(false) setConfirmSendDialogOpen(false)
setTemplateToSend(null) setTemplateToSend(null)
@@ -218,7 +175,7 @@ export function TemplatePanel({
if (templates.length === 0) { if (templates.length === 0) {
setImportMessage({ setImportMessage({
type: "error", type: "error",
text: dict.templates.exportEmpty || "No templates to export", text: dict.templates.exportEmpty,
}) })
return return
} }
@@ -260,8 +217,7 @@ export function TemplatePanel({
if (!file) { if (!file) {
setImportMessage({ setImportMessage({
type: "error", type: "error",
text: text: dict.templates.importNoFile,
dict.templates.importNoFile || "Please select a JSON file",
}) })
return return
} }
@@ -370,7 +326,7 @@ export function TemplatePanel({
<div className="mt-3">{importMessageBox}</div> <div className="mt-3">{importMessageBox}</div>
)} )}
<TemplateCreateDialog <TemplateFormDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess} onSuccess={handleCreateSuccess}
@@ -500,7 +456,7 @@ export function TemplatePanel({
<div className="relative shrink-0"> <div className="relative shrink-0">
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible"> <div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
{template.runCount > 0 {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} : dict.templates.neverUsed}
</div> </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"> <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={ title={
template.pinned template.pinned
? dict.templates ? dict.templates.unpin
.unpin || "Unpin" : dict.templates.pin
: dict.templates.pin ||
"Pin"
} }
> >
<Bookmark <Bookmark
@@ -545,10 +499,7 @@ export function TemplatePanel({
handleDuplicate(template) handleDuplicate(template)
}} }}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all" className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={ title={dict.templates.duplicate}
dict.templates.duplicate ||
"Duplicate"
}
> >
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4" />
</button> </button>
@@ -570,13 +521,13 @@ export function TemplatePanel({
</div> </div>
</div> </div>
<TemplateCreateDialog <TemplateFormDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess} onSuccess={handleCreateSuccess}
/> />
<TemplateEditDialog <TemplateFormDialog
open={editDialogOpen} open={editDialogOpen}
onOpenChange={setEditDialogOpen} onOpenChange={setEditDialogOpen}
template={templateToEdit} template={templateToEdit}
@@ -591,12 +542,10 @@ export function TemplatePanel({
<AlertDialogContent className="max-w-sm"> <AlertDialogContent className="max-w-sm">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{dict.templates.deleteTitle || {dict.templates.deleteTitle}
"Delete this template?"}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{dict.templates.deleteDescription || {dict.templates.deleteDescription}
"This will permanently delete this template. This action cannot be undone."}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -621,12 +570,10 @@ export function TemplatePanel({
<AlertDialogContent className="max-w-sm"> <AlertDialogContent className="max-w-sm">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{dict.templates.confirmSendTitle || {dict.templates.confirmSendTitle}
"Replace current input?"}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{dict.templates.confirmSendDescription || {dict.templates.confirmSendDescription}
"You have unsent content in the input. Sending this template will replace it."}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -634,8 +581,7 @@ export function TemplatePanel({
{dict.common.cancel} {dict.common.cancel}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSend}> <AlertDialogAction onClick={handleConfirmSend}>
{dict.templates.confirmSendButton || {dict.templates.confirmSendButton}
"Send Template"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
-4
View File
@@ -16,7 +16,6 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"
export type ValidationStatus = export type ValidationStatus =
| "idle"
| "capturing" | "capturing"
| "validating" | "validating"
| "success" | "success"
@@ -159,13 +158,10 @@ export function ValidationCard({
color: "text-gray-600 bg-gray-50", color: "text-gray-600 bg-gray-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />, icon: <Check className="h-4 w-4" aria-hidden="true" />,
} }
default:
return null
} }
} }
const statusDisplay = getStatusDisplay() const statusDisplay = getStatusDisplay()
if (!statusDisplay || state.status === "idle") return null
return ( return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"> <div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
+122 -200
View File
@@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react"
import { import {
type ChatStatus, type ChatStatus,
DefaultChatTransport, DefaultChatTransport,
getToolName,
isToolUIPart, isToolUIPart,
type UIMessage,
} from "ai" } from "ai"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import type React from "react" import type React from "react"
@@ -19,14 +19,10 @@ import {
useRef, useRef,
useState, useState,
} from "react" } from "react"
import { flushSync } from "react-dom"
import { toast } from "sonner" import { toast } from "sonner"
import type { ValidationState } from "@/components/chat/ValidationCard" import type { ValidationState } from "@/components/chat/ValidationCard"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
keepFileVars,
useDiagramToolHandlers,
} from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { import {
getSelectedAIConfig, getSelectedAIConfig,
@@ -34,13 +30,18 @@ import {
useModelConfig, useModelConfig,
} from "@/hooks/use-model-config" } from "@/hooks/use-model-config"
import { import {
getSessionVersions,
type SessionData, type SessionData,
useSessionManager, useSessionManager,
} from "@/hooks/use-session-manager" } from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram" import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses" 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 { buildChatHeaders } from "@/lib/chat-request"
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages" import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config" 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. * Only checks the LAST tool part (most recent tool call), not all tool parts.
* A call the user stopped is not one to retry. * 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] const lastMessage = messages[messages.length - 1]
if (lastMessage?.role !== "assistant") return false if (lastMessage?.role !== "assistant") return false
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1) const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
@@ -107,45 +108,23 @@ function viewedPageId(): string | null {
return useCanvasStore.getState().currentPageId return useCanvasStore.getState().currentPageId
} }
// Shapes sent with a user message (also kept in its metadata) function selectionOf(message: ChatUIMessage): MessageSelection {
type MessageSelection = Pick<SelectedCell, "id" | "label">[] const selected = message.metadata?.selectedCells
return Array.isArray(selected) ? selected : []
function selectionOf(message: UIMessage): MessageSelection {
const meta = message.metadata as
| { selectedCells?: MessageSelection }
| undefined
return Array.isArray(meta?.selectedCells) ? meta.selectedCells : []
} }
function newLangfuseSessionId() { function newLangfuseSessionId() {
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` 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 * 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 * its pages inflated: previews and the model read it before draw.io saves
* it again. A broken or cut off file gives null. * it again. A broken or cut off file gives null.
*/ */
async function readDiagramFile(file: File): Promise<string | null> { async function readDiagramFile(file: File): Promise<string | null> {
const raw = await file.text() const loaded = parseDrawioFileContent(await file.text())
// What XML allows before the root element: the declaration, comments, return loaded.ok ? withUniquePageIds(loaded.xml) : null
// 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)
} }
/** /**
@@ -190,15 +169,13 @@ function withUniquePageIds(xml: string): string {
class FileReadError extends Error {} class FileReadError extends Error {}
export interface ChatEngine { export interface ChatEngine {
messages: UIMessage[] messages: ChatUIMessage[]
status: ChatStatus status: ChatStatus
error: Error | undefined error: Error | undefined
isBusy: boolean isBusy: boolean
/** The chat on screen is being left (saved, then another one shown) */ /** The chat on screen is being left (saved, then another one shown) */
isLeaving: boolean isLeaving: boolean
/** A message is being sent again; the chat is not empty meanwhile */ setMessages: (messages: ChatUIMessage[]) => void
isResending: boolean
setMessages: (messages: UIMessage[]) => void
stop: () => void stop: () => void
input: string input: string
@@ -267,11 +244,10 @@ export function ChatEngineProvider({
}) { }) {
const { const {
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
handleExport: onExport, fetchDiagramXml,
exportResolversRef,
chartXML, chartXML,
chartXMLRef: liveChartXMLRef, chartXMLRef,
latestSvg, latestSvgRef,
clearDiagram, clearDiagram,
getThumbnailSvg, getThumbnailSvg,
captureValidationPng, captureValidationPng,
@@ -284,26 +260,10 @@ export function ChatEngineProvider({
const searchParams = useSearchParams() const searchParams = useSearchParams()
const urlSessionId = searchParams.get("session") const urlSessionId = searchParams.get("session")
const settings = useSettingsStore() const vlmValidationEnabled = useSettingsStore((s) => s.vlmValidationEnabled)
const openSettings = useUiStore((s) => s.openSettings) const openSettings = useUiStore((s) => s.openSettings)
const focusComposer = useUiStore((s) => s.focusComposer) 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({ const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({
tooLong: dict.errors.fileTooLong, tooLong: dict.errors.fileTooLong,
readFailed: dict.errors.fileReadFailed, readFailed: dict.errors.fileReadFailed,
@@ -318,9 +278,6 @@ export function ChatEngineProvider({
const sessionManager = useSessionManager({ initialSessionId: urlSessionId }) const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
const [input, setInputState] = useState("") 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 // Restore the unsent input from sessionStorage
useEffect(() => { useEffect(() => {
@@ -340,21 +297,7 @@ export function ChatEngineProvider({
else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) 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({ const quotaManager = useQuotaManager({
dailyRequestLimit,
dailyTokenLimit,
tpmLimit,
onConfigModel: () => openSettings("models"), onConfigModel: () => openSettings("models"),
}) })
@@ -392,20 +335,12 @@ export function ChatEngineProvider({
const hasRestoredRef = useRef(false) const hasRestoredRef = useRef(false)
const [isRestored, setIsRestored] = useState(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) // Session loaded without a diagram (prevents thumbnail contamination)
const justLoadedSessionIdRef = useRef<string | null>(null) const justLoadedSessionIdRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
chartXMLRef.current = chartXML
if (chartXML) justLoadedSessionIdRef.current = null if (chartXML) justLoadedSessionIdRef.current = null
}, [chartXML]) }, [chartXML])
const latestSvgRef = useRef(latestSvg)
useEffect(() => {
latestSvgRef.current = latestSvg
}, [latestSvg])
const autoRetryCountRef = useRef(0) const autoRetryCountRef = useRef(0)
const continuationRetryCountRef = useRef(0) const continuationRetryCountRef = useRef(0)
// Partial XML when output was truncated; non-empty means continuation mode // Partial XML when output was truncated; non-empty means continuation mode
@@ -424,9 +359,6 @@ export function ChatEngineProvider({
const [isLeaving, setIsLeaving] = useState(false) const [isLeaving, setIsLeaving] = useState(false)
// The user's provider the current turn was sent to, for its errors // The user's provider the current turn was sent to, for its errors
const requestProviderIdRef = useRef<string | undefined>(undefined) 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 // Presses of Stop: a check that began before one still knows of it after
// the next message clears stoppedRef // the next message clears stoppedRef
const stopCountRef = useRef(0) const stopCountRef = useRef(0)
@@ -458,13 +390,13 @@ export function ChatEngineProvider({
processedToolCallsRef, processedToolCallsRef,
validationRetryCountRef, validationRetryCountRef,
// A preview undone just before the tool call is in this one already // A preview undone just before the tool call is in this one already
chartXMLRef: liveChartXMLRef, chartXMLRef,
turnPageIdRef, turnPageIdRef,
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart: fetchDiagramXml,
captureValidationPng, captureValidationPng,
validateDiagram: validateWithFallback, validateDiagram: validateWithFallback,
enableVlmValidation: settings.vlmValidationEnabled, enableVlmValidation: vlmValidationEnabled,
sessionId: langfuseSessionId, sessionId: langfuseSessionId,
watchStop: () => { watchStop: () => {
const stopsBefore = stopCountRef.current const stopsBefore = stopCountRef.current
@@ -492,9 +424,40 @@ export function ChatEngineProvider({
onDisplayChart(original, true, "revert") 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( 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 { const {
@@ -505,19 +468,19 @@ export function ChatEngineProvider({
error, error,
setMessages, setMessages,
stop, stop,
} = useChat({ } = useChat<ChatUIMessage>({
transport, transport,
onToolCall: async ({ toolCall }) => { onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutputSafely) await handleToolCall({ toolCall }, addToolOutputSafely)
}, },
onFinish: ({ message, isAbort, isError }) => { onFinish: ({ message, isAbort, isError, finishReason }) => {
// Stopped, failed, or the reply just ended (a closed connection): // Stopped, failed, or the reply just ended (a closed connection):
// tool calls still streaming never reach the tool handler. Mark // tool calls still streaming never reach the tool handler. Mark
// them handled so a later render of the stream does not draw // them handled so a later render of the stream does not draw
// their preview again. // their preview again.
const cut = (message.parts as any[]).filter( const cut = message.parts
(part) => part.state === "input-streaming" && part.toolCallId, .filter(isToolUIPart)
) .filter((part) => part.state === "input-streaming")
for (const part of cut) { for (const part of cut) {
processedToolCallsRef.current.add(part.toolCallId) processedToolCallsRef.current.add(part.toolCallId)
} }
@@ -526,18 +489,16 @@ export function ChatEngineProvider({
// or the page going, aborts a reply). Not once another chat is // or the page going, aborts a reply). Not once another chat is
// on screen: the call is not among its messages. // on screen: the call is not among its messages.
if (isAbort && getChatGeneration() === turnChatRef.current) { if (isAbort && getChatGeneration() === turnChatRef.current) {
for (const part of cut) { for (const part of cut) stopToolCall(part)
addToolOutputSafely({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
} }
if (isAbort || isError || cut.length === 0) return if (isAbort || isError) return
// Ended with no error: undo the preview, and say so, as an // The output budget ran out before the model called a tool (its
// error does (with Retry) // 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() const [originalXml] = editDiagramOriginalXmlRef.current.values()
if (originalXml) onDisplayChart(originalXml, true, "revert") if (originalXml) onDisplayChart(originalXml, true, "revert")
editDiagramOriginalXmlRef.current.clear() editDiagramOriginalXmlRef.current.clear()
@@ -546,16 +507,11 @@ export function ChatEngineProvider({
if (getChatGeneration() !== turnChatRef.current) return if (getChatGeneration() !== turnChatRef.current) return
setMessages((currentMessages) => [ setMessages((currentMessages) => [
...currentMessages, ...currentMessages,
{ noticeMessage({
id: `error-${Date.now()}`, text: outOfTokens
role: "system" as const, ? dict.errors.llm.output_truncated
parts: [ : dict.errors.answerEndedEarly,
{ }),
type: "text" as const,
text: dict.errors.answerEndedEarly,
},
],
},
]) ])
}, },
onError: (error) => { onError: (error) => {
@@ -624,19 +580,15 @@ export function ChatEngineProvider({
// the model selected may have changed since, e.g. in another tab // the model selected may have changed since, e.g. in another tab
const providerId = requestProviderIdRef.current 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) => [ setMessages((currentMessages) => [
...currentMessages, ...currentMessages,
{ noticeMessage({
id: `error-${Date.now()}`, text,
role: "system" as const, ...(openModelConfig && { openModelConfig, providerId }),
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 },
}),
},
]) ])
if (isAccessCodeError) openSettings("general") 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 // After each request: send the next one (retry, continue a cut off
// drawing) or end the turn // drawing) or end the turn
const shouldSendAgain = (messages: UIMessage[]) => { const shouldSendAgain = (messages: ChatUIMessage[]) => {
// The user stopped: a tool result that arrives later (a VLM // The user stopped: a tool result that arrives later (a VLM
// check still running) must not start a new request // check still running) must not start a new request
if (stoppedRef.current) return false if (stoppedRef.current) return false
@@ -720,9 +682,6 @@ export function ChatEngineProvider({
} }
const isBusy = status === "streaming" || status === "submitted" 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 // isBusy of the latest render, for code that runs after an await
const busyRef = useRef(isBusy) const busyRef = useRef(isBusy)
busyRef.current = isBusy busyRef.current = isBusy
@@ -834,16 +793,10 @@ export function ChatEngineProvider({
const [shownChats, setShownChats] = useState(0) const [shownChats, setShownChats] = useState(0)
// The messages shown when another chat was put on screen: useChat can // 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 // 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( const syncUIWithSession = useCallback(
( (data: SessionData | null) => {
data:
| (SessionData & {
diagramHistory?: { svg: string; xml: string }[]
})
| null,
) => {
// An answer still coming in belongs to the chat that was on // An answer still coming in belongs to the chat that was on
// screen (browser back or forward): it stops, before this chat's // screen (browser back or forward): it stops, before this chat's
// messages are put in its place, so none of it lands here // messages are put in its place, so none of it lands here
@@ -880,26 +833,17 @@ export function ChatEngineProvider({
loadedMessageIdsRef.current = new Set( loadedMessageIdsRef.current = new Set(
(data.messages as { id: string }[]).map((m) => m.id), (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) xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
if (data.diagramXml) { if (data.diagramXml) onDisplayChart(data.diagramXml, true)
onDisplayChart(data.diagramXml, true) else clearDiagram()
chartXMLRef.current = data.diagramXml
} else {
clearDiagram()
chartXMLRef.current = ""
}
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = "" if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
useVersionsStore useVersionsStore.getState().setVersions(data.versions ?? [])
.getState()
.setVersions(getSessionVersions(data))
} else { } else {
loadedMessageIdsRef.current = new Set() loadedMessageIdsRef.current = new Set()
setMessages([]) setMessages([])
xmlSnapshotsRef.current.clear() xmlSnapshotsRef.current.clear()
clearDiagram() clearDiagram()
chartXMLRef.current = ""
latestSvgRef.current = ""
useVersionsStore.getState().clear() useVersionsStore.getState().clear()
} }
}, },
@@ -932,7 +876,6 @@ export function ChatEngineProvider({
if (isRealDiagram(currentDiagramXml) && options.withThumbnail) { if (isRealDiagram(currentDiagramXml) && options.withThumbnail) {
const freshThumb = await getThumbnailSvg() const freshThumb = await getThumbnailSvg()
if (freshThumb) { if (freshThumb) {
latestSvgRef.current = freshThumb
thumbnailDataUrl = freshThumb thumbnailDataUrl = freshThumb
} else if (latestSvgRef.current) { } else if (latestSvgRef.current) {
thumbnailDataUrl = latestSvgRef.current thumbnailDataUrl = latestSvgRef.current
@@ -1197,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 = ( const sendChatMessage = (
parts: any, parts: any,
xml: string, xml: string,
previousXml: string, previousXml: string,
turnIndex: number, turnIndex: number,
selectedCells: MessageSelection, selectedCells: MessageSelection,
messageId?: string,
) => { ) => {
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0 continuationRetryCountRef.current = 0
@@ -1240,9 +1186,11 @@ export function ChatEngineProvider({
maxOutputTokens, maxOutputTokens,
}), }),
} }
turnRequestsRef.current = 0
// The selection stays with the message, for regenerate and retry // The selection stays with the message, for regenerate and retry
sendMessage( sendMessage(
{ {
...(messageId !== undefined && { messageId }),
parts, parts,
...(selected.length > 0 && { ...(selected.length > 0 && {
metadata: { selectedCells: selected }, metadata: { selectedCells: selected },
@@ -1261,7 +1209,7 @@ export function ChatEngineProvider({
selectedCells: MessageSelection = [], selectedCells: MessageSelection = [],
generation = getChatGeneration(), generation = getChatGeneration(),
): Promise<boolean> => { ): Promise<boolean> => {
const chartXml = formatXML(await onFetchChart()) const chartXml = formatXML(await fetchDiagramXml())
if (getChatGeneration() !== generation || leavingRef.current) { if (getChatGeneration() !== generation || leavingRef.current) {
return false return false
} }
@@ -1344,12 +1292,8 @@ export function ChatEngineProvider({
}) })
if (prepared.ok) { if (prepared.ok) {
const pageId = viewedPageId() const pageId = viewedPageId()
const canvasXml = chartXMLRef.current || ""
onDisplayChart( onDisplayChart(
keepFileVars( placeOnPage(prepared.xml, chartXMLRef.current, pageId),
placeOnPage(prepared.xml, canvasXml, pageId),
canvasXml,
),
true, true,
"commit", "commit",
{ toolCallId, pageId }, { toolCallId, pageId },
@@ -1437,8 +1381,8 @@ export function ChatEngineProvider({
} }
// Templates fill the input and send it right away (attachments included) // Templates fill the input and send it right away (attachments included)
// Whether it was sent (it can run after a wait: the template panel // Whether it was sent (it can run after a wait, so busy is read from
// stores its click count first, so busy is read from the ref) // the ref)
const sendTemplate = async (prompt: string): Promise<boolean> => { const sendTemplate = async (prompt: string): Promise<boolean> => {
// Like the send button, it waits for an answer or a message being // 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 // prepared (that message may still come back to the input), and for
@@ -1478,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 // Snapshots of the messages after this one go with them
const dropLaterSnapshots = (messageIndex: number) => { const dropLaterSnapshots = (messageIndex: number) => {
for (const key of xmlSnapshotsRef.current.keys()) { for (const key of xmlSnapshotsRef.current.keys()) {
@@ -1500,22 +1439,17 @@ export function ChatEngineProvider({
return false return false
} }
const previousXml = getPreviousXml(index) const previousXml = getPreviousXml(index)
restoreDiagramFromSnapshot(savedXml) onDisplayChart(savedXml, true)
dropLaterSnapshots(index) dropLaterSnapshots(index)
useVersionsStore.getState().removeFromTurn(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( sendChatMessage(
parts, parts,
getPageXml(savedXml, viewedPageId()), getPageXml(savedXml, viewedPageId()),
previousXml, previousXml,
index, index,
selectionOf(messages[index]), selectionOf(messages[index]),
messages[index].id,
) )
return true return true
@@ -1568,7 +1502,7 @@ export function ChatEngineProvider({
// Like submit: the diagram export comes first // Like submit: the diagram export comes first
preparingSendRef.current = true preparingSendRef.current = true
try { try {
const chartXml = formatXML(await onFetchChart()) const chartXml = formatXML(await fetchDiagramXml())
// Another chat came on screen meanwhile (browser back/forward), // Another chat came on screen meanwhile (browser back/forward),
// or this one is being left // or this one is being left
if (getChatGeneration() !== generation || leavingRef.current) { if (getChatGeneration() !== generation || leavingRef.current) {
@@ -1581,16 +1515,13 @@ export function ChatEngineProvider({
chartXMLRef.current || chartXml, chartXMLRef.current || chartXml,
) )
} }
flushSync(() => {
setIsResending(true)
setMessages(messagesRef.current.slice(0, index))
})
sendChatMessage( sendChatMessage(
message.parts, message.parts,
chartXml, chartXml,
getPreviousXml(index), getPreviousXml(index),
index, index,
selectionOf(message), selectionOf(message),
message.id,
) )
} catch (error) { } catch (error) {
console.error("Error fetching chart data:", error) console.error("Error fetching chart data:", error)
@@ -1609,25 +1540,17 @@ export function ChatEngineProvider({
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
// Calls the tool handler already took can still show as streaming: // Calls the tool handler already took can still show as streaming:
// the messages update at most every 150 ms (useChat throttle) // the messages update at most every 150 ms (useChat throttle)
const toolParts = lastMessage?.parts?.filter( const streaming = (lastMessage?.parts ?? [])
(part: any) => .filter(isToolUIPart)
part.type?.startsWith("tool-") && .filter(
part.state === "input-streaming" && (part) =>
!processedToolCallsRef.current.has(part.toolCallId), part.state === "input-streaming" &&
) !processedToolCallsRef.current.has(part.toolCallId),
for (const part of (toolParts ?? []) as any[]) { )
if (part.toolCallId) { for (const part of streaming) stopToolCall(part)
addToolOutputSafely({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
}
stop() stop()
abandonContinuation() abandonContinuation()
}, [messages, addToolOutputSafely, stop, cancelValidation]) }, [messages, stopToolCall, stop, cancelValidation])
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Session actions // Session actions
@@ -1937,8 +1860,7 @@ export function ChatEngineProvider({
error, error,
isBusy, isBusy,
isLeaving, isLeaving,
isResending, setMessages: setMessages as (messages: ChatUIMessage[]) => void,
setMessages: setMessages as (messages: UIMessage[]) => void,
stop: handleStop, stop: handleStop,
input, input,
setInput, setInput,
+8 -19
View File
@@ -18,9 +18,8 @@ import {
useState, useState,
} from "react" } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog" import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
import { TemplatePanel } from "@/components/chat/TemplatePanel" import { TemplatePanel } from "@/components/chat/TemplatePanel"
import { ErrorToast } from "@/components/error-toast"
import { FilePreviewList } from "@/components/file-preview-list" import { FilePreviewList } from "@/components/file-preview-list"
import { ModelSelector } from "@/components/model-selector" import { ModelSelector } from "@/components/model-selector"
import { import {
@@ -46,24 +45,16 @@ import { useUiStore } from "@/stores/ui-store"
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments" import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
import { useChatEngine } from "./chat-engine" 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) { function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return if (errors.length === 0) return
if (errors.length === 1) { if (errors.length === 1) {
showErrorToast( toast.error(
<span className="text-muted-foreground">{errors[0]}</span>, <span className="text-muted-foreground">{errors[0]}</span>,
{ duration: 5000 },
) )
return return
} }
showErrorToast( toast.error(
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium"> <span className="font-medium">
{formatMessage(dict.errors.filesRejected, { {formatMessage(dict.errors.filesRejected, {
@@ -83,6 +74,7 @@ function showValidationErrors(errors: string[], dict: any) {
)} )}
</ul> </ul>
</div>, </div>,
{ duration: 5000 },
) )
} }
@@ -277,10 +269,11 @@ export function Composer({
next.delete(url) next.delete(url)
return next return next
}) })
showErrorToast( toast.error(
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{err instanceof Error ? err.message : dict.url.failed} {err instanceof Error ? err.message : dict.url.failed}
</span>, </span>,
{ duration: 5000 },
) )
} finally { } finally {
setIsExtractingUrl(false) setIsExtractingUrl(false)
@@ -519,7 +512,7 @@ export function Composer({
onSubmit={handleUrlExtract} onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl} isExtracting={isExtractingUrl}
/> />
<TemplateCreateDialog <TemplateFormDialog
open={saveTemplateOpen} open={saveTemplateOpen}
onOpenChange={setSaveTemplateOpen} onOpenChange={setSaveTemplateOpen}
onSuccess={() => setSaveTemplateOpen(false)} onSuccess={() => setSaveTemplateOpen(false)}
@@ -532,10 +525,6 @@ export function Composer({
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin"> <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
<TemplatePanel <TemplatePanel
setInput={(value) => {
setInput(value)
setTemplatesOpen(false)
}}
currentInput={input} currentInput={input}
onSendTemplate={(template) => { onSendTemplate={(template) => {
setTemplatesOpen(false) setTemplatesOpen(false)
+9 -22
View File
@@ -1,4 +1,4 @@
import { FileText, FileUp, History, Pencil } from "lucide-react" import { FileUp, History, Pencil } from "lucide-react"
import type React from "react" import type React from "react"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path" import { getAssetUrl } from "@/lib/base-path"
@@ -12,7 +12,7 @@ interface Example {
title: string title: string
description: string description: string
/** Picture of the result (or of the input, for "replicate" examples) */ /** Picture of the result (or of the input, for "replicate" examples) */
image?: string image: string
prompt: string prompt: string
/** Bundled file sent with the prompt; matches the cached example */ /** Bundled file sent with the prompt; matches the cached example */
file?: { path: string; name: string; type: string } file?: { path: string; name: string; type: string }
@@ -34,7 +34,6 @@ function ExampleTile({
title, title,
description, description,
picture, picture,
plain,
testId, testId,
}: { }: {
onClick: () => void onClick: () => void
@@ -42,8 +41,6 @@ function ExampleTile({
/** Shown as the tooltip: the title alone names the example */ /** Shown as the tooltip: the title alone names the example */
description: string description: string
picture: React.ReactNode picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */
plain?: boolean
testId?: string testId?: string
}) { }) {
return ( return (
@@ -54,12 +51,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" className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
data-testid={testId} data-testid={testId}
> >
<span <span className="flex h-16 w-full items-center justify-center overflow-hidden rounded-lg sheet-light bg-white">
className={cn(
"flex h-16 w-full items-center justify-center overflow-hidden rounded-lg",
plain ? "bg-muted" : "sheet-light bg-white",
)}
>
{picture} {picture}
</span> </span>
<span className="px-1 text-[13px] leading-snug font-medium text-foreground"> <span className="px-1 text-[13px] leading-snug font-medium text-foreground">
@@ -220,18 +212,13 @@ export function LobbyHero({
description={example.description} description={example.description}
testId={`example-${example.key}`} testId={`example-${example.key}`}
picture={ picture={
example.image ? ( // biome-ignore lint/performance/noImgElement: static preview
// biome-ignore lint/performance/noImgElement: static preview <img
<img src={getAssetUrl(example.image)}
src={getAssetUrl(example.image)} alt=""
alt="" className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]" />
/>
) : (
<FileText className="size-5 text-muted-foreground" />
)
} }
plain={!example.image}
/> />
), ),
)} )}
+28 -41
View File
@@ -1,4 +1,4 @@
import type { UIMessage } from "ai" import { isToolUIPart, type UIMessage } from "ai"
import { import {
AlertCircle, AlertCircle,
BookmarkPlus, BookmarkPlus,
@@ -16,18 +16,15 @@ import {
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
import { toast } from "sonner" import { toast } from "sonner"
import { import { Reasoning } from "@/components/ai-elements/reasoning"
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer" 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 type { ToolPartLike } from "@/components/chat/types"
import { ValidationCard } from "@/components/chat/ValidationCard" import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath" import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { noticeOf } from "@/lib/chat-message"
import { useCopy } from "@/lib/clipboard" import { useCopy } from "@/lib/clipboard"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store" import { useUiStore } from "@/stores/ui-store"
@@ -291,7 +288,7 @@ function UserMessage({
{text && <MessageText text={text} />} {text && <MessageText text={text} />}
</div> </div>
{saveTemplate && ( {saveTemplate && (
<TemplateCreateDialog <TemplateFormDialog
open open
onOpenChange={(open) => !open && setSaveTemplate(false)} onOpenChange={(open) => !open && setSaveTemplate(false)}
onSuccess={() => setSaveTemplate(false)} onSuccess={() => setSaveTemplate(false)}
@@ -373,7 +370,7 @@ function AssistantMessage({
parts.forEach((part, partIndex) => { parts.forEach((part, partIndex) => {
if (part.type === "reasoning") if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex, texts: [] }) blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
else if (part.type?.startsWith("tool-")) else if (isToolUIPart(part))
blocks.push({ kind: "tool", index: partIndex, texts: [] }) blocks.push({ kind: "tool", index: partIndex, texts: [] })
else if (part.type === "text") { else if (part.type === "text") {
const text = (part as { text: string }).text const text = (part as { text: string }).text
@@ -398,13 +395,9 @@ function AssistantMessage({
className="w-full" className="w-full"
isStreaming={isStreamingReasoning} isStreaming={isStreamingReasoning}
defaultOpen={!isRestored} defaultOpen={!isRestored}
getThinkingMessage={thinkingMessage}
> >
<ReasoningTrigger {(part as { text: string }).text}
getThinkingMessage={thinkingMessage}
/>
<ReasoningContent>
{(part as { text: string }).text}
</ReasoningContent>
</Reasoning> </Reasoning>
) )
} }
@@ -576,9 +569,10 @@ export function MessageList() {
} }
}, [messages]) }, [messages])
const lastAssistantIndex = messages // A notice (a failed request) is an assistant message without an answer
.map((m) => m.role) const lastAssistantIndex = messages.findLastIndex(
.lastIndexOf("assistant") (m) => m.role === "assistant" && !noticeOf(m),
)
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user") const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return ( return (
@@ -587,6 +581,7 @@ export function MessageList() {
const isRestored = engine.loadedMessageIdsRef.current.has( const isRestored = engine.loadedMessageIdsRef.current.has(
message.id, message.id,
) )
const notice = noticeOf(message)
return ( return (
<div <div
key={message.id} key={message.id}
@@ -598,33 +593,18 @@ export function MessageList() {
index={index} index={index}
isLast={index === lastUserIndex} isLast={index === lastUserIndex}
/> />
) : message.role === "assistant" ? ( ) : notice ? (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
) : (
<ErrorNotice <ErrorNotice
text={getMessageTextContent(message)} text={notice.text}
openModelConfig={ openModelConfig={!!notice.openModelConfig}
!!( onOpenModelConfig={() =>
message.metadata as
| { openModelConfig?: boolean }
| undefined
)?.openModelConfig
}
onOpenModelConfig={() => {
const { providerId } =
(message.metadata as
| { providerId?: string }
| undefined) ?? {}
openSettings( openSettings(
"models", "models",
providerId ? { providerId } : "list", notice.providerId
? { providerId: notice.providerId }
: "list",
) )
}} }
onRetry={ onRetry={
index === messages.length - 1 && index === messages.length - 1 &&
!engine.isBusy && !engine.isBusy &&
@@ -633,6 +613,13 @@ export function MessageList() {
: undefined : undefined
} }
/> />
) : (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
)} )}
</div> </div>
) )
+3 -2
View File
@@ -1,3 +1,4 @@
import { getToolName } from "ai"
import { import {
AlertCircle, AlertCircle,
Check, Check,
@@ -164,7 +165,7 @@ function ToolRow({
tone === "error" && "border-destructive/25", tone === "error" && "border-destructive/25",
)} )}
data-testid="tool-row" data-testid="tool-row"
data-tool-name={part.type?.replace("tool-", "")} data-tool-name={getToolName(part)}
data-tool-state={part.state} data-tool-state={part.state}
> >
<button <button
@@ -246,7 +247,7 @@ export function ToolActivity({
const t = dict.versions const t = dict.versions
const engine = useChatEngine() const engine = useChatEngine()
const versions = useVersionsStore((s) => s.versions) const versions = useVersionsStore((s) => s.versions)
const toolName = part.type?.replace("tool-", "") const toolName = getToolName(part)
const { state, errorText, output } = part const { state, errorText, output } = part
const versionIndex = versions.findIndex( const versionIndex = versions.findIndex(
+12 -8
View File
@@ -1,15 +1,19 @@
import type { DynamicToolUIPart, ToolUIPart, UITools } from "ai"
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts" import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation } export type { DiagramOperation }
export interface ToolPartLike { /**
type: string * A tool part as the UI reads it, in any state: input, output and errorText
toolCallId: string * are each there or not, instead of one shape per state.
state?: string */
input?: { export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & {
xml?: string input?:
operations?: DiagramOperation[] | ({
} & Record<string, unknown> xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>)
| undefined
output?: string output?: string
errorText?: string errorText?: string
} }
+21 -22
View File
@@ -5,25 +5,25 @@ import type { RefObject } from "react"
import { useCallback, useEffect, useRef } from "react" import { useCallback, useEffect, useRef } from "react"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types" import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { pageSelectorFor } from "@/lib/diagram-pages" import { pageSelectorFor, replacePageModel } from "@/lib/diagram-pages"
import { import { extractCompleteMxCells } from "@/lib/utils"
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
} from "@/lib/utils"
import { import {
defaultLayerOf, defaultLayerOf,
expandCompactCells, expandCompactCells,
} from "@/packages/mcp-server/src/compact-cells.ts" } from "@/packages/mcp-server/src/compact-cells.ts"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts" import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { targetPageXml } from "@/packages/mcp-server/src/edit-diagram.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 { import {
addDefaultStyles, addDefaultStyles,
applyStyleClasses, applyStyleClasses,
edgeIdsOf, edgeIdsOf,
readStyleClasses, readStyleClasses,
} from "@/packages/mcp-server/src/style-classes.ts" } 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 // Helper to extract complete operations from streaming input
function getCompleteOperations( function getCompleteOperations(
@@ -91,32 +91,31 @@ export function useDiagramStreaming({
const { classes, xml: cellsXml } = readStyleClasses(xml || "") const { classes, xml: cellsXml } = readStyleClasses(xml || "")
const completeCells = extractCompleteMxCells(cellsXml) const completeCells = extractCompleteMxCells(cellsXml)
if (!completeCells) return 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( applyStyleClasses(
expandCompactCells(convertToLegalXml(completeCells)), expandCompactCells(
autoFixXml(wrapCellsInModel(completeCells)).fixed,
),
classes, classes,
), ),
) )
if (convertedXml === previousXML.current) return if (model === previousXML.current) return
// Skip this update while the cells written so far don't parse // Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString( const testDoc = new DOMParser().parseFromString(model, "text/xml")
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return if (testDoc.querySelector("parsererror")) return
try { try {
// Replace the model's page's cells so other pages stay // Replace the model's page so other pages stay intact; an
// intact. An empty canvas gets a default mxfile to put the // empty canvas gets the blank mxfile to put the page in
// cells in. const replacedXML = replacePageModel(
const baseXML = chartXML || BLANK_MXFILE chartXML,
const replacedXML = replaceNodes(
baseXML,
convertedXml,
turnPageIdRef?.current, turnPageIdRef?.current,
model,
) )
previousXML.current = convertedXml previousXML.current = model
loadDiagram(replacedXML, true, "preview") loadDiagram(replacedXML, true, "preview")
} catch (error) { } catch (error) {
console.error("Error processing XML:", error) console.error("Error processing XML:", error)
+12 -25
View File
@@ -1,7 +1,6 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
// Dev XML presets for streaming simulator // Dev XML presets for streaming simulator
@@ -135,15 +134,12 @@ const DEV_XML_PRESETS: Record<string, string> = {
interface DevXmlSimulatorProps { interface DevXmlSimulatorProps {
setMessages: React.Dispatch<React.SetStateAction<any[]>> setMessages: React.Dispatch<React.SetStateAction<any[]>>
onDisplayChart: (xml: string) => void onDisplayChart: (xml: string) => void
onShowQuotaToast?: () => void
} }
export function DevXmlSimulator({ export function DevXmlSimulator({
setMessages, setMessages,
onDisplayChart, onDisplayChart,
onShowQuotaToast,
}: DevXmlSimulatorProps) { }: DevXmlSimulatorProps) {
const dict = useDictionary()
const [devXml, setDevXml] = useState("") const [devXml, setDevXml] = useState("")
const [isSimulating, setIsSimulating] = useState(false) const [isSimulating, setIsSimulating] = useState(false)
const [devIntervalMs, setDevIntervalMs] = useState(1) const [devIntervalMs, setDevIntervalMs] = useState(1)
@@ -180,7 +176,7 @@ export function DevXmlSimulator({
parts: [ parts: [
{ {
type: "text" as const, 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 const lastMsg = updated[updated.length - 1] as any
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) { if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
lastMsg.parts[0].state = "output-available" 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 } lastMsg.parts[0].input = { xml }
} }
return updated 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"> <div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
<details> <details>
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium"> <summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
{dict.dev.title} Dev: XML Streaming Simulator
</summary> </summary>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.preset} Preset:
</label> </label>
<select <select
onChange={(e) => { onChange={(e) => {
@@ -264,7 +260,7 @@ export function DevXmlSimulator({
defaultValue="" defaultValue=""
> >
<option value="" disabled> <option value="" disabled>
{dict.dev.selectPreset} Select a preset...
</option> </option>
{Object.keys(DEV_XML_PRESETS).map((name) => ( {Object.keys(DEV_XML_PRESETS).map((name) => (
<option key={name} value={name}> <option key={name} value={name}>
@@ -277,19 +273,19 @@ export function DevXmlSimulator({
onClick={() => setDevXml("")} onClick={() => setDevXml("")}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded" className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
> >
{dict.dev.clear} Clear
</button> </button>
</div> </div>
<textarea <textarea
value={devXml} value={devXml}
onChange={(e) => setDevXml(e.target.value)} 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" 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-4">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.interval} Interval:
</label> </label>
<input <input
type="range" type="range"
@@ -308,7 +304,7 @@ export function DevXmlSimulator({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.chars} Chars:
</label> </label>
<input <input
type="number" 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" 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 {isSimulating
? dict.dev.streaming ? "Streaming..."
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`} : `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
</button> </button>
{isSimulating && ( {isSimulating && (
<button <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" className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
> >
{dict.dev.stop} 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}
</button> </button>
)} )}
</div> </div>
-44
View File
@@ -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>
)
}
+13
View File
@@ -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>
)
}
+2 -41
View File
@@ -43,7 +43,6 @@ import {
CommandList, CommandList,
} from "@/components/ui/command" } from "@/components/ui/command"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -164,7 +163,6 @@ export function ModelConfigDialog({
) )
const [customModelInput, setCustomModelInput] = useState("") const [customModelInput, setCustomModelInput] = useState("")
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState("")
// Models whose test is running (they are all tested at once) // Models whose test is running (they are all tested at once)
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>( const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
() => new Set(), () => new Set(),
@@ -441,7 +439,6 @@ export function ModelConfigDialog({
setFetchModelsError("") setFetchModelsError("")
updates = { updates = {
...updates, ...updates,
validated: false,
models: selectedProvider.models.map((m) => ({ models: selectedProvider.models.map((m) => ({
...m, ...m,
validated: undefined, validated: undefined,
@@ -659,10 +656,6 @@ export function ModelConfigDialog({
return return
} }
// A model whose id changed was not tested
if (allValid && !idChanged) {
updateProvider(selectedProviderId, { validated: true })
}
markRejected(selectedProviderId, askedWith, rejected) markRejected(selectedProviderId, askedWith, rejected)
// The status line is about the provider shown now // The status line is about the provider shown now
if (selectedProviderIdRef.current !== selectedProviderId) return if (selectedProviderIdRef.current !== selectedProviderId) return
@@ -1004,7 +997,6 @@ export function ModelConfigDialog({
name={provider.name} name={provider.name}
baseUrl={provider.baseUrl} baseUrl={provider.baseUrl}
awsRegion={provider.awsRegion} awsRegion={provider.awsRegion}
bedrockApiKey
settingsLayout settingsLayout
bedrockFilled={{ bedrockFilled={{
apiKey: !!provider.apiKey, apiKey: !!provider.apiKey,
@@ -1389,10 +1381,7 @@ export function ModelConfigDialog({
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<AlertDialog <AlertDialog
open={deleteConfirmOpen} open={deleteConfirmOpen}
onOpenChange={(open) => { onOpenChange={setDeleteConfirmOpen}
setDeleteConfirmOpen(open)
if (!open) setDeleteConfirmText("")
}}
> >
<AlertDialogContent className="border-destructive/30"> <AlertDialogContent className="border-destructive/30">
<AlertDialogHeader> <AlertDialogHeader>
@@ -1410,39 +1399,11 @@ export function ModelConfigDialog({
})} })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </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> <AlertDialogFooter>
<AlertDialogCancel>{t.cancel}</AlertDialogCancel> <AlertDialogCancel>{t.cancel}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDeleteProvider} onClick={handleDeleteProvider}
disabled={ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
selectedProvider &&
selectedProvider.models.length >= 3 &&
deleteConfirmText !==
providerName(selectedProvider)
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{t.delete} {t.delete}
</AlertDialogAction> </AlertDialogAction>
+368 -334
View File
@@ -10,28 +10,38 @@ import {
Settings2, Settings2,
User, User,
} from "lucide-react" } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { import {
ModelSelectorContent, type ComponentProps,
ModelSelectorEmpty, type ReactNode,
ModelSelectorGroup, useEffect,
ModelSelectorInput, useMemo,
ModelSelectorItem, useRef,
ModelSelectorList, useState,
ModelSelectorLogo, } from "react"
ModelSelectorName,
ModelSelector as ModelSelectorRoot,
ModelSelectorSectionHeader,
ModelSelectorSeparator,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ProviderLogo } from "@/components/provider-logo" import { ProviderLogo } from "@/components/provider-logo"
import { useDictionary } from "@/hooks/use-dictionary" import { Button } from "@/components/ui/button"
import { import {
type FlattenedModel, Command,
PROVIDER_LOGO_MAP, CommandEmpty,
} from "@/lib/types/model-config" 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" import { cn } from "@/lib/utils"
interface ModelSelectorProps { interface ModelSelectorProps {
@@ -44,25 +54,22 @@ interface ModelSelectorProps {
disabled?: boolean disabled?: boolean
} }
type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] }
// Group models by providerLabel (handles duplicate providers) // Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider( function groupModelsByProvider(
models: FlattenedModel[], models: FlattenedModel[],
): Map<string, { provider: string; models: FlattenedModel[] }> { ): Map<string, ProviderGroup> {
const groups = new Map< const groups = new Map<string, ProviderGroup>()
string,
{ provider: string; models: FlattenedModel[] }
>()
for (const model of models) { for (const model of models) {
// For server models, strip "Server · " prefix for cleaner grouping const existing = groups.get(model.providerLabel)
const key =
model.source === "server"
? model.providerLabel.replace(/^Server · /, "")
: model.providerLabel
const existing = groups.get(key)
if (existing) { if (existing) {
existing.models.push(model) existing.models.push(model)
} else { } else {
groups.set(key, { provider: model.provider, models: [model] }) groups.set(model.providerLabel, {
provider: model.provider,
models: [model],
})
} }
} }
return groups return groups
@@ -88,6 +95,72 @@ export function shortModelName(id: string): string {
return short 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({ export function ModelSelector({
models, models,
selectedModelId, selectedModelId,
@@ -146,6 +219,52 @@ export function ModelSelector({
setOpen(false) 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 const tooltipContent = selectedModel
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}` ? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}` : `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
@@ -186,329 +305,244 @@ export function ModelSelector({
return ( return (
<div ref={wrapperRef} className="min-w-0 max-w-44"> <div ref={wrapperRef} className="min-w-0 max-w-44">
<ModelSelectorRoot <Dialog
open={open} open={open}
onOpenChange={(next) => { onOpenChange={(next) => {
if (next) toSettingsRef.current = false if (next) toSettingsRef.current = false
setOpen(next) setOpen(next)
}} }}
> >
<ModelSelectorTrigger asChild> {/* The dialog trigger must wrap the button itself: Radix
<ButtonWithTooltip Tooltip's root renders no element to pass its props to */}
tooltipContent={tooltipContent} <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <DialogTrigger asChild>
disabled={disabled} <Button
className={cn( variant="ghost"
"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", size="sm"
!showLabel && "px-1.5 justify-center", disabled={disabled}
)} className={cn(
// accessibility: expose label to screen readers "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",
aria-label={tooltipContent} !showLabel && "px-1.5 justify-center",
data-testid="model-selector" )}
> // accessibility: expose label to screen readers
{selectedModel ? ( aria-label={tooltipContent}
<ProviderLogo data-testid="model-selector"
provider={selectedModel.provider} >
className="size-3.5 flex-shrink-0 opacity-80" {selectedModel ? (
/> <ProviderLogo
) : ( provider={selectedModel.provider}
<Bot className="h-4 w-4 flex-shrink-0" /> className="size-3.5 flex-shrink-0 opacity-80"
)} />
{/* show/hide visible label based on measured width */} ) : (
{showLabel ? ( <Bot className="h-4 w-4 flex-shrink-0" />
<span className="min-w-0 truncate text-xs"> )}
{selectedModel {/* show/hide visible label based on measured width */}
? shortModelName(selectedModel.modelId) {showLabel ? (
: dict.modelConfig.serverDefault} <span className="min-w-0 truncate text-xs">
</span> {selectedModel
) : ( ? shortModelName(
// Keep an sr-only label for screen readers when hidden selectedModel.modelId,
<span className="sr-only"> )
{selectedModel : dict.modelConfig.serverDefault}
? selectedModel.modelId </span>
: dict.modelConfig.serverDefault} ) : (
</span> // Keep an sr-only label for screen readers when hidden
)} <span className="sr-only">
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> {selectedModel
</ButtonWithTooltip> ? selectedModel.modelId
</ModelSelectorTrigger> : 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 <DialogContent
title={dict.modelConfig.selectModel} className="p-0"
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
if (!toSettingsRef.current) return if (!toSettingsRef.current) return
toSettingsRef.current = false toSettingsRef.current = false
e.preventDefault() e.preventDefault()
}} }}
> >
<ModelSelectorInput <DialogTitle className="sr-only">
placeholder={dict.modelConfig.searchModels} {dict.modelConfig.selectModel}
/> </DialogTitle>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <Command className="**:data-[slot=command-input-wrapper]:h-auto">
<div className="flex-1 min-h-0 overflow-hidden"> <CommandInput
<ModelSelectorList className="overflow-y-auto scrollbar-thin"> className="h-auto py-3.5"
<ModelSelectorEmpty> placeholder={dict.modelConfig.searchModels}
{dict.modelConfig.noModelsFound} />
</ModelSelectorEmpty> <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 */} {/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && ( {serverModels.length === 0 && (
<ModelSelectorGroup <CommandGroup
heading={dict.modelConfig.default} heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
> >
<Check <CommandItem
value="__server_default__"
onSelect={handleSelect}
className={cn( className={cn(
"mr-2 h-4 w-4", "cursor-pointer",
!selectedModelId !selectedModelId &&
? "opacity-100" "bg-accent",
: "opacity-0",
)} )}
>
<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" /> {renderGroups(
<ModelSelectorName> groupedServerModels,
{dict.modelConfig.serverDefault} "server",
</ModelSelectorName> (model) =>
</ModelSelectorItem> model.isDefault && (
</ModelSelectorGroup> <span
)} title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
),
)}
</>
)}
{/* Server Models Section */} {/* User Models Section */}
{serverModels.length > 0 && ( {userModels.length > 0 && (
<> <>
<ModelSelectorSectionHeader {serverModels.length > 0 && (
icon={<Monitor />} <CommandSeparator />
label={ )}
dict.modelConfig.serverModels <SectionHeader
} icon={<User />}
/> label={
{Array.from( dict.modelConfig.userModels
groupedServerModels.entries(), }
).map( />
([ {renderGroups(
providerLabel, groupedUserModels,
{ "user",
provider, (model) =>
models: providerModels, model.validated !==
}, true && (
]) => ( <span
<ModelSelectorGroup className={cn(
key={`server-${providerLabel}`} "ml-auto shrink-0 pl-2 text-xs",
heading={providerLabel} model.validated ===
className="[&>[cmdk-group-heading]]:pl-4" false
> ? "text-destructive"
{providerModels.map( : "text-muted-foreground",
(model) => ( )}
<ModelSelectorItem >
key={model.id} {model.validated ===
// Unique value so same-named models highlight false
// separately; keywords keep search by name ? dict
value={model.id} .modelConfig
keywords={[ .modelFailed
model.modelId, : dict
providerLabel, .modelConfig
]} .modelUntested}
onSelect={() => </span>
handleSelect( ),
model.id, )}
) </>
} )}
className="cursor-pointer" </ModelList>
>
<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>
)}
</div> </div>
)} {/* Pinned footer: add a provider, configure models
</div> (z-10 above list shadow). Buttons, outside the
</ModelSelectorContent> search: reachable with Tab, never filtered out */}
</ModelSelectorRoot> {(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> </div>
) )
} }
+2 -35
View File
@@ -58,9 +58,6 @@ interface ProviderCredentialsFieldsProps {
name?: string name?: string
baseUrl?: string baseUrl?: string
awsRegion?: string awsRegion?: string
// Bedrock: also offer an API key (the user dialog; the admin panel
// stores access keys only)
bedrockApiKey?: boolean
disabled?: boolean disabled?: boolean
// Update a plain text field // Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
@@ -68,9 +65,6 @@ interface ProviderCredentialsFieldsProps {
// UI (e.g. the user dialog's inline Test button + validation error); the // UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it. // shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode 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 // 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 // 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 // signing in that is chosen. Switching it empties the other way's
@@ -91,11 +85,9 @@ export function ProviderCredentialsFields({
name, name,
baseUrl, baseUrl,
awsRegion, awsRegion,
bedrockApiKey,
disabled, disabled,
onChange, onChange,
renderSecret, renderSecret,
footer,
settingsLayout, settingsLayout,
clearSecrets, clearSecrets,
bedrockFilled, bedrockFilled,
@@ -269,10 +261,8 @@ export function ProviderCredentialsFields({
</div> </div>
) )
// EdgeOne needs no credentials — the caller supplies just a test button // EdgeOne needs no credentials
if (provider === "edgeone") { if (provider === "edgeone") return null
return <div className="space-y-5">{footer}</div>
}
if (settingsLayout) { if (settingsLayout) {
return ( return (
@@ -447,7 +437,6 @@ export function ProviderCredentialsFields({
</> </>
)} )}
</div> </div>
{footer}
</div> </div>
) )
} }
@@ -458,26 +447,6 @@ export function ProviderCredentialsFields({
{provider === "bedrock" ? ( {provider === "bedrock" ? (
<> <>
{/* 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 */} {/* AWS Access Key ID */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
@@ -535,8 +504,6 @@ export function ProviderCredentialsFields({
{baseUrlField} {baseUrlField}
</> </>
)} )}
{footer}
</div> </div>
) )
} }
+6 -12
View File
@@ -2,7 +2,7 @@
import { Coffee, Settings, X } from "lucide-react" import { Coffee, Settings, X } from "lucide-react"
import type React from "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 { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
@@ -28,16 +28,10 @@ export function QuotaLimitToast({
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString() n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const quotaMessage = isTokenLimit const quotaMessage = isTokenLimit
? isSelfHosted ? dict.quota.messageToken
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken) : dict.quota.messageApi
: dict.quota.messageToken
: isSelfHosted
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
: dict.quota.messageApi
const tipHtml = isSelfHosted const tipHtml = isSelfHosted ? dict.quota.tipSelfHosted : dict.quota.tip
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
: dict.quota.tip
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
@@ -52,7 +46,7 @@ export function QuotaLimitToast({
aria-live="polite" aria-live="polite"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} 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 */} {/* Close button */}
<button <button
@@ -115,7 +109,7 @@ export function QuotaLimitToast({
rel="noopener noreferrer" 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" 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} {dict.quota.selfHost}
</a> </a>
<a <a
+3 -3
View File
@@ -1,5 +1,4 @@
import { import {
Github,
Info, Info,
Monitor, Monitor,
Moon, Moon,
@@ -13,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { useChatEngine } from "@/components/chat/chat-engine" import { useChatEngine } from "@/components/chat/chat-engine"
import { GitHubIcon } from "@/components/icons/github"
import { ModelConfigDialog } from "@/components/model-config-dialog" import { ModelConfigDialog } from "@/components/model-config-dialog"
import { SettingsHeader } from "@/components/settings/settings-header" import { SettingsHeader } from "@/components/settings/settings-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -510,7 +510,7 @@ function AboutTab() {
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true" const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true" const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}` const aboutPath = `/${lang}/about`
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -553,7 +553,7 @@ function AboutTab() {
rel="noopener noreferrer" 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" 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 GitHub
</a> </a>
{showAbout && ( {showAbout && (
-11
View File
@@ -12,14 +12,6 @@ function AlertDialog({
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> 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({ function AlertDialogPortal({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
@@ -144,9 +136,6 @@ function AlertDialogCancel({
export { export {
AlertDialog, AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent, AlertDialogContent,
AlertDialogHeader, AlertDialogHeader,
AlertDialogFooter, AlertDialogFooter,
-51
View File
@@ -5,13 +5,6 @@ import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react" import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({ function Command({
className, 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({ function CommandInput({
className, className,
...props ...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 { export {
Command, Command,
CommandDialog,
CommandInput, CommandInput,
CommandList, CommandList,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandItem, CommandItem,
CommandShortcut,
CommandSeparator, CommandSeparator,
} }
+1 -10
View File
@@ -24,12 +24,6 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> 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({ function DialogOverlay({
className, className,
...props ...props
@@ -65,7 +59,7 @@ function DialogContent({
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]", "max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
"grid gap-4 p-6", "grid gap-4 p-6",
// Refined visual treatment // 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 // Entry/exit animations
"data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
@@ -151,13 +145,10 @@ function DialogDescription({
export { export {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} }
+1 -7
View File
@@ -39,10 +39,4 @@ function PopoverContent({
) )
} }
function PopoverAnchor({ export { Popover, PopoverTrigger, PopoverContent }
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+1 -1
View File
@@ -55,4 +55,4 @@ function ScrollBar({
) )
} }
export { ScrollArea, ScrollBar } export { ScrollArea }
-37
View File
@@ -12,12 +12,6 @@ function Select({
return <SelectPrimitive.Root data-slot="select" {...props} /> 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({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: 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({ function SelectItem({
className, className,
children, 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({ function SelectScrollUpButton({
className, className,
...props ...props
@@ -176,12 +144,7 @@ function SelectScrollDownButton({
export { export {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} }
+1 -15
View File
@@ -13,9 +13,7 @@ interface IconButtonProps extends React.ComponentProps<"button"> {
label: string label: string
/** Shown next to the label in the tooltip, e.g. "⌘B" */ /** Shown next to the label in the tooltip, e.g. "⌘B" */
shortcut?: string shortcut?: string
active?: boolean
tooltipSide?: "top" | "bottom" | "left" | "right" tooltipSide?: "top" | "bottom" | "left" | "right"
size?: "sm" | "md"
} }
/** Square icon button with a tooltip; the tooltip text is also its label */ /** Square icon button with a tooltip; the tooltip text is also its label */
@@ -24,9 +22,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{ {
label, label,
shortcut, shortcut,
active = false,
tooltipSide = "bottom", tooltipSide = "bottom",
size = "md",
className, className,
children, children,
...props ...props
@@ -40,17 +36,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
ref={ref} ref={ref}
type="button" type="button"
aria-label={label} aria-label={label}
aria-pressed={active || undefined}
className={cn( 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", "hover:bg-accent hover:text-foreground",
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring", "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
"disabled:pointer-events-none disabled:opacity-35", "disabled:pointer-events-none disabled:opacity-35",
"[&_svg]:size-4 [&_svg]:stroke-[1.75]", "[&_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, className,
)} )}
{...props} {...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 />
}
-1
View File
@@ -150,7 +150,6 @@ function WorkspaceLayout() {
const showHero = const showHero =
engine.isRestored && engine.isRestored &&
engine.messages.length === 0 && engine.messages.length === 0 &&
!engine.isResending &&
!isRealDiagram(chartXML) && !isRealDiagram(chartXML) &&
!heroDismissed !heroDismissed
+117 -146
View File
@@ -78,20 +78,16 @@ interface DiagramContextType {
chartXML: string chartXML: string
// chartXML right away, before the re-render (loadDiagram sets both) // chartXML right away, before the re-render (loadDiagram sets both)
chartXMLRef: React.MutableRefObject<string> chartXMLRef: React.MutableRefObject<string>
latestSvg: string // The diagram's latest SVG export, a fallback thumbnail for saves
latestSvgRef: React.MutableRefObject<string>
loadDiagram: ( loadDiagram: (
chart: string, chart: string,
skipValidation?: boolean, skipValidation?: boolean,
mode?: LoadMode, mode?: LoadMode,
meta?: { toolCallId?: string; pageId?: string | null }, meta?: { toolCallId?: string; pageId?: string | null },
) => string | null ) => string | null
// Returns the export's tag (empty when draw.io is not there yet) /** The XML of the page on screen, exported from draw.io */
handleExport: () => string fetchDiagramXml: () => Promise<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>
>
drawioRef: React.MutableRefObject<DrawioFrameHandle | null> drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
handleDiagramExport: (data: DrawioExportEvent) => void handleDiagramExport: (data: DrawioExportEvent) => void
handleDiagramAutoSave: (data: { xml?: string }) => void handleDiagramAutoSave: (data: { xml?: string }) => void
@@ -101,7 +97,7 @@ interface DiagramContextType {
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
successMessage?: string, successMessage?: string,
) => void ) => Promise<void>
getThumbnailSvg: () => Promise<string | null> getThumbnailSvg: () => Promise<string | null>
getVersionSvg: () => Promise<string | null> getVersionSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null> captureValidationPng: () => Promise<string | null>
@@ -135,11 +131,11 @@ const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// echoes the request back in the export event, so each result reaches its // 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 // own caller. Tags end in a request number, so a late result never answers
// a newer request. // a newer request.
type ExportTag = "thumbnail" | "validation" | "version" type ExportTag = "thumbnail" | "validation" | "version" | "fetch" | "save"
export function DiagramProvider({ children }: { children: React.ReactNode }) { export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("") const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("") const latestSvgRef = useRef("")
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const mountedRef = useRef(true) const mountedRef = useRef(true)
@@ -152,7 +148,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const drawioRef = useRef<DrawioFrameHandle | null>(null) const drawioRef = useRef<DrawioFrameHandle | null>(null)
// Pending exports, keyed by their export tag // Pending exports, keyed by their export tag
const exportResolversRef = useRef< const exportResolversRef = useRef<
Record<string, (data: string, xml?: string) => void> Record<string, (result: ExportResult) => void>
>({}) >({})
const exportSeqRef = useRef(0) const exportSeqRef = useRef(0)
// Track latest chartXML for restoration after remount // Track latest chartXML for restoration after remount
@@ -217,26 +213,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setChartXML(xml) 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 // 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 // event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally. // expected occasionally.
// (Reads refs only, so it keeps one identity) // (Reads refs only, so it keeps one identity)
const requestTaggedExport = useCallback( const requestTaggedExport = useCallback(
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) => (tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
new Promise<string | null>((resolve) => { new Promise<ExportResult | null>((resolve) => {
const id = `${tag}-${++exportSeqRef.current}` const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: string | null) => { const finish = (value: ExportResult | null) => {
clearTimeout(timer) clearTimeout(timer)
delete exportResolversRef.current[id] delete exportResolversRef.current[id]
resolve(value) resolve(value)
@@ -249,20 +234,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
) )
// Get current diagram as SVG for thumbnail (used by session storage). // Get current diagram as SVG for thumbnail (used by session storage).
// One identity: the chat's auto-save depends on it, and each thumbnail // One identity: the chat's auto-save effect depends on it
// renders this provider again (latestSvg), which would otherwise start
// the next save
const getThumbnailSvg = useCallback(async (): Promise<string | null> => { const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
if (!drawioRef.current) return null if (!drawioRef.current) return null
// Don't export if diagram is empty // Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL // xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000) const result = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) { if (result?.data?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves // Kept for later saves that take no fresh thumbnail
setLatestSvg(svgData) latestSvgRef.current = result.data
return svgData return result.data
} }
return null return null
}, [requestTaggedExport]) }, [requestTaggedExport])
@@ -270,8 +253,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Plain SVG (no embedded diagram data) for version cards // Plain SVG (no embedded diagram data) for version cards
const getVersionSvg = async (): Promise<string | null> => { const getVersionSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null if (!drawioRef.current) return null
const svgData = await requestTaggedExport("version", "svg", 4000) const result = await requestTaggedExport("version", "svg", 4000)
return svgData?.startsWith("data:image/svg") ? svgData : null return result?.data?.startsWith("data:image/svg") ? result.data : null
} }
// Capture current diagram as PNG for VLM validation // Capture current diagram as PNG for VLM validation
@@ -280,9 +263,27 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Don't export if diagram is empty // Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null 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 // 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 // Like requestTaggedExport, with the request's other parameters (a PNG's
@@ -301,8 +302,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolve(value) resolve(value)
} }
const timer = setTimeout(() => finish(null), timeoutMs) const timer = setTimeout(() => finish(null), timeoutMs)
exportResolversRef.current[id] = (data, xml) => exportResolversRef.current[id] = finish
finish({ data, xml })
drawioRef.current.exportDiagram({ ...request, message: id }) drawioRef.current.exportDiagram({ ...request, message: id })
}), }),
[], [],
@@ -406,34 +406,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resetPreview() resetPreview()
} }
// Each export result goes to the caller that asked for it, by its tag
const handleDiagramExport = (data: DrawioExportEvent) => { const handleDiagramExport = (data: DrawioExportEvent) => {
// Thumbnail, version, validation PNG, file save and requestExport
// exports go only to their own caller
const tag = data.message?.message const tag = data.message?.message
if (/^(thumbnail|validation|version|save|export)-/.test(tag ?? "")) { if (!tag) return
exportResolversRef.current[tag as string]?.(data.data, data.xml) exportResolversRef.current[tag]?.({ data: data.data, xml: 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)
}
} }
// The frame registers this callback once per iframe mount, so it must // The frame registers this callback once per iframe mount, so it must
@@ -459,10 +436,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const emptyDiagram = BLANK_MXFILE const emptyDiagram = BLANK_MXFILE
// Skip validation for trusted internal template (loadDiagram also sets chartXML) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(emptyDiagram, true) loadDiagram(emptyDiagram, true)
setLatestSvg("") latestSvgRef.current = ""
} }
const saveDiagramToFile = ( const saveDiagramToFile = async (
filename: string, filename: string,
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
@@ -477,85 +454,80 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const drawioFormat = const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Each save has its own tag, so two at once never swap results // Each save has its own tag, so two at once never swap results. A
const tag = `save-${++exportSeqRef.current}` // large PNG takes a while to render.
exportResolversRef.current[tag] = ( const result = await requestTaggedExport("save", drawioFormat, 30000)
exportData: string, if (!result?.data) {
fullDiagramXML?: string, console.warn("Diagram export for saving timed out")
) => { return
delete exportResolversRef.current[tag] }
let fileContent: string | Blob const exportData = result.data
let mimeType: string const fullDiagramXML = result.xml
let extension: string let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") { if (format === "drawio") {
// Prefer the complete document from the export event so all pages are saved. // Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim() const xml = fullDiagramXML?.trim()
? fullDiagramXML ? fullDiagramXML
: extractDiagramXML(exportData) : extractDiagramXML(exportData)
fileContent = fileContent =
normalizeToMxfile(xml, { normalizeToMxfile(xml, {
pageId: "page-1", pageId: "page-1",
pageName: "Page-1", pageName: "Page-1",
}) ?? xml }) ?? xml
mimeType = "application/xml" mimeType = "application/xml"
extension = ".drawio" extension = ".drawio"
} else if (format === "png") { } else if (format === "png") {
// PNG data comes as base64 data URL // PNG data comes as base64 data URL
fileContent = exportData fileContent = exportData
mimeType = "image/png" mimeType = "image/png"
extension = ".png" extension = ".png"
} else if (format === "xmlsvg") { } else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG) // Editable SVG: pass data URL directly (like PNG)
fileContent = exportData fileContent = exportData
mimeType = "image/svg+xml" mimeType = "image/svg+xml"
extension = ".drawio.svg" extension = ".drawio.svg"
} else { } else {
// SVG format (view-only) // SVG format (view-only)
fileContent = exportData fileContent = exportData
mimeType = "image/svg+xml" mimeType = "image/svg+xml"
extension = ".svg" 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)
}
} }
// Export diagram - callback will be handled in handleDiagramExport // Log save event to Langfuse (flags the trace)
drawioRef.current.exportDiagram({ logSaveToLangfuse(filename, format, sessionId)
format: drawioFormat,
message: tag, // 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) // Log save event to Langfuse (just flags the trace, doesn't send content)
@@ -586,10 +558,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
value={{ value={{
chartXML, chartXML,
chartXMLRef, chartXMLRef,
latestSvg, latestSvgRef,
loadDiagram, loadDiagram,
handleExport, fetchDiagramXml,
exportResolversRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleDiagramAutoSave, handleDiagramAutoSave,
+1 -1
View File
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) | | `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
| `models` | 是 | 模型 ID 列表 | | `models` | 是 | 模型 ID 列表 |
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 | | `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) | | `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 | | `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
### 说明 ### 说明
+8 -38
View File
@@ -68,47 +68,15 @@ cp env.example .env.local
## 第三步 — 选择部署类型 ## 第三步 — 选择部署类型
### 选项 A:不使用 R2 部署(简单,免费) ### 选项 A:不使用 R2 部署(默认,免费)
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署: 仓库自带的就是这套配置:`open-next.config.ts` 为 `defineCloudflareConfig({})`,`wrangler.jsonc` 中没有 `r2_buckets`。无需改动任何文件,直接跳至 **第四步**。
**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"
}
]
}
```
直接跳至 **第四步**。
--- ---
### 选项 B:使用 R2 部署(完整的 ISR 支持) ### 选项 B:使用 R2 部署(可选,用于 ISR 缓存)
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。 R2 为 OpenNext 提供存放 **增量静态再生 (ISR)** 页面的空间。本应用目前没有 ISR 页面,只有在您自己添加了此类页面时才需要这一步。需要在您的 Cloudflare 账户中绑定支付方式。
**1. 在 Cloudflare 控制台中创建 R2 存储桶:** **1. 在 Cloudflare 控制台中创建 R2 存储桶:**
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
}) })
``` ```
**3. 配置 `wrangler.jsonc`(包含 R2):** **3. 在 `wrangler.jsonc` 中加入 `r2_buckets`:**
```jsonc ```jsonc
{ {
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。 > **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
> **注意:** 定时重新验证(页面或 fetch 上的 `revalidate`)还需要在 `open-next.config.ts` 中配置 `queue`,请参阅 OpenNext 的 Cloudflare 缓存文档。
--- ---
## 第四步 — 注册 workers.dev 子域名(仅首次需要) ## 第四步 — 注册 workers.dev 子域名(仅首次需要)
@@ -264,4 +234,4 @@ npm run preview
| API 路由 | 支持 | 支持 | | API 路由 | 支持 | 支持 |
| 配置复杂度 | 简单 | 中等 | | 配置复杂度 | 简单 | 中等 |
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。 仓库默认 **不使用 R2**。只有当您添加了需要 ISR 缓存的页面时,才选择 **使用 R2**。
+1 -1
View File
@@ -407,7 +407,7 @@ This is shorthand for the equivalent `ai-models.json`. For multiple providers or
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) | | `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
| `models` | Yes | List of model IDs | | `models` | Yes | List of model IDs |
| `default` | No | Set to `true` to auto-select this provider's first model as default | | `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 | | `baseUrlEnv` | No | Custom base URL env var name |
### Notes ### Notes
+8 -38
View File
@@ -68,47 +68,15 @@ Fill in the same values there.
## Step 3 — Choose your deployment type ## 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: 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**.
**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**.
--- ---
### 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: **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 ```jsonc
{ {
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard. > **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) ## Step 4 — Register a workers.dev subdomain (first-time only)
@@ -264,4 +234,4 @@ npm run preview
| API Routes | Yes | Yes | | API Routes | Yes | Yes |
| Setup Complexity | Simple | Moderate | | 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.
+1 -1
View File
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) | | `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
| `models` | はい | モデルIDのリスト | | `models` | はい | モデルIDのリスト |
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます | | `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) | | `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数)。環境変数名の配列も指定でき、リクエストごとに値のある変数から1つがランダムに選ばれます(複数キーへの負荷分散) |
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 | | `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
### 備考 ### 備考
+8 -38
View File
@@ -68,47 +68,15 @@ cp env.example .env.local
## ステップ 3 — デプロイタイプの選択 ## ステップ 3 — デプロイタイプの選択
### オプション A: R2 なしでのデプロイ(シンプル、無料) ### オプション A: R2 なしでのデプロイ(デフォルト、無料)
ISR キャッシュが不要な場合は、R2 なしでデプロイできます: リポジトリはこの構成のままです。`open-next.config.ts` は `defineCloudflareConfig({})` で、`wrangler.jsonc` に `r2_buckets` はありません。ファイルを変更する必要はないので、**ステップ 4** へ進んでください。
**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** へ進んでください。
--- ---
### オプション B: R2 ありでのデプロイ(完全な ISR サポート) ### オプション B: R2 ありでのデプロイ(オプション、ISR キャッシュ用)
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。 R2 は OpenNext が **Incremental Static Regeneration (ISR)** ページを保存する場所になります。このアプリには現在 ISR ページがないため、自分で ISR ページを追加した場合にだけ必要です。Cloudflare アカウントに支払い方法の登録が必要です。
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて): **1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
}) })
``` ```
**3. `wrangler.jsonc` の設定(R2 あり):** **3. `wrangler.jsonc` に `r2_buckets` を追加:**
```jsonc ```jsonc
{ {
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。 > **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
> **注意:** 時間ベースの再検証(ページや fetch の `revalidate`)には、`open-next.config.ts` で `queue` の設定も必要です。OpenNext の Cloudflare キャッシュドキュメントを参照してください。
--- ---
## ステップ 4 — workers.dev サブドメインの登録(初回のみ) ## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
@@ -264,4 +234,4 @@ npm run preview
| API ルート | あり | あり | | API ルート | あり | あり |
| 設定の複雑さ | シンプル | 普通 | | 設定の複雑さ | シンプル | 普通 |
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。 リポジトリのデフォルトは **R2 なし** です。ISR キャッシュが必要なページを追加した場合にだけ **R2 あり** を選んでください。
+14 -56
View File
@@ -29,6 +29,7 @@ const messageItemSchema = z
}) })
.passthrough() .passthrough()
// The fields read here; every other field is passed on to AI.chatCompletions
const messageSchema = z const messageSchema = z
.object({ .object({
messages: z.array(messageItemSchema), messages: z.array(messageItemSchema),
@@ -36,21 +37,6 @@ const messageSchema = z
stream: z.boolean().optional(), stream: z.boolean().optional(),
tools: z.any().optional(), tools: z.any().optional(),
tool_choice: 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() .passthrough()
@@ -61,12 +47,6 @@ const ALLOWED_MODELS = [
"@tx/deepseek-ai/deepseek-v3-0324", "@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 * Create standardized JSON response
*/ */
@@ -145,8 +125,16 @@ export async function onRequest({ request, env }: any) {
) )
} }
const { messages, model, stream, tools, tool_choice, ...extraParams } = // stream is taken out so it is not passed on: AI.chatCompletions
parseResult.data // only streams, and every caller reads a stream
const {
messages,
model,
stream: _,
tools,
tool_choice,
...extraParams
} = parseResult.data
// Validate messages // Validate messages
const userMessages = messages.filter( const userMessages = messages.filter(
@@ -165,14 +153,13 @@ export async function onRequest({ request, env }: any) {
} }
// Resolve model // Resolve model
const requestedModel = model || ALLOWED_MODELS[0] const selectedModel = model || ALLOWED_MODELS[0]
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
if (!ALLOWED_MODELS.includes(selectedModel)) { if (!ALLOWED_MODELS.includes(selectedModel)) {
return createResponse( return createResponse(
{ {
error: { error: {
message: `Invalid model: ${requestedModel}.`, message: `Invalid model: ${selectedModel}.`,
type: "invalid_request_error", type: "invalid_request_error",
}, },
}, },
@@ -181,39 +168,10 @@ export async function onRequest({ request, env }: any) {
} }
console.log( console.log(
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`, `[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}`,
) )
try { 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 // Build AI.chatCompletions options for streaming
const aiOptions: any = { const aiOptions: any = {
...extraParams, ...extraParams,
+3 -69
View File
@@ -2,29 +2,6 @@
* Type declarations for Electron API exposed via preload script * 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 */ /** Proxy configuration interface */
interface ProxyConfig { interface ProxyConfig {
httpProxy?: string httpProxy?: string
@@ -35,7 +12,6 @@ interface ProxyConfig {
interface SetProxyResult { interface SetProxyResult {
success: boolean success: boolean
error?: string error?: string
devMode?: boolean
} }
/** Result of setting user locale */ /** Result of setting user locale */
@@ -48,31 +24,17 @@ declare global {
interface Window { interface Window {
/** Main window Electron API */ /** Main window Electron API */
electronAPI?: { electronAPI?: {
/** Current platform (darwin, win32, linux) */
platform: NodeJS.Platform
/** Whether running in Electron environment */ /** Whether running in Electron environment */
isElectron: boolean 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 */ /** Get proxy configuration */
getProxy: () => Promise<ProxyConfig> getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */ /** Set proxy configuration (saves and restarts server) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult> setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
/** Get user's preferred locale */
getUserLocale: () => Promise<
"en" | "zh" | "ja" | "zh-Hant" | undefined
>
/** Set user's preferred locale */ /** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult> setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
/** /**
* Call back after the server restarted on the same port (another * Call back after the server restarted on the same port (proxy
* preset); returns a function that stops the calls * change); returns a function that stops the calls
*/ */
onServerRestarted?: (callback: () => void) => () => void onServerRestarted?: (callback: () => void) => () => void
/** A chat was saved: open this port next launch */ /** A chat was saved: open this port next launch */
@@ -80,35 +42,7 @@ declare global {
/** The page loaded with this many chats */ /** The page loaded with this many chats */
chatsLoaded?: (count: number) => Promise<void> 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 { export type { ProxyConfig }
ApplyPresetResult,
ConfigPreset,
ProxyConfig,
SetProxyResult,
SetUserLocaleResult,
}
+2 -161
View File
@@ -1,23 +1,9 @@
import { import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
app,
BrowserWindow,
dialog,
Menu,
type MenuItemConstructorOptions,
shell,
} from "electron"
import {
applyPresetToEnv,
getAllPresets,
getCurrentPresetId,
setCurrentPreset,
} from "./config-manager"
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n" import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
import { restartNextServer } from "./next-server"
import { showSettingsWindow } from "./settings-window"
/** /**
* Build and set the application menu with i18n support * Build and set the application menu with i18n support
* (call again when the language changes)
*/ */
export function buildAppMenu(): void { export function buildAppMenu(): void {
const template = getMenuTemplate() const template = getMenuTemplate()
@@ -25,75 +11,6 @@ export function buildAppMenu(): void {
Menu.setApplicationMenu(menu) 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 * Get the menu template with translations
*/ */
@@ -113,15 +30,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
submenu: [ submenu: [
{ role: "about" }, // System-translated { role: "about" }, // System-translated
{ type: "separator" }, { type: "separator" },
{
label: t.settings,
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
{ type: "separator" },
{ role: "services" }, // System-translated { role: "services" }, // System-translated
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, // System-translated { role: "hide" }, // System-translated
@@ -137,19 +45,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
template.push({ template.push({
label: t.file, label: t.file,
submenu: [ 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 isMac ? { role: "close" } : { role: "quit" }, // System-translated
], ],
}) })
@@ -196,9 +91,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
], ],
}) })
// Configuration menu with presets
template.push(buildConfigMenu(t))
// Window menu // Window menu
template.push({ template.push({
label: t.window, label: t.window,
@@ -239,54 +131,3 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
return template 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
View File
@@ -1,570 +1,57 @@
import { randomUUID } from "node:crypto" import { app } from "electron"
import { import * as fs from "fs"
existsSync, import * as path from "path"
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs"
import path from "node:path"
import { app, safeStorage } from "electron"
/** export const USER_LOCALES = ["en", "zh", "ja", "zh-Hant"] as const
* Fields that contain sensitive data and should be encrypted export type UserLocale = (typeof USER_LOCALES)[number]
*/
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
/** // Earlier versions kept their configuration presets in this file too; the
* Prefix to identify encrypted values // name stays so the saved language survives the upgrade
*/ const CONFIG_FILE = "config-presets.json"
const ENCRYPTED_PREFIX = "encrypted:"
/** function getConfigPath(): string {
* Check if safeStorage encryption is available return path.join(app.getPath("userData"), CONFIG_FILE)
*/
function isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable()
} }
/** /**
* 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 function loadConfig(): Record<string, unknown> {
/**
* 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
}
try { try {
const encrypted = safeStorage.encryptString(value) const configPath = getConfigPath()
return ENCRYPTED_PREFIX + encrypted.toString("base64") if (fs.existsSync(configPath)) {
} catch (error) { const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
console.error("Encryption failed:", error) if (data && typeof data === "object" && !Array.isArray(data)) {
// Fail secure: don't store if encryption fails return data
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
} }
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) { } 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 * Get the menu locale the user chose, if any
* Returns the environment variables that were applied
*/ */
export function applyPresetToEnv(id: string): Record<string, string> | null { export function getUserLocale(): UserLocale | undefined {
const data = loadPresets() const { userLocale } = loadConfig()
const preset = data.presets.find((p) => p.id === id) return (USER_LOCALES as readonly unknown[]).includes(userLocale)
? (userLocale as UserLocale)
if (!preset) { : undefined
return null
}
const appliedEnv = setPresetEnv(preset)
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
} }
/** /**
* Apply the saved current preset's environment variables (used at startup) * Save the menu locale the user chose
*/ */
export function applyCurrentPresetToEnv(): void { export function setUserLocale(locale: UserLocale): void {
setPresetEnv(getCurrentPreset()) 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
* Get user's preferred locale from config // a truncated file
* Returns undefined if not set const tempPath = `${configPath}.tmp`
*/ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined { fs.renameSync(tempPath, configPath)
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)
} }
+3 -58
View File
@@ -1,5 +1,6 @@
import fs from "node:fs" import fs from "node:fs"
import path from "node:path" import path from "node:path"
import { parse } from "dotenv"
import { app } from "electron" import { app } from "electron"
/** /**
@@ -28,69 +29,13 @@ export function loadEnvFile(): void {
console.log("No .env file found, using system environment variables") 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 * Parse and load environment variables from a file
*/ */
function loadEnvFromFile(filePath: string): void { function loadEnvFromFile(filePath: string): void {
try { try {
const content = fs.readFileSync(filePath, "utf-8") const parsed = parse(fs.readFileSync(filePath, "utf-8"))
const lines = content.split("\n") for (const [key, value] of Object.entries(parsed)) {
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)
}
}
// Don't override existing environment variables // Don't override existing environment variables
if (!(key in process.env)) { if (!(key in process.env)) {
process.env[key] = value process.env[key] = value
-8
View File
@@ -1,11 +1,9 @@
import { app, BrowserWindow, dialog, shell } from "electron" import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu" import { buildAppMenu } from "./app-menu"
import { applyCurrentPresetToEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader" import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers" import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server" import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager" import { applyProxyToEnv } from "./proxy-manager"
import { registerSettingsWindowHandlers } from "./settings-window"
import { import {
createWindow, createWindow,
getAppUrl, getAppUrl,
@@ -36,14 +34,8 @@ if (!gotTheLock) {
const isDev = !app.isPackaged const isDev = !app.isPackaged
app.whenReady().then(async () => { 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 // Register IPC handlers
registerIpcHandlers() registerIpcHandlers()
registerSettingsWindowHandlers()
// Build application menu // Build application menu
buildAppMenu() buildAppMenu()
+11 -171
View File
@@ -1,17 +1,6 @@
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron" import { app, type IpcMainInvokeEvent, ipcMain } from "electron"
import { rebuildAppMenu, switchPreset } from "./app-menu" import { buildAppMenu } from "./app-menu"
import { import { setUserLocale, USER_LOCALES, type UserLocale } from "./config-manager"
type ConfigPreset,
createPreset,
deletePreset,
getAllPresets,
getCurrentPreset,
getCurrentPresetId,
getUserLocale,
setCurrentPreset,
setUserLocale,
updatePreset,
} from "./config-manager"
import { restartNextServer } from "./next-server" import { restartNextServer } from "./next-server"
import { noteNoChats, rememberChatPort } from "./port-manager" import { noteNoChats, rememberChatPort } from "./port-manager"
import { import {
@@ -23,37 +12,9 @@ import {
import { isAppUrl } from "./window-manager" import { isAppUrl } from "./window-manager"
/** /**
* Allowed configuration keys for presets * Register an IPC handler that only answers the app's own pages (on the app
* This whitelist prevents arbitrary environment variable injection * server). A main window that somehow ends up on an external site still
*/ * gets the preload API, so its calls must be rejected here.
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.
*/ */
function handle<Args extends unknown[]>( function handle<Args extends unknown[]>(
channel: string, channel: string,
@@ -61,7 +22,7 @@ function handle<Args extends unknown[]>(
): void { ): void {
ipcMain.handle(channel, (event, ...args) => { ipcMain.handle(channel, (event, ...args) => {
const url = event.senderFrame?.url const url = event.senderFrame?.url
if (!isAppUrl(url) && !url?.startsWith("file://")) { if (!isAppUrl(url)) {
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`) throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
} }
return listener(event, ...(args as Args)) return listener(event, ...(args as Args))
@@ -72,12 +33,6 @@ function handle<Args extends unknown[]>(
* Register all IPC handlers * Register all IPC handlers
*/ */
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
// ==================== App Info ====================
handle("get-version", () => {
return app.getVersion()
})
// ==================== Where the chats are ==================== // ==================== Where the chats are ====================
// The page saved a chat, or loaded without any: decides which port // The page saved a chat, or loaded without any: decides which port
@@ -87,116 +42,6 @@ export function registerIpcHandlers(): void {
if (count === 0) noteNoChats() 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 ==================== // ==================== Proxy Settings ====================
handle("get-proxy", () => { handle("get-proxy", () => {
@@ -228,7 +73,7 @@ export function registerIpcHandlers(): void {
if (!app.isPackaged) { if (!app.isPackaged) {
// In development, env vars are already applied // In development, env vars are already applied
// Next.js dev server may need manual restart // 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 // Production: restart Next.js server to pick up new env vars
@@ -247,20 +92,15 @@ export function registerIpcHandlers(): void {
// ==================== User Locale ==================== // ==================== User Locale ====================
handle("get-user-locale", () => {
return getUserLocale()
})
handle("set-user-locale", (_event, locale: string) => { handle("set-user-locale", (_event, locale: string) => {
// Validate locale is one of the supported values if (!(USER_LOCALES as readonly string[]).includes(locale)) {
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" } return { success: false, error: "Invalid locale" }
} }
try { try {
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant") setUserLocale(locale as UserLocale)
// Rebuild the menu to reflect the new locale // Rebuild the menu to reflect the new locale
rebuildAppMenu() buildAppMenu()
return { success: true } return { success: true }
} catch (error) { } catch (error) {
return { return {
+27 -113
View File
@@ -3,71 +3,35 @@
* Translations for menu labels that don't use Electron's built-in roles * 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 type MenuLocale = UserLocale
export interface MenuTranslations {
// App menu (macOS only)
settings: string
const en = {
// File menu // File menu
file: string file: "File",
// Edit menu // Edit menu
edit: string edit: "Edit",
// View menu // View menu
view: string view: "View",
// Configuration menu
configuration: string
switchPreset: string
managePresets: string
addConfigurationPreset: string
// Window menu // Window menu
window: string window: "Window",
// Help menu // Help menu
help: string help: "Help",
documentation: string documentation: "Documentation",
reportIssue: string reportIssue: "Report Issue",
} }
type MenuTranslations = typeof en
const translations: Record<MenuLocale, MenuTranslations> = { const translations: Record<MenuLocale, MenuTranslations> = {
en: { en,
// App menu
settings: "Settings...",
// File menu
file: "File",
// Edit menu
edit: "Edit",
// View menu
view: "View",
// Configuration menu
configuration: "Configuration",
switchPreset: "Switch Preset",
managePresets: "Manage Presets...",
addConfigurationPreset: "Add Configuration Preset...",
// Window menu
window: "Window",
// Help menu
help: "Help",
documentation: "Documentation",
reportIssue: "Report Issue",
},
zh: { zh: {
// App menu
settings: "设置...",
// File menu // File menu
file: "文件", file: "文件",
@@ -77,12 +41,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
// View menu // View menu
view: "查看", view: "查看",
// Configuration menu
configuration: "配置",
switchPreset: "切换预设",
managePresets: "管理预设...",
addConfigurationPreset: "添加配置预设...",
// Window menu // Window menu
window: "窗口", window: "窗口",
@@ -93,9 +51,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
}, },
ja: { ja: {
// App menu
settings: "設定...",
// File menu // File menu
file: "ファイル", file: "ファイル",
@@ -105,12 +60,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
// View menu // View menu
view: "表示", view: "表示",
// Configuration menu
configuration: "設定",
switchPreset: "プリセット切り替え",
managePresets: "プリセット管理...",
addConfigurationPreset: "設定プリセットを追加...",
// Window menu // Window menu
window: "ウインドウ", window: "ウインドウ",
@@ -121,9 +70,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
}, },
"zh-Hant": { "zh-Hant": {
// App menu
settings: "設定...",
// File menu // File menu
file: "檔案", file: "檔案",
@@ -133,12 +79,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
// View menu // View menu
view: "檢視", view: "檢視",
// Configuration menu
configuration: "配置",
switchPreset: "切換預設",
managePresets: "管理預設...",
addConfigurationPreset: "新增配置預設...",
// Window menu // Window menu
window: "視窗", window: "視窗",
@@ -150,33 +90,22 @@ const translations: Record<MenuLocale, MenuTranslations> = {
} }
/** /**
* Get menu translations for a given locale * Get menu translations for a locale (English for an unknown one)
* Falls back to English if locale is not supported
*/ */
export function getMenuTranslations(locale: string): MenuTranslations { export function getMenuTranslations(locale: MenuLocale): MenuTranslations {
// Check for zh-Hant before normalizing return translations[locale] ?? translations.en
if (
locale === "zh-Hant" ||
locale.toLowerCase().startsWith("zh-hant") ||
locale.toLowerCase().startsWith("zh-tw") ||
locale.toLowerCase().startsWith("zh-hk")
) {
return translations["zh-Hant"]
}
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
const normalized = locale.toLowerCase().split("-")[0]
if (normalized === "zh") return translations.zh
if (normalized === "ja") return translations.ja
return translations.en
} }
/** /**
* Detect system locale from Electron app * Get locale from stored preference or system default
* Returns one of: "en", "zh", "ja", "zh-Hant" * 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() const lower = appLocale.toLowerCase()
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified // Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
@@ -188,24 +117,9 @@ export function detectSystemLocale(appLocale: string): MenuLocale {
return "zh-Hant" return "zh-Hant"
} }
const normalized = lower.split("-")[0] const language = lower.split("-")[0]
if (normalized === "zh") return "zh" if (language === "zh") return "zh"
if (normalized === "ja") return "ja" if (language === "ja") return "ja"
return "en" 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)
}
+6 -20
View File
@@ -12,7 +12,7 @@ import { setAppUrl } from "./window-manager"
let serverProcess: UtilityProcess | null = null let serverProcess: UtilityProcess | null = null
// Start and restart run one at a time, so overlapping calls (e.g. two quick // 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() let serverQueue: Promise<unknown> = Promise.resolve()
function runExclusive<T>(task: () => Promise<T>): Promise<T> { function runExclusive<T>(task: () => Promise<T>): Promise<T> {
@@ -21,18 +21,6 @@ function runExclusive<T>(task: () => Promise<T>): Promise<T> {
return result 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 * 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> { 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") const serverPath = path.join(resourcePath, "server.js")
console.log(`Starting Next.js server from: ${resourcePath}`) 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() const port = await findAvailablePort()
console.log(`Using port: ${port}`) console.log(`Using port: ${port}`)
@@ -86,7 +75,7 @@ async function startServer(): Promise<string> {
HOSTNAME: "127.0.0.1", HOSTNAME: "127.0.0.1",
// Enable Node.js built-in proxy support for fetch (Node.js 24+) // Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1", 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", 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) // 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 // This is necessary because the packaged app might be on a read-only volume
if (app.isPackaged) { env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache")
const cacheDir = path.join(app.getPath("userData"), "cache")
env.NEXT_CACHE_DIR = cacheDir
}
// Copy existing environment variables // Copy existing environment variables
for (const [key, value] of Object.entries(process.env)) { for (const [key, value] of Object.entries(process.env)) {
+15 -22
View File
@@ -9,8 +9,6 @@ import { app } from "electron"
* (localStorage is origin-specific, so changing ports loses all saved data) * (localStorage is origin-specific, so changing ports loses all saved data)
*/ */
const PORT_CONFIG = { 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 // Legacy production port — tried first to preserve localStorage for existing users
legacyProduction: 61337, legacyProduction: 61337,
// New production port below the ephemeral range (49152-65535) // New production port below the ephemeral range (49152-65535)
@@ -108,9 +106,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
/** /**
* Find an available port * Find an available port
* - In development: uses fixed port (6002) * - Uses the legacy port (61337), then 13370, to preserve localStorage;
* - In production: uses the legacy port (61337), then 13370, to preserve * 13370 first when only it has saved chats
* localStorage; 13370 first when only it has saved chats
* - Falls back to sequential ports if preferred port is unavailable * - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0) * - 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 * @returns Promise<number> The available port
*/ */
export async function findAvailablePort(reuseExisting = true): Promise<number> { export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged const preferredPort = PORT_CONFIG.production
const preferredPort = isDev
? PORT_CONFIG.development
: PORT_CONFIG.production
// Try to reuse cached port if requested and available // Try to reuse cached port if requested and available
if (reuseExisting && allocatedPort !== null) { if (reuseExisting && allocatedPort !== null) {
@@ -135,19 +129,18 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
// In production, first the port where a chat was last saved. Without // First the port where a chat was last saved. Without one, the legacy
// one, the legacy port first to preserve existing users' data, unless // port first to preserve existing users' data, unless only the new port
// only the new port has data: their app started on 13370 while Windows // has data: their app started on 13370 while Windows reserved 61337,
// reserved 61337, and 61337 being free now would hide it // and 61337 being free now would hide it
const chatPort = isDev ? null : readChatPort() const chatPort = readChatPort()
const candidates = isDev const candidates =
? [preferredPort] chatPort !== null
: chatPort !== null ? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)] : hasStoredData(PORT_CONFIG.production) &&
: hasStoredData(PORT_CONFIG.production) && !hasStoredData(PORT_CONFIG.legacyProduction)
!hasStoredData(PORT_CONFIG.legacyProduction) ? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction] : [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
for (const port of candidates) { for (const port of candidates) {
if (await isPortAvailable(port)) { if (await isPortAvailable(port)) {
allocatedPort = port allocatedPort = port
-78
View File
@@ -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()
})
}
+3 -3
View File
@@ -107,9 +107,9 @@ export function getAppUrl(): string | null {
/** /**
* Point the main window at the restarted app server (it can come up on a * 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 * different port). On the same port the page is told so it can fetch the
* server models instead of sending the old preset's choice; it is not * server's models again; it is not reloaded, which would drop unsent
* reloaded, which would drop unsent attachments. * attachments.
*/ */
export function setAppUrl(url: string): void { export function setAppUrl(url: string): void {
if (url === appUrl) { if (url === appUrl) {
+1 -13
View File
@@ -4,27 +4,15 @@ import { contextBridge, ipcRenderer } from "electron"
* Expose safe APIs to the renderer process * Expose safe APIs to the renderer process
*/ */
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
// Platform information
platform: process.platform,
// Check if running in Electron // Check if running in Electron
isElectron: true, isElectron: true,
// Application version
getVersion: () => ipcRenderer.invoke("get-version"),
// Window controls (optional, for custom title bar)
minimize: () => ipcRenderer.send("window-minimize"),
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
// Proxy settings // Proxy settings
getProxy: () => ipcRenderer.invoke("get-proxy"), getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) => setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
ipcRenderer.invoke("set-proxy", config), ipcRenderer.invoke("set-proxy", config),
// User locale settings // User locale settings
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
setUserLocale: (locale: string) => setUserLocale: (locale: string) =>
ipcRenderer.invoke("set-user-locale", locale), ipcRenderer.invoke("set-user-locale", locale),
@@ -33,7 +21,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
chatSaved: () => ipcRenderer.invoke("chat-saved"), chatSaved: () => ipcRenderer.invoke("chat-saved"),
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count), chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
// The server restarted on the same port (another preset) // The server restarted on the same port (proxy change)
onServerRestarted: (callback: () => void) => { onServerRestarted: (callback: () => void) => {
const listener = () => callback() const listener = () => callback()
ipcRenderer.on("server-restarted", listener) ipcRenderer.on("server-restarted", listener)
-35
View File
@@ -1,35 +0,0 @@
/**
* Preload script for settings window
* Exposes APIs for managing configuration presets
*/
import { contextBridge, ipcRenderer } from "electron"
// Expose settings API to the renderer process
contextBridge.exposeInMainWorld("settingsAPI", {
// Get all presets
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
// Get current preset ID
getCurrentPresetId: () =>
ipcRenderer.invoke("config-presets:get-current-id"),
// Get current preset
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
// Save (create or update) a preset
savePreset: (preset: {
id?: string
name: string
config: Record<string, string | undefined>
}) => ipcRenderer.invoke("config-presets:save", preset),
// Delete a preset
deletePreset: (id: string) =>
ipcRenderer.invoke("config-presets:delete", id),
// Apply a preset (sets environment variables and restarts server)
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
// Close settings window
close: () => ipcRenderer.send("settings:close"),
})
-117
View File
@@ -1,117 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
<title>Settings - Next AI Draw.io</title>
<link rel="stylesheet" href="./settings.css">
</head>
<body>
<div class="container">
<div class="deprecation-notice">
<strong>⚠️ Deprecation Notice</strong>
<p>This settings panel will be removed in a future update.</p>
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
</div>
<h1>Configuration Presets</h1>
<div class="section">
<h2>Presets</h2>
<div id="preset-list" class="preset-list">
<!-- Presets will be loaded here -->
</div>
<button id="add-preset-btn" class="btn btn-primary">
+ Add New Preset
</button>
</div>
</div>
<!-- Add/Edit Preset Modal -->
<div id="preset-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Add Preset</h3>
</div>
<div class="modal-body">
<form id="preset-form">
<input type="hidden" id="preset-id">
<div class="form-group">
<label for="preset-name">Preset Name *</label>
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
</div>
<div class="form-group">
<label for="ai-provider">AI Provider</label>
<select id="ai-provider">
<option value="">-- Select Provider --</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="google">Google AI (Gemini)</option>
<option value="azure">Azure OpenAI</option>
<option value="bedrock">AWS Bedrock</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option>
<option value="modelscope">ModelScope</option>
<option value="ollama">Ollama (Local)</option>
</select>
</div>
<div class="form-group">
<label for="ai-model">Model ID</label>
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
<div class="hint">The model identifier to use with the selected provider</div>
</div>
<div class="form-group">
<label for="ai-api-key">API Key</label>
<input type="password" id="ai-api-key" placeholder="Your API key">
<div class="hint">This will be stored locally on your device</div>
</div>
<div class="form-group">
<label for="ai-base-url">Base URL (Optional)</label>
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
<div class="hint">Custom API endpoint URL</div>
</div>
<div class="form-group">
<label for="temperature">Temperature (Optional)</label>
<input type="text" id="temperature" placeholder="0.7">
<div class="hint">Controls randomness (0.0 - 2.0)</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>Delete Preset</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
<p class="delete-warning">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
<!-- Toast notification -->
<div id="toast" class="toast"></div>
<script src="./settings.js"></script>
</body>
</html>
-344
View File
@@ -1,344 +0,0 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #e8e8e8;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--border-color: #e0e0e0;
--accent-color: #0066cc;
--accent-hover: #0052a3;
--danger-color: #dc3545;
--success-color: #28a745;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-hover: #3d3d3d;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #404040;
--accent-color: #4da6ff;
--accent-hover: #66b3ff;
}
}
.deprecation-notice {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.deprecation-notice strong {
color: #856404;
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.deprecation-notice p {
color: #856404;
font-size: 13px;
margin: 4px 0;
}
@media (prefers-color-scheme: dark) {
.deprecation-notice {
background-color: #332701;
border-color: #665200;
}
.deprecation-notice strong,
.deprecation-notice p {
color: #ffc107;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
}
.container {
max-width: 560px;
margin: 0 auto;
padding: 24px;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
.section {
margin-bottom: 32px;
}
.preset-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.preset-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-card:hover {
background: var(--bg-hover);
}
.preset-card.active {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.preset-name {
font-weight: 600;
font-size: 15px;
}
.preset-badge {
background: var(--accent-color);
color: white;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
.preset-info {
font-size: 13px;
color: var(--text-secondary);
}
.preset-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin-bottom: 16px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--bg-primary);
border-radius: 12px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
}
.form-group .hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--text-primary);
color: var(--bg-primary);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 200;
opacity: 0;
transition: opacity 0.3s ease;
}
.toast.show {
opacity: 1;
}
.toast.success {
background: var(--success-color);
color: white;
}
.toast.error {
background: var(--danger-color);
color: white;
}
/* Inline style replacements */
.delete-warning {
color: var(--text-secondary);
margin-top: 8px;
font-size: 14px;
}
-313
View File
@@ -1,313 +0,0 @@
// Settings page JavaScript
// This file handles the UI interactions for the settings window
let presets = []
let currentPresetId = null
let editingPresetId = null
let deletingPresetId = null
// DOM Elements
const presetList = document.getElementById("preset-list")
const addPresetBtn = document.getElementById("add-preset-btn")
const presetModal = document.getElementById("preset-modal")
const deleteModal = document.getElementById("delete-modal")
const presetForm = document.getElementById("preset-form")
const modalTitle = document.getElementById("modal-title")
const toast = document.getElementById("toast")
// Form fields
const presetIdField = document.getElementById("preset-id")
const presetNameField = document.getElementById("preset-name")
const aiProviderField = document.getElementById("ai-provider")
const aiModelField = document.getElementById("ai-model")
const aiApiKeyField = document.getElementById("ai-api-key")
const aiBaseUrlField = document.getElementById("ai-base-url")
const temperatureField = document.getElementById("temperature")
// Buttons
const cancelBtn = document.getElementById("cancel-btn")
const saveBtn = document.getElementById("save-btn")
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
// Initialize
document.addEventListener("DOMContentLoaded", async () => {
await loadPresets()
setupEventListeners()
})
// Load presets from main process
async function loadPresets() {
try {
presets = await window.settingsAPI.getPresets()
currentPresetId = await window.settingsAPI.getCurrentPresetId()
renderPresets()
} catch (error) {
console.error("Failed to load presets:", error)
showToast("Failed to load presets", "error")
}
}
// Render presets list
function renderPresets() {
if (presets.length === 0) {
presetList.innerHTML = `
<div class="empty-state">
<p>No presets configured yet.</p>
<p>Add a preset to quickly switch between different AI configurations.</p>
</div>
`
return
}
presetList.innerHTML = presets
.map((preset) => {
const isActive = preset.id === currentPresetId
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
return `
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
<div class="preset-header">
<span class="preset-name">${escapeHtml(preset.name)}</span>
${isActive ? '<span class="preset-badge">Active</span>' : ""}
</div>
<div class="preset-info">
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
</div>
<div class="preset-actions">
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
</div>
</div>
`
})
.join("")
// Add event listeners to buttons
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
applyPreset(btn.dataset.id)
})
})
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openEditModal(btn.dataset.id)
})
})
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openDeleteModal(btn.dataset.id)
})
})
}
// Setup event listeners
function setupEventListeners() {
addPresetBtn.addEventListener("click", () => openAddModal())
cancelBtn.addEventListener("click", () => closeModal())
saveBtn.addEventListener("click", () => savePreset())
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
// Close modal on overlay click
presetModal.addEventListener("click", (e) => {
if (e.target === presetModal) closeModal()
})
deleteModal.addEventListener("click", (e) => {
if (e.target === deleteModal) closeDeleteModal()
})
// Handle Enter key in form
presetForm.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault()
savePreset()
}
})
}
// Open add modal
function openAddModal() {
editingPresetId = null
modalTitle.textContent = "Add Preset"
presetForm.reset()
presetIdField.value = ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Open edit modal
function openEditModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
editingPresetId = id
modalTitle.textContent = "Edit Preset"
presetIdField.value = preset.id
presetNameField.value = preset.name
aiProviderField.value = preset.config.AI_PROVIDER || ""
aiModelField.value = preset.config.AI_MODEL || ""
aiApiKeyField.value = preset.config.AI_API_KEY || ""
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
temperatureField.value = preset.config.TEMPERATURE || ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Close modal
function closeModal() {
presetModal.classList.remove("show")
editingPresetId = null
}
// Open delete modal
function openDeleteModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
deletingPresetId = id
document.getElementById("delete-preset-name").textContent = preset.name
deleteModal.classList.add("show")
}
// Close delete modal
function closeDeleteModal() {
deleteModal.classList.remove("show")
deletingPresetId = null
}
// Save preset
async function savePreset() {
const name = presetNameField.value.trim()
if (!name) {
showToast("Please enter a preset name", "error")
presetNameField.focus()
return
}
const preset = {
id: editingPresetId || undefined,
name: name,
config: {
AI_PROVIDER: aiProviderField.value || undefined,
AI_MODEL: aiModelField.value.trim() || undefined,
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
TEMPERATURE: temperatureField.value.trim() || undefined,
},
}
// Remove undefined values
Object.keys(preset.config).forEach((key) => {
if (preset.config[key] === undefined) {
delete preset.config[key]
}
})
// closeModal() clears editingPresetId, so remember it for the toast
const isEdit = Boolean(editingPresetId)
try {
saveBtn.disabled = true
saveBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.savePreset(preset)
await loadPresets()
closeModal()
showToast(isEdit ? "Preset updated" : "Preset created", "success")
} catch (error) {
console.error("Failed to save preset:", error)
showToast("Failed to save preset", "error")
} finally {
saveBtn.disabled = false
saveBtn.textContent = "Save"
}
}
// Confirm delete
async function confirmDelete() {
if (!deletingPresetId) return
try {
deleteConfirmBtn.disabled = true
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.deletePreset(deletingPresetId)
await loadPresets()
closeDeleteModal()
showToast("Preset deleted", "success")
} catch (error) {
console.error("Failed to delete preset:", error)
showToast("Failed to delete preset", "error")
} finally {
deleteConfirmBtn.disabled = false
deleteConfirmBtn.textContent = "Delete"
}
}
// Apply preset
async function applyPreset(id) {
try {
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
if (btn) {
btn.disabled = true
btn.innerHTML = '<span class="loading"></span>'
}
const result = await window.settingsAPI.applyPreset(id)
if (result.success) {
showToast("Preset applied, server restarting...", "success")
} else {
showToast(result.error || "Failed to apply preset", "error")
}
} catch (error) {
console.error("Failed to apply preset:", error)
showToast("Failed to apply preset", "error")
} finally {
// Reload to show the active preset and reset the Apply button
await loadPresets()
}
}
// Get provider display label
function getProviderLabel(provider) {
const labels = {
openai: "OpenAI",
anthropic: "Anthropic",
google: "Google AI",
azure: "Azure OpenAI",
bedrock: "AWS Bedrock",
openrouter: "OpenRouter",
deepseek: "DeepSeek",
siliconflow: "SiliconFlow",
modelscope: "ModelScope",
ollama: "Ollama",
}
return labels[provider] || provider
}
// Show toast notification
function showToast(message, type = "") {
toast.textContent = message
toast.className = "toast show" + (type ? ` ${type}` : "")
setTimeout(() => {
toast.classList.remove("show")
}, 3000)
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}
+18 -5
View File
@@ -8,6 +8,7 @@ AI_PROVIDER=bedrock
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs. # Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
# The first one becomes the default and the rest appear in the model picker. # The first one becomes the default and the rest appear in the model picker.
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead. # For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
# There, apiKeyEnv may also be an array of env var names: each request picks one that has a value at random.
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro # Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
@@ -28,8 +29,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OpenAI Configuration # OpenAI Configuration
# OPENAI_API_KEY=sk-... # OPENAI_API_KEY=sk-...
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint # OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
# OPENAI_ORGANIZATION=org-... # Optional
# OPENAI_PROJECT=proj_... # Optional
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed) # Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5 # OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed) # OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
@@ -142,6 +141,18 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# ADMIN_PASSWORD=your-admin-password # ADMIN_PASSWORD=your-admin-password
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location # SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Quota & Validation (Optional, also editable in the admin panel)
# Quota enforcement is disabled unless DYNAMODB_QUOTA_TABLE is set.
# DYNAMODB_QUOTA_TABLE=next-ai-draw-io-quota # DynamoDB table that stores the per-IP counters
# DYNAMODB_REGION=ap-northeast-1 # Region of the table
# QUOTA_TIMEZONE=UTC # Timezone for the daily reset boundary
# DAILY_REQUEST_LIMIT=10 # Requests per IP per day
# DAILY_TOKEN_LIMIT=200000 # Tokens per IP per day
# TPM_LIMIT=20000 # Tokens per minute
# ENABLE_VLM_VALIDATION=true # Visually validate generated diagrams with a vision model
# VALIDATION_MODEL= # Falls back to the default AI model when empty
# VALIDATION_TIMEOUT=30000 # Validation timeout in ms
# Draw.io Configuration (Optional) # Draw.io Configuration (Optional)
# By default the app serves its own copy of draw.io from /drawio (downloaded into # By default the app serves its own copy of draw.io from /drawio (downloaded into
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for # public/drawio by `npm run dev` / `npm run build`). Same origin is required for
@@ -156,9 +167,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio # NEXT_PUBLIC_BASE_PATH=/nextaidrawio
# PDF Input Feature (Optional) # PDF Input Feature (Optional)
# Enable PDF file upload to extract text and generate diagrams # PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable.
# ENABLE_PDF_INPUT=true
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000) # NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
# Security Settings (Optional) # Security Settings (Optional)
@@ -184,6 +193,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# It also turns off the MCP server advertisement in Settings > About. # It also turns off the MCP server advertisement in Settings > About.
# NEXT_PUBLIC_SELFHOSTED=true # NEXT_PUBLIC_SELFHOSTED=true
# Analytics (Optional)
# Google Analytics measurement ID; nothing is loaded when unset
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# Minimax Configuration (Optional) # Minimax Configuration (Optional)
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro # Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
# MINIMAX_API_KEY=your_minimax_api_key # MINIMAX_API_KEY=your_minimax_api_key
+6 -108
View File
@@ -1,3 +1,4 @@
import type { ChatAddToolOutputFunction } from "ai"
import type { RefObject } from "react" import type { RefObject } from "react"
import type { DiagramOperation } from "@/components/chat/types" import type { DiagramOperation } from "@/components/chat/types"
import type { import type {
@@ -5,6 +6,7 @@ import type {
ValidationStatus, ValidationStatus,
} from "@/components/chat/ValidationCard" } from "@/components/chat/ValidationCard"
import type { LoadMode } from "@/contexts/diagram-context" import type { LoadMode } from "@/contexts/diagram-context"
import type { ChatUIMessage } from "@/lib/chat-message"
import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages" import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator"
@@ -14,57 +16,18 @@ import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { hasCells } from "@/packages/mcp-server/src/pages.ts" import { hasCells } from "@/packages/mcp-server/src/pages.ts"
const DEBUG = process.env.NODE_ENV === "development"
// The page a drawing is wrapped in; on the canvas it replaces the page the // The page a drawing is wrapped in; on the canvas it replaces the page the
// model works on, so the canvas page keeps its own id and name // model works on, so the canvas page keeps its own id and name
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" } const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
/**
* A new diagram written without file variables (%name% placeholders) keeps
* the canvas file's, as replacing the page in draw.io does
*/
export function keepFileVars(xml: string, canvasXml: string): string {
const varsOf = (doc: Document) =>
doc.documentElement?.nodeName === "mxfile"
? doc.documentElement.getAttribute("vars")
: null
const parser = new DOMParser()
const canvasVars = varsOf(parser.parseFromString(canvasXml, "text/xml"))
if (!canvasVars) return xml
const doc = parser.parseFromString(xml, "text/xml")
if (doc.documentElement?.nodeName !== "mxfile" || varsOf(doc) !== null) {
return xml
}
doc.documentElement.setAttribute("vars", canvasVars)
return new XMLSerializer().serializeToString(doc)
}
interface ToolCall { interface ToolCall {
toolCallId: string toolCallId: string
toolName: string toolName: string
input: unknown input: unknown
} }
type AddToolOutputSuccess = { // useChat's addToolOutput, typed with the chat's tools
tool: string type AddToolOutputFn = ChatAddToolOutputFunction<ChatUIMessage>
toolCallId: string
state?: "output-available"
output: string
errorText?: undefined
}
type AddToolOutputError = {
tool: string
toolCallId: string
state: "output-error"
output?: undefined
errorText: string
}
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void
const MAX_VALIDATION_RETRIES = 3 const MAX_VALIDATION_RETRIES = 3
@@ -157,12 +120,6 @@ export function useDiagramToolHandlers({
{ toolCall }: { toolCall: ToolCall }, { toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn, addToolOutput: AddToolOutputFn,
) => { ) => {
if (DEBUG) {
console.log(
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
)
}
processedToolCallsRef.current.add(toolCall.toolCallId) processedToolCallsRef.current.add(toolCall.toolCallId)
// Only display_diagram, edit_diagram and a completing append_diagram // Only display_diagram, edit_diagram and a completing append_diagram
// put their result on the canvas. Other tools (get_shape_library, // put their result on the canvas. Other tools (get_shape_library,
@@ -204,12 +161,9 @@ export function useDiagramToolHandlers({
// A drawn page goes onto the model's page of the canvas file, which // A drawn page goes onto the model's page of the canvas file, which
// keeps its other pages; a drawing that brings several pages replaces // keeps its other pages; a drawing that brings several pages replaces
// the file, and gets its variables // the file. Either way it keeps the file's variables unless it sets its own.
const onAiPage = (drawnXml: string) => const onAiPage = (drawnXml: string) =>
keepFileVars( placeOnPage(drawnXml, chartXMLRef.current, aiPageId())
placeOnPage(drawnXml, chartXMLRef.current, aiPageId()),
chartXMLRef.current,
)
const takeOriginals = (): string | undefined => { const takeOriginals = (): string | undefined => {
const [originalXml] = editDiagramOriginalXmlRef.current.values() const [originalXml] = editDiagramOriginalXmlRef.current.values()
@@ -228,20 +182,8 @@ export function useDiagramToolHandlers({
) => { ) => {
const { xml } = toolCall.input as { xml: string } const { xml } = toolCall.input as { xml: string }
// DEBUG: Log raw input to diagnose false truncation detection
if (DEBUG) {
console.log(
"[display_diagram] XML ending (last 100 chars):",
xml.slice(-100),
)
console.log("[display_diagram] XML length:", xml.length)
}
// Check if XML is truncated (incomplete mxCell indicates truncated output) // Check if XML is truncated (incomplete mxCell indicates truncated output)
const isTruncated = !isMxCellXmlComplete(xml) const isTruncated = !isMxCellXmlComplete(xml)
if (DEBUG) {
console.log("[display_diagram] isTruncated:", isTruncated)
}
if (isTruncated) { if (isTruncated) {
// Store the partial XML for continuation via append_diagram // Store the partial XML for continuation via append_diagram
@@ -298,11 +240,6 @@ NEXT STEP: Call append_diagram with the continuation XML.
// keeps the diagram from before this failed call // keeps the diagram from before this failed call
if (originalXml) onDisplayChart(originalXml, true, "revert") if (originalXml) onDisplayChart(originalXml, true, "revert")
// Return error to model - sendAutomaticallyWhen will trigger retry // Return error to model - sendAutomaticallyWhen will trigger retry
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-error",
)
}
addToolOutput({ addToolOutput({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
@@ -318,12 +255,6 @@ ${finalXml}
}) })
} else { } else {
// Success - diagram will be rendered by chat-message-display // Success - diagram will be rendered by chat-message-display
if (DEBUG) {
console.log(
"[display_diagram] Success! Checking if VLM validation is enabled...",
)
}
// VLM validation after successful display // VLM validation after successful display
if ( if (
enableVlmValidation && enableVlmValidation &&
@@ -348,12 +279,6 @@ ${finalXml}
if (stopped?.()) { if (stopped?.()) {
updateValidationState(toolCall.toolCallId, "skipped") updateValidationState(toolCall.toolCallId, "skipped")
} else if (capturedPngData) { } else if (capturedPngData) {
if (DEBUG) {
console.log(
"[display_diagram] Captured PNG for validation",
)
}
// Each retry is a new tool call, so count attempts // Each retry is a new tool call, so count attempts
// per user turn (the chat resets it when the user sends) // per user turn (the chat resets it when the user sends)
const attempt = validationRetryCountRef.current + 1 const attempt = validationRetryCountRef.current + 1
@@ -379,12 +304,6 @@ ${finalXml}
if (attempt < MAX_VALIDATION_RETRIES) { if (attempt < MAX_VALIDATION_RETRIES) {
const feedback = const feedback =
formatValidationFeedback(result) formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
// Notify UI of validation failure (include the image) // Notify UI of validation failure (include the image)
updateValidationState( updateValidationState(
@@ -407,11 +326,6 @@ ${finalXml}
return return
} else { } else {
// Last attempt - accept the diagram with warning // Last attempt - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
// Notify UI that we're accepting with issues (include the image) // Notify UI that we're accepting with issues (include the image)
updateValidationState( updateValidationState(
toolCall.toolCallId, toolCall.toolCallId,
@@ -427,12 +341,6 @@ ${finalXml}
return return
} }
} else { } else {
if (DEBUG) {
console.log(
"[display_diagram] Validation passed!",
)
}
// Notify UI of success (include the image) // Notify UI of success (include the image)
// Use "success_with_warnings" if valid but has issues // Use "success_with_warnings" if valid but has issues
const hasWarnings = result.issues.length > 0 const hasWarnings = result.issues.length > 0
@@ -474,21 +382,11 @@ ${finalXml}
} }
} }
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-available",
)
}
addToolOutput({ addToolOutput({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.", output: "Successfully displayed the diagram.",
}) })
if (DEBUG) {
console.log(
"[display_diagram] Tool output added. Diagram should be visible now.",
)
}
} }
} }
-2
View File
@@ -25,5 +25,3 @@ export function useDictionary() {
} }
return dict return dict
} }
export default useDictionary
+2 -95
View File
@@ -18,56 +18,6 @@ import {
type ProviderName, type ProviderName,
} from "@/lib/types/model-config" } from "@/lib/types/model-config"
// Old storage keys for migration
const OLD_KEYS = {
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
}
/**
* Migrate from old single-provider format to new multi-model format
*/
function migrateOldConfig(): MultiModelConfig | null {
if (typeof window === "undefined") return null
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
// No old config to migrate. A local Ollama needs no key.
if (!oldProvider || !oldModel) return null
if (!oldApiKey && oldProvider !== "ollama") return null
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
// Create new config from old format
const provider = createProviderConfig(oldProvider as ProviderName)
provider.apiKey = oldApiKey ?? ""
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
// Without one, the old version used the server's Ollama (OLLAMA_BASE_URL
// or this machine), not the cloud address new providers get
else if (oldProvider === "ollama") provider.baseUrl = ""
const model = createModelConfig(oldModel)
provider.models.push(model)
const config: MultiModelConfig = {
version: 1,
providers: [provider],
selectedModelId: model.id,
}
// Clear old keys after migration
localStorage.removeItem(OLD_KEYS.aiProvider)
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
localStorage.removeItem(OLD_KEYS.aiApiKey)
localStorage.removeItem(OLD_KEYS.aiModel)
return config
}
const isKnownProvider = (p: { provider: string }) => const isKnownProvider = (p: { provider: string }) =>
Object.hasOwn(PROVIDER_INFO, p.provider) Object.hasOwn(PROVIDER_INFO, p.provider)
@@ -96,7 +46,6 @@ function parseStoredConfig(stored: string): MultiModelConfig {
function loadConfig(): MultiModelConfig { function loadConfig(): MultiModelConfig {
if (typeof window === "undefined") return createEmptyConfig() if (typeof window === "undefined") return createEmptyConfig()
// First, check if new format exists
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (stored) { if (stored) {
try { try {
@@ -106,17 +55,6 @@ function loadConfig(): MultiModelConfig {
} }
} }
// Try migration from old format
const migrated = migrateOldConfig()
if (migrated) {
// Save migrated config
localStorage.setItem(
STORAGE_KEYS.modelConfigs,
JSON.stringify(migrated),
)
return migrated
}
return createEmptyConfig() return createEmptyConfig()
} }
@@ -163,7 +101,6 @@ export interface UseModelConfigReturn {
// Getters // Getters
models: FlattenedModel[] models: FlattenedModel[]
selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined selectedModelId: string | undefined
// Actions // Actions
@@ -181,7 +118,6 @@ export interface UseModelConfigReturn {
updates: Partial<ModelConfig>, updates: Partial<ModelConfig>,
) => void ) => void
deleteModel: (providerId: string, modelConfigId: string) => void deleteModel: (providerId: string, modelConfigId: string) => void
resetConfig: () => void
} }
export function useModelConfig(): UseModelConfigReturn { export function useModelConfig(): UseModelConfigReturn {
@@ -281,7 +217,7 @@ export function useModelConfig(): UseModelConfigReturn {
id: m.id, id: m.id,
modelId: m.modelId, modelId: m.modelId,
provider: m.provider, provider: m.provider,
providerLabel: `Server · ${m.providerLabel}`, providerLabel: m.providerLabel,
apiKey: "", apiKey: "",
baseUrl: undefined, baseUrl: undefined,
awsAccessKeyId: undefined, awsAccessKeyId: undefined,
@@ -291,17 +227,11 @@ export function useModelConfig(): UseModelConfigReturn {
validated: true, validated: true,
source: "server" as const, source: "server" as const,
isDefault: m.isDefault, isDefault: m.isDefault,
apiKeyEnv: m.apiKeyEnv,
baseUrlEnv: m.baseUrlEnv,
})), })),
// User models from local configuration // User models from local configuration
...userModels, ...userModels,
] ]
const selectedModel = config.selectedModelId
? models.find((m) => m.id === config.selectedModelId)
: undefined
// Actions // Actions
const setSelectedModelId = useCallback((modelId: string | undefined) => { const setSelectedModelId = useCallback((modelId: string | undefined) => {
setConfig((prev) => ({ setConfig((prev) => ({
@@ -426,15 +356,10 @@ export function useModelConfig(): UseModelConfigReturn {
[serverModels], [serverModels],
) )
const resetConfig = useCallback(() => {
setConfig(createEmptyConfig())
}, [])
return { return {
config, config,
isLoaded: isLoaded && serverLoaded, isLoaded: isLoaded && serverLoaded,
models, models,
selectedModel,
selectedModelId: config.selectedModelId, selectedModelId: config.selectedModelId,
setSelectedModelId, setSelectedModelId,
addProvider, addProvider,
@@ -443,7 +368,6 @@ export function useModelConfig(): UseModelConfigReturn {
addModel, addModel,
updateModel, updateModel,
deleteModel, deleteModel,
resetConfig,
} }
} }
@@ -491,24 +415,7 @@ export function getSelectedAIConfig(): {
// Load multi-model config // Load multi-model config
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (!stored) { if (!stored) return { ...empty, accessCode }
// Fallback to old format for backward compatibility
return {
accessCode,
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
// Old format didn't support AWS
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
providerId: "",
}
}
let config: MultiModelConfig let config: MultiModelConfig
try { try {
+3 -19
View File
@@ -12,7 +12,6 @@ import {
getAllSessionMetadata, getAllSessionMetadata,
getSession, getSession,
isIndexedDBAvailable, isIndexedDBAvailable,
migrateFromLocalStorage,
readSessionCount, readSessionCount,
renameSession as renameSessionInDB, renameSession as renameSessionInDB,
type SessionMetadata, type SessionMetadata,
@@ -20,10 +19,7 @@ import {
saveSession, saveSession,
} from "@/lib/session-storage" } from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage" import { STORAGE_KEYS } from "@/lib/storage"
import { import type { DiagramVersion } from "@/stores/versions-store"
type DiagramVersion,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
export interface SessionData { export interface SessionData {
messages: StoredMessage[] messages: StoredMessage[]
@@ -35,13 +31,6 @@ export interface SessionData {
title?: string title?: string
} }
/** Versions of a stored session, converting the old history format */
export function getSessionVersions(
session: Pick<ChatSession, "versions" | "diagramHistory">,
): DiagramVersion[] {
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
}
// Taken right before a save's data is read: the chat on screen then, and // Taken right before a save's data is read: the chat on screen then, and
// the order of the reads // the order of the reads
export interface SaveTicket { export interface SaveTicket {
@@ -192,9 +181,6 @@ export function useSessionManager(
setIsAvailable(true) setIsAvailable(true)
try { try {
// Run migration first (one-time conversion from localStorage)
await migrateFromLocalStorage()
// Load sessions list // Load sessions list
const metadata = await getAllSessionMetadata() const metadata = await getAllSessionMetadata()
setSessions(metadata) setSessions(metadata)
@@ -334,7 +320,7 @@ export function useSessionManager(
xmlSnapshots: session.xmlSnapshots, xmlSnapshots: session.xmlSnapshots,
diagramXml: session.diagramXml, diagramXml: session.diagramXml,
thumbnailDataUrl: session.thumbnailDataUrl, thumbnailDataUrl: session.thumbnailDataUrl,
versions: getSessionVersions(session), versions: session.versions ?? [],
} }
}, },
[currentSessionId, changeChat], [currentSessionId, changeChat],
@@ -417,9 +403,7 @@ export function useSessionManager(
thumbnailDataUrl: thumbnailDataUrl:
data.thumbnailDataUrl ?? data.thumbnailDataUrl ??
currentSession.thumbnailDataUrl, currentSession.thumbnailDataUrl,
versions: versions: data.versions ?? currentSession.versions ?? [],
data.versions ?? getSessionVersions(currentSession),
diagramHistory: undefined,
updatedAt: Date.now(), updatedAt: Date.now(),
// Update title if it's still default and we have messages // Update title if it's still default and we have messages
title: title:
+42 -112
View File
@@ -1,10 +1,9 @@
"use client" "use client"
/** /**
* Hook for VLM-based diagram validation using AI SDK's useObject. * Hook for VLM-based diagram validation: one request, one JSON result.
*/ */
import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react" import { useCallback, useRef } from "react"
import { getSelectedAIConfig } from "@/hooks/use-model-config" import { getSelectedAIConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -13,8 +12,6 @@ import {
ValidationResultSchema, ValidationResultSchema,
} from "@/lib/validation-schema" } from "@/lib/validation-schema"
export type { ValidationResult }
// Default valid result for fallback cases // Default valid result for fallback cases
const DEFAULT_VALID_RESULT: ValidationResult = { const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true, valid: true,
@@ -22,111 +19,42 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [], suggestions: [],
} }
interface UseValidateDiagramOptions { export function useValidateDiagram() {
onSuccess?: (result: ValidationResult) => void // The running check's controller; a new check or cancel() aborts it
onError?: (error: Error) => void const controllerRef = useRef<AbortController | null>(null)
}
// Track pending validation promises for imperative API
type PendingValidation = {
resolve: (result: ValidationResult) => void
reject: (error: Error) => void
}
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { onSuccess, onError } = options
const pendingValidationRef = useRef<PendingValidation | null>(null)
const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema,
// Resolved per request so a changed access code is picked up
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
onFinish: ({
object,
error: finishError,
}: {
object: ValidationResult | undefined
error: Error | undefined
}) => {
if (finishError) {
console.error(
"[useValidateDiagram] Validation error:",
finishError,
)
onError?.(finishError)
pendingValidationRef.current?.reject(finishError)
pendingValidationRef.current = null
return
}
if (object) {
const result = object as ValidationResult
onSuccess?.(result)
pendingValidationRef.current?.resolve(result)
pendingValidationRef.current = null
}
},
onError: (err: Error) => {
console.error("[useValidateDiagram] Stream error:", err)
onError?.(err)
pendingValidationRef.current?.reject(err)
pendingValidationRef.current = null
},
})
/** /**
* Validate a diagram image. * Validate a diagram image, with fallback: a failed check gives the
* Returns a promise that resolves with the validation result. * default valid result so it never blocks the user. A cancelled check
*/ * is passed on as its AbortError.
const validate = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
// Reject any pending validation to prevent promise leaks
if (pendingValidationRef.current) {
pendingValidationRef.current.reject(
new Error("Validation superseded by new request"),
)
pendingValidationRef.current = null
}
return new Promise((resolve, reject) => {
// Store the promise handlers
pendingValidationRef.current = { resolve, reject }
// Submit the validation request
submit({ imageData, sessionId })
})
},
[submit],
)
/**
* End a running check (the user pressed Stop): its promise rejects with
* an AbortError, so the tool handler can finish at once.
*/
const cancel = useCallback(() => {
const pending = pendingValidationRef.current
if (!pending) return
pendingValidationRef.current = null
stop()
pending.reject(new DOMException("Validation cancelled", "AbortError"))
}, [stop])
/**
* Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures.
* A cancelled check is passed on as its AbortError.
*/ */
const validateWithFallback = useCallback( const validateWithFallback = useCallback(
async ( async (
imageData: string, imageData: string,
sessionId?: string, sessionId?: string,
): Promise<ValidationResult> => { ): Promise<ValidationResult> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try { try {
return await validate(imageData, sessionId) const res = await fetch(
getApiEndpoint("/api/validate-diagram"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
// Read per request so a changed access code is
// picked up
"x-access-code": getSelectedAIConfig().accessCode,
},
body: JSON.stringify({ imageData, sessionId }),
signal: controller.signal,
},
)
if (!res.ok) {
throw new Error(`Validation request failed: ${res.status}`)
}
return ValidationResultSchema.parse(await res.json())
} catch (error) { } catch (error) {
if ((error as Error)?.name === "AbortError") throw error if ((error as Error)?.name === "AbortError") throw error
console.warn( console.warn(
@@ -134,21 +62,23 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
error, error,
) )
return DEFAULT_VALID_RESULT return DEFAULT_VALID_RESULT
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null
}
} }
}, },
[validate], [],
) )
return { /**
// Validation functions * End a running check (the user pressed Stop): its promise rejects with
validate, * an AbortError, so the tool handler can finish at once.
validateWithFallback, */
stop, const cancel = useCallback(() => {
cancel, controllerRef.current?.abort()
controllerRef.current = null
}, [])
// State return { validateWithFallback, cancel }
isValidating: isLoading,
partialResult: object as ValidationResult | undefined,
error,
}
} }
+3 -9
View File
@@ -115,7 +115,7 @@ export function loadAdminProviders(): StoredAdminProvider[] {
} }
} }
export type MaskedAdminProvider = Omit< type MaskedAdminProvider = Omit<
StoredAdminProvider, StoredAdminProvider,
(typeof SECRET_FIELDS)[number] (typeof SECRET_FIELDS)[number]
> & { > & {
@@ -278,15 +278,9 @@ export function deriveEnvUpdates(
return updates return updates
} }
// Every settings key the panel may have written for a given list. // Every settings key the panel may have written for a given list
// AI_MODELS_CONFIG is included to clean up values written by older
// versions of the panel (it is no longer written).
function derivedEnvKeys(list: StoredAdminProvider[]): string[] { function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const keys = new Set<string>([ const keys = new Set<string>(["AI_PROVIDER", "AI_MODEL"])
"AI_MODELS_CONFIG",
"AI_PROVIDER",
"AI_MODEL",
])
const indexByProvider = new Map<ProviderName, number>() const indexByProvider = new Map<ProviderName, number>()
for (const p of list) { for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0 const index = indexByProvider.get(p.provider) ?? 0
+1 -9
View File
@@ -10,7 +10,7 @@
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout // - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example) // - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum" type SettingType = "string" | "secret" | "number" | "boolean"
export interface SettingDef { export interface SettingDef {
key: string key: string
@@ -18,7 +18,6 @@ export interface SettingDef {
type: SettingType type: SettingType
label: string label: string
description?: string description?: string
options?: string[]
min?: number min?: number
max?: number max?: number
placeholder?: string placeholder?: string
@@ -123,13 +122,6 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
label: "Validation Timeout (ms)", label: "Validation Timeout (ms)",
min: 1000, min: 1000,
}, },
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{ {
key: "ALLOW_PRIVATE_URLS", key: "ALLOW_PRIVATE_URLS",
group: "features", group: "features",
+1 -1
View File
@@ -32,7 +32,7 @@ globalState.__adminSettingsState ??= {
} }
const state = globalState.__adminSettingsState const state = globalState.__adminSettingsState
export function getSettingsPath(): string { function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE const custom = process.env.SETTINGS_FILE
if (custom && custom.trim().length > 0) return custom if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json") return path.join(process.cwd(), "data", "settings.json")
+68 -118
View File
@@ -30,9 +30,7 @@ import {
type ProviderName, type ProviderName,
} from "@/lib/types/model-config" } from "@/lib/types/model-config"
export type { ProviderName } const AIHUBMIX_APP_CODE = "MSBS9675"
export const AIHUBMIX_APP_CODE = "MSBS9675"
interface ModelConfig { interface ModelConfig {
model: any model: any
@@ -56,7 +54,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
* Normalize MiniMax base URL for AI SDK compatibility. * Normalize MiniMax base URL for AI SDK compatibility.
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints. * MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
*/ */
export function normalizeMiniMaxBaseURL(rawUrl: string): { function normalizeMiniMaxBaseURL(rawUrl: string): {
baseURL: string baseURL: string
isAnthropicCompatible: boolean isAnthropicCompatible: boolean
} { } {
@@ -129,21 +127,19 @@ const BEDROCK_ANTHROPIC_BETA = {
* @param userApiKey - User-provided API key (if any) * @param userApiKey - User-provided API key (if any)
* @param userBaseUrl - User-provided base URL (if any) * @param userBaseUrl - User-provided base URL (if any)
* @param serverBaseUrl - Server's base URL from environment variable * @param serverBaseUrl - Server's base URL from environment variable
* @param defaultBaseUrl - Provider's official/default base URL (optional)
* @returns The resolved base URL to use * @returns The resolved base URL to use
*/ */
export function resolveBaseURL( export function resolveBaseURL(
userApiKey: string | null | undefined, userApiKey: string | null | undefined,
userBaseUrl: string | null | undefined, userBaseUrl: string | null | undefined,
serverBaseUrl: string | undefined, serverBaseUrl: string | undefined,
defaultBaseUrl?: string,
): string | undefined { ): string | undefined {
if (userApiKey) { if (userApiKey) {
// User provides their own API key - only use user's baseUrl or default // User provides their own API key - only use user's baseUrl
return userBaseUrl || defaultBaseUrl || undefined return userBaseUrl || undefined
} }
// No user API key - fall back to server config // No user API key - fall back to server config
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined return userBaseUrl || serverBaseUrl || undefined
} }
/** /**
@@ -244,6 +240,39 @@ function googleSamplingSettings(): { topK?: number; topP?: number } {
return settings return settings
} }
/**
* Gemini 2.5/3 models think by default, but need includeThoughts: true to
* return the reasoning. The budget applies to Gemini 2.5, the level to
* Gemini 3. Shared by Google AI and Vertex AI, which read different variables.
*/
function geminiThinkingOptions(
modelId: string | undefined,
budgetEnvName: string,
levelEnvName: string,
): { thinkingConfig: Record<string, any> } | undefined {
if (!modelId || !/gemini-?[23]/.test(modelId)) return undefined
const thinkingConfig: Record<string, any> = { includeThoughts: true }
const budget = parseIntSafe(
process.env[budgetEnvName],
budgetEnvName,
1024,
100000,
)
const level = process.env[levelEnvName] as
| "minimal"
| "low"
| "medium"
| "high"
| undefined
if (budget && (modelId.includes("2.5") || modelId.includes("2-5"))) {
thinkingConfig.thinkingBudget = budget
} else if (level && /gemini-?3/.test(modelId)) {
thinkingConfig.thinkingLevel = level
}
return { thinkingConfig }
}
/** /**
* Build provider-specific options from environment variables * Build provider-specific options from environment variables
* Supports various AI SDK providers with their unique configuration options * Supports various AI SDK providers with their unique configuration options
@@ -271,41 +300,27 @@ function buildProviderOptions(
switch (provider) { switch (provider) {
case "openai": { case "openai": {
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT const reasoningEffort = process.env.OPENAI_REASONING_EFFORT as
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY | "minimal"
| "low"
| "medium"
| "high"
| undefined
// Reasoning models (the o-series, gpt-5 and later) need // Reasoning models (the o-series, gpt-5 and later) need
// reasoningSummary to return thoughts // reasoningSummary to return thoughts; 'auto' as the default
if (modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)) { // since not all models support 'detailed'
options.openai = { const isReasoning =
// Auto-enable reasoning summary for reasoning models !!modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)
// Use 'auto' as default since not all models support 'detailed' const reasoningSummary =
reasoningSummary: (process.env.OPENAI_REASONING_SUMMARY as
(reasoningSummary as "auto" | "detailed") || "auto", | "auto"
} | "detailed"
| undefined) || (isReasoning ? "auto" : undefined)
// Optionally configure reasoning effort if (reasoningSummary || reasoningEffort) {
if (reasoningEffort) { options.openai = {
options.openai.reasoningEffort = reasoningEffort as ...(reasoningSummary && { reasoningSummary }),
| "minimal" ...(reasoningEffort && { reasoningEffort }),
| "low"
| "medium"
| "high"
}
} else if (reasoningEffort || reasoningSummary) {
// Non-reasoning models: only apply if explicitly configured
options.openai = {}
if (reasoningEffort) {
options.openai.reasoningEffort = reasoningEffort as
| "minimal"
| "low"
| "medium"
| "high"
}
if (reasoningSummary) {
options.openai.reasoningSummary = reasoningSummary as
| "auto"
| "detailed"
} }
} }
break break
@@ -333,85 +348,21 @@ function buildProviderOptions(
} }
case "google": { case "google": {
const thinkingBudgetVal = parseIntSafe( const google = geminiThinkingOptions(
process.env.GOOGLE_THINKING_BUDGET, modelId,
"GOOGLE_THINKING_BUDGET", "GOOGLE_THINKING_BUDGET",
1024, "GOOGLE_THINKING_LEVEL",
100000,
) )
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL if (google) options.google = google
// Google Gemini 2.5/3 models think by default, but need includeThoughts: true
// to return the reasoning in the response
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
// Optionally configure thinking budget or level
if (
thinkingBudgetVal &&
(modelId.includes("2.5") || modelId.includes("2-5"))
) {
thinkingConfig.thinkingBudget = thinkingBudgetVal
} else if (
thinkingLevel &&
(modelId.includes("gemini-3") ||
modelId.includes("gemini3"))
) {
thinkingConfig.thinkingLevel = thinkingLevel as
| "low"
| "high"
}
options.google = { thinkingConfig }
}
break break
} }
case "vertexai": { case "vertexai": {
const thinkingBudget = parseIntSafe( const google = geminiThinkingOptions(
process.env.GOOGLE_VERTEX_THINKING_BUDGET, modelId,
"GOOGLE_VERTEX_THINKING_BUDGET", "GOOGLE_VERTEX_THINKING_BUDGET",
1024, "GOOGLE_VERTEX_THINKING_LEVEL",
100000,
) )
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL if (google) options.google = google
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
const isGemini3 =
modelId?.includes("gemini-3") ||
modelId?.includes("gemini3")
const isGemini25 =
modelId?.includes("2.5") || modelId?.includes("2-5")
if (isGemini3 && thinkingLevel) {
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
thinkingConfig.thinkingLevel = thinkingLevel as
| "minimal"
| "low"
| "medium"
| "high"
} else if (isGemini25 && thinkingBudget) {
thinkingConfig.thinkingBudget = thinkingBudget
}
options.google = { thinkingConfig }
}
break break
} }
case "azure": { case "azure": {
@@ -498,7 +449,7 @@ function buildProviderOptions(
} }
// Map of provider to required environment variable // Map of provider to required environment variable
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = { const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: "OPENAI_API_KEY", openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY", anthropic: "ANTHROPIC_API_KEY",
@@ -1033,8 +984,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
// Like other providers, a user's key never goes to the server's // Like other providers, a user's key never goes to the server's
// base URL: without a URL of their own it goes to Ollama Cloud. // base URL: without a URL of their own it goes to Ollama Cloud.
// The server's key goes to OLLAMA_BASE_URL (or a server model's // The server's key goes to OLLAMA_BASE_URL (or a server model's
// own variable), else to the SDK's local default: the desktop // own variable), else to the SDK's local default.
// app's "Ollama (Local)" preset puts its key field there too.
const baseURL = const baseURL =
overrides?.baseUrl || overrides?.baseUrl ||
(overrides?.apiKey (overrides?.apiKey
@@ -1203,7 +1153,7 @@ export function usesServerCredentials(
provider: ProviderName, provider: ProviderName,
overrides?: ClientOverrides, overrides?: ClientOverrides,
): boolean { ): boolean {
// The desktop app's local server holds the user's own preset keys // The desktop app's local server only has the user's own keys (its .env)
if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false
// Cleaned like getAIModel does: "/" means no base URL // Cleaned like getAIModel does: "/" means no base URL
const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "") const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "")
+2 -3
View File
@@ -892,9 +892,9 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
}, },
] ]
// The chat panel answers a built-in example from here, without a request.
// Examples that come with a file only match when that exact example file is // Examples that come with a file only match when that exact example file is
// attached, so a user's own file with the same prompt still goes to the model. // attached, so a user's own file with the same prompt still goes to the model.
// Callers that can't tell file names (the server) only get text-only examples.
export function findCachedResponse( export function findCachedResponse(
promptText: string, promptText: string,
hasImage: boolean, hasImage: boolean,
@@ -904,7 +904,6 @@ export function findCachedResponse(
(c) => (c) =>
c.promptText === promptText && c.promptText === promptText &&
c.hasImage === hasImage && c.hasImage === hasImage &&
(!c.fileName || c.fileName === fileName) && (!c.fileName || c.fileName === fileName),
c.xml !== "",
) )
} }
-29
View File
@@ -58,35 +58,6 @@ function isValidToolInput(input: unknown): boolean {
return !!input && typeof input === "object" && Object.keys(input).length > 0 return !!input && typeof input === "object" && Object.keys(input).length > 0
} }
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content.map((part: any) => {
if (
part.type === "tool-call" &&
isValidToolInput(part.input) &&
(part.toolName === "display_diagram" ||
part.toolName === "edit_diagram")
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
return part
})
return { ...msg, content: replacedContent }
})
}
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming), // Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
// together with their tool-results: providers reject a result whose call is missing. // together with their tool-results: providers reject a result whose call is missing.
// Messages left empty are removed too (Bedrock rejects empty content arrays). // Messages left empty are removed too (Bedrock rejects empty content arrays).
+44
View File
@@ -0,0 +1,44 @@
import type { InferUITools, UIMessage } from "ai"
import type { chatTools } from "@/lib/chat-tools"
import type { SelectedCell } from "@/stores/canvas-store"
/** Shapes sent with a user message (also kept in its metadata) */
export type MessageSelection = Pick<SelectedCell, "id" | "label">[]
export interface ChatMessageMetadata {
selectedCells?: MessageSelection
}
/**
* A failed request or an answer that drew nothing, shown in the chat with
* what to do: an assistant message with this as its only part. The server
* ignores data parts, so it never reaches the model.
*/
export interface ChatNotice {
text: string
/** Show a button that opens model settings, on the provider that refused */
openModelConfig?: boolean
providerId?: string
}
export type ChatToolName = keyof InferUITools<typeof chatTools>
export type ChatUIMessage = UIMessage<
ChatMessageMetadata,
{ notice: ChatNotice },
InferUITools<typeof chatTools>
>
export function noticeMessage(notice: ChatNotice): ChatUIMessage {
return {
id: `notice-${Date.now()}`,
role: "assistant",
parts: [{ type: "data-notice", data: notice }],
}
}
/** The notice a message carries, if it is one */
export function noticeOf(message: ChatUIMessage): ChatNotice | undefined {
const part = message.parts.find((p) => p.type === "data-notice")
return part?.data
}
+138
View File
@@ -0,0 +1,138 @@
import { type InferUITools, tool } from "ai"
import path from "path"
import { z } from "zod"
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"
/**
* The chat's tools. display_diagram, edit_diagram and append_diagram run in
* the browser: the chat engine's tool handler puts their result on the
* canvas and reports it back as text, so they have an output schema and no
* execute. get_shape_library runs on the server.
*/
export const chatTools = {
display_diagram: tool({
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: &lt; &gt; &amp; &quot;
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 &lt;br&gt; for a line break, never \\n; a literal < or > is written &amp;lt; or &amp;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"),
}),
outputSchema: z.string(),
}),
edit_diagram: tool({
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"),
}),
outputSchema: z.string(),
}),
append_diagram: tool({
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)",
),
}),
outputSchema: z.string(),
}),
get_shape_library: tool({
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
},
}),
}
/** The tool names and their input and output types, for the UI messages */
export type ChatTools = InferUITools<typeof chatTools>
+10 -15
View File
@@ -1,4 +1,9 @@
import { modelOfPage, pageElement } from "@/lib/diagram-pages" import {
fileVars,
modelOfPage,
pageElement,
parseXml,
} from "@/lib/diagram-pages"
/** What changed on one page between two versions of a diagram */ /** What changed on one page between two versions of a diagram */
export interface ChangeSummary { export interface ChangeSummary {
@@ -32,18 +37,12 @@ interface CellInfo {
signature: string signature: string
} }
function parse(xml: string): Document | null {
if (!xml?.trim()) return null
const doc = new DOMParser().parseFromString(xml, "text/xml")
return doc.querySelector("parsererror") ? null : doc
}
/** /**
* A page's mxGraphModel element, whatever wrapper the XML has: the page * A page's mxGraphModel element, whatever wrapper the XML has: the page
* with this id, else the first * with this id, else the first
*/ */
function pageModel(xml: string, pageId?: string | null): Element | null { function pageModel(xml: string, pageId?: string | null): Element | null {
const doc = parse(xml) const doc = parseXml(xml)
if (!doc) return null if (!doc) return null
const diagram = pageElement(doc, pageId) const diagram = pageElement(doc, pageId)
if (diagram) return modelOfPage(diagram) if (diagram) return modelOfPage(diagram)
@@ -304,14 +303,10 @@ export function isSameDocument(a: string, b: string): boolean {
model: modelOfPage(d), model: modelOfPage(d),
})) }))
} }
const docA = parse(a) const docA = parseXml(a)
const docB = parse(b) const docB = parseXml(b)
if (!docA || !docB) return a === b if (!docA || !docB) return a === b
const varsOf = (doc: Document) => if (!sameFileVars(fileVars(docA), fileVars(docB))) return false
doc.documentElement.nodeName === "mxfile"
? doc.documentElement.getAttribute("vars")
: null
if (!sameFileVars(varsOf(docA), varsOf(docB))) return false
const pagesA = pagesOf(docA) const pagesA = pagesOf(docA)
const pagesB = pagesOf(docB) const pagesB = pagesOf(docB)
if (pagesA.length !== pagesB.length) return false if (pagesA.length !== pagesB.length) return false
+35 -17
View File
@@ -12,12 +12,20 @@ import {
type PageSelector, type PageSelector,
} from "@/packages/mcp-server/src/pages.ts" } from "@/packages/mcp-server/src/pages.ts"
function parse(xml: string): Document | null { /** The parsed XML, or null when it is empty or does not parse */
export function parseXml(xml: string): Document | null {
if (!xml?.trim()) return null if (!xml?.trim()) return null
const doc = new DOMParser().parseFromString(xml, "text/xml") const doc = new DOMParser().parseFromString(xml, "text/xml")
return doc.querySelector("parsererror") ? null : doc return doc.querySelector("parsererror") ? null : doc
} }
/** The file variables (%name% placeholders) an <mxfile> sets, else null */
export function fileVars(doc: Document): string | null {
return doc.documentElement.nodeName === "mxfile"
? doc.documentElement.getAttribute("vars")
: null
}
/** The <diagram> the AI works on: the one with this id, else the first */ /** The <diagram> the AI works on: the one with this id, else the first */
export function pageElement( export function pageElement(
doc: Document, doc: Document,
@@ -40,7 +48,7 @@ export function modelOfPage(diagram: Element): Element | null {
if (model) return model if (model) return model
const inflated = decompressPageContent(diagram.textContent || "") const inflated = decompressPageContent(diagram.textContent || "")
if (!inflated) return null if (!inflated) return null
return parse(inflated)?.querySelector("mxGraphModel") ?? null return parseXml(inflated)?.querySelector("mxGraphModel") ?? null
} }
/** /**
@@ -52,7 +60,7 @@ export function pageSelectorFor(
pageId: string | null | undefined, pageId: string | null | undefined,
): PageSelector { ): PageSelector {
if (!pageId) return {} if (!pageId) return {}
const doc = parse(xml) const doc = parseXml(xml)
const found = doc const found = doc
? Array.from(doc.getElementsByTagName("diagram")).some( ? Array.from(doc.getElementsByTagName("diagram")).some(
(diagram) => diagram.getAttribute("id") === pageId, (diagram) => diagram.getAttribute("id") === pageId,
@@ -69,7 +77,7 @@ export function pageModelXml(
xml: string, xml: string,
pageId: string | null | undefined, pageId: string | null | undefined,
): string | null { ): string | null {
const doc = parse(xml) const doc = parseXml(xml)
if (!doc) return null if (!doc) return null
const root = doc.documentElement const root = doc.documentElement
if (root.nodeName === "mxGraphModel") return xml if (root.nodeName === "mxGraphModel") return xml
@@ -90,15 +98,15 @@ export function replacePageModel(
pageId: string | null | undefined, pageId: string | null | undefined,
modelXml: string, modelXml: string,
): string { ): string {
const model = parse(modelXml) const model = parseXml(modelXml)
if (model?.documentElement.nodeName !== "mxGraphModel") { if (model?.documentElement.nodeName !== "mxGraphModel") {
throw new Error("replacePageModel needs an <mxGraphModel>") throw new Error("replacePageModel needs an <mxGraphModel>")
} }
let doc = parse(fileXml) let doc = parseXml(fileXml)
if (doc?.documentElement.nodeName !== "mxfile") doc = parse(BLANK_MXFILE) if (doc?.documentElement.nodeName !== "mxfile") doc = parseXml(BLANK_MXFILE)
let diagram = pageElement(doc as Document, pageId) let diagram = pageElement(doc as Document, pageId)
if (!diagram) { if (!diagram) {
doc = parse(BLANK_MXFILE) doc = parseXml(BLANK_MXFILE)
diagram = pageElement(doc as Document, null) diagram = pageElement(doc as Document, null)
} }
const target = diagram as Element const target = diagram as Element
@@ -112,27 +120,37 @@ export function replacePageModel(
/** /**
* Where a drawn diagram goes. One page's worth (bare cells wrapped by * Where a drawn diagram goes. One page's worth (bare cells wrapped by
* prepareNewDiagram, or a one-page file) replaces the AI's page of the * prepareNewDiagram, or a one-page file) replaces the AI's page of the
* canvas file; file variables the drawn file sets replace the canvas * canvas file. A file with several pages replaces the whole document: the
* file's. A file with several pages replaces the whole document: the model * model wrote the pages on purpose. Either way, file variables the drawn
* wrote the pages on purpose. * file sets replace the canvas file's, and a drawn file without any keeps
* the canvas file's (as replacing the page in draw.io does).
*/ */
export function placeOnPage( export function placeOnPage(
drawnXml: string, drawnXml: string,
canvasXml: string, canvasXml: string,
pageId: string | null | undefined, pageId: string | null | undefined,
): string { ): string {
const doc = parse(drawnXml) const doc = parseXml(drawnXml)
const drawnFile = const drawnFile =
doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null
if (drawnFile && drawnFile.getElementsByTagName("diagram").length > 1) { const severalPages =
return drawnXml (drawnFile?.getElementsByTagName("diagram").length ?? 0) > 1
const model = severalPages ? null : pageModelXml(drawnXml, null)
if (!model) {
// The drawn document as it is, with the canvas file's variables
// when it sets none itself
const canvas = parseXml(canvasXml)
const canvasVars = canvas ? fileVars(canvas) : null
if (!doc || !drawnFile || fileVars(doc) !== null || !canvasVars) {
return drawnXml
}
drawnFile.setAttribute("vars", canvasVars)
return new XMLSerializer().serializeToString(doc)
} }
const model = pageModelXml(drawnXml, null)
if (!model) return drawnXml
const placed = replacePageModel(canvasXml, pageId, model) const placed = replacePageModel(canvasXml, pageId, model)
const vars = drawnFile?.getAttribute("vars") const vars = drawnFile?.getAttribute("vars")
if (vars === null || vars === undefined) return placed if (vars === null || vars === undefined) return placed
const result = parse(placed) as Document const result = parseXml(placed) as Document
result.documentElement.setAttribute("vars", vars) result.documentElement.setAttribute("vars", vars)
return new XMLSerializer().serializeToString(result) return new XMLSerializer().serializeToString(result)
} }
+2 -2
View File
@@ -1,10 +1,10 @@
/** /**
* Types and utilities for VLM-based diagram validation. * Types and utilities for VLM-based diagram validation.
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject. * The validation itself runs in the useValidateDiagram hook (a JSON request to /api/validate-diagram).
*/ */
// Re-export types from the schema file (single source of truth) // Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema" export type { ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema" import type { ValidationResult } from "./validation-schema"
-13
View File
@@ -666,18 +666,6 @@ function readSelectionRect() {
} }
} }
// ---------------------------------------------------------------------------
// Pages
// ---------------------------------------------------------------------------
function readPages() {
const pages = (ui?.pages ?? []) as any[]
return pages.map((page, index) => ({
id: String(page.getId?.() ?? index),
name: String(page.getName?.() ?? `Page-${index + 1}`),
}))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Keeping the store in sync // Keeping the store in sync
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -690,7 +678,6 @@ function syncAll() {
if (!ui) return if (!ui) return
const g = graph() const g = graph()
useCanvasStore.getState().set({ useCanvasStore.getState().set({
pages: readPages(),
currentPageId: ui.currentPage?.getId currentPageId: ui.currentPage?.getId
? String(ui.currentPage.getId()) ? String(ui.currentPage.getId())
: null, : null,
+45 -61
View File
@@ -1,7 +1,6 @@
import { import {
ConditionalCheckFailedException, ConditionalCheckFailedException,
DynamoDBClient, DynamoDBClient,
GetItemCommand,
UpdateItemCommand, UpdateItemCommand,
} from "@aws-sdk/client-dynamodb" } from "@aws-sdk/client-dynamodb"
@@ -108,76 +107,61 @@ export async function checkAndIncrementRequest(
":tokenLimit": { N: String(limits.tokens || 999999) }, ":tokenLimit": { N: String(limits.tokens || 999999) },
":tpmLimit": { N: String(limits.tpm || 999999) }, ":tpmLimit": { N: String(limits.tpm || 999999) },
}, },
// On a failed condition the exception carries the item as it
// was, so no second read is needed to tell which limit was hit
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
}), }),
) )
return { allowed: true } return { allowed: true }
} catch (e: any) { } catch (e: any) {
// Condition failed - need to determine which limit was exceeded // Condition failed - the exception's Item holds the current counts
if (e instanceof ConditionalCheckFailedException) { if (e instanceof ConditionalCheckFailedException) {
// Get current counts to determine which limit was hit const item = e.Item
try { const storedMinute = item?.lastMinute?.S
const getResult = await client.send(
new GetItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
}),
)
const item = getResult.Item const reqCount = Number(item?.reqCount?.N || 0)
const storedMinute = item?.lastMinute?.S const tokenCount = Number(item?.tokenCount?.N || 0)
const tpmCount =
storedMinute !== currentMinute
? 0
: Number(item?.tpmCount?.N || 0)
const reqCount = Number(item?.reqCount?.N || 0) // Determine which limit was exceeded
const tokenCount = Number(item?.tokenCount?.N || 0) if (limits.requests > 0 && reqCount >= limits.requests) {
const tpmCount = return {
storedMinute !== currentMinute allowed: false,
? 0 type: "request",
: Number(item?.tpmCount?.N || 0) error: "Daily request limit exceeded",
used: reqCount,
// Determine which limit was exceeded limit: limits.requests,
if (limits.requests > 0 && reqCount >= limits.requests) {
return {
allowed: false,
type: "request",
error: "Daily request limit exceeded",
used: reqCount,
limit: limits.requests,
}
} }
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
limit: limits.tokens,
}
}
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
return {
allowed: false,
type: "tpm",
error: "Rate limit exceeded (tokens per minute)",
used: tpmCount,
limit: limits.tpm,
}
}
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
return { allowed: true }
} catch (getError: any) {
console.error(
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
)
return { allowed: true } // Fail open
} }
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
limit: limits.tokens,
}
}
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
return {
allowed: false,
type: "tpm",
error: "Rate limit exceeded (tokens per minute)",
used: tpmCount,
limit: limits.tpm,
}
}
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
return { allowed: true }
} }
// Other DynamoDB errors - fail open // Other DynamoDB errors - fail open
+1 -90
View File
@@ -3,48 +3,25 @@
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"close": "Close", "close": "Close",
"clear": "Clear",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"loading": "Loading.." "loading": "Loading.."
}, },
"nav": { "nav": {
"editor": "Editor",
"newChat": "New diagram", "newChat": "New diagram",
"github": "GitHub",
"settings": "Settings", "settings": "Settings",
"hidePanel": "Hide chat panel", "hidePanel": "Hide chat panel",
"showPanel": "Show chat panel", "showPanel": "Show chat panel",
"aiChat": "AI chat" "aiChat": "AI chat"
}, },
"providers": {
"useServerDefault": "Use Server Default",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": { "chat": {
"placeholder": "Describe your diagram or upload a file...", "placeholder": "Describe your diagram or upload a file...",
"inputLabel": "Chat input", "inputLabel": "Chat input",
"send": "Send", "send": "Send",
"stopGeneration": "Stop generation", "stopGeneration": "Stop generation",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"uploadFile": "Upload file (image, PDF, text)", "uploadFile": "Upload file (image, PDF, text)",
"minimalStyle": "Minimal", "minimalStyle": "Minimal",
"styledMode": "Styled", "styledMode": "Styled",
"minimalTooltip": "Use minimal for faster generation (no colors)",
"regenerate": "Regenerate response", "regenerate": "Regenerate response",
"copyResponse": "Copy response", "copyResponse": "Copy response",
"copied": "Copied!", "copied": "Copied!",
@@ -82,17 +59,6 @@
"accessCode": "Access code", "accessCode": "Access code",
"accessCodePlaceholder": "Enter access code", "accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.", "accessCodeDescription": "Required to use this application.",
"aiProvider": "AI Provider Settings",
"aiProviderDescription": "Use your own API key to bypass usage limits. Your key is stored locally in your browser and is never stored on the server.",
"provider": "Provider",
"modelId": "Model ID",
"apiKey": "API Key",
"apiKeyPlaceholder": "Your API key",
"baseUrl": "Base URL (optional)",
"customEndpoint": "Custom endpoint URL",
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"language": "Language", "language": "Language",
"languageDescription": "Choose your interface language.", "languageDescription": "Choose your interface language.",
"theme": "Theme", "theme": "Theme",
@@ -103,10 +69,6 @@
"sendShortcutDescription": "Choose how to send messages.", "sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send", "enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send", "ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download",
"proxy": "Proxy settings", "proxy": "Proxy settings",
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)", "proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
"httpProxy": "HTTP proxy", "httpProxy": "HTTP proxy",
@@ -170,13 +132,9 @@
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.", "retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.", "continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
"sessionCorrupted": "Session data was corrupted. Starting fresh.", "sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage",
"failedToPersist": "Failed to persist state before unload",
"failedToExport": "Error fetching chart data", "failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image", "failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.", "failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.", "sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.", "sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
"continueWithoutSaving": "Continue without saving", "continueWithoutSaving": "Continue without saving",
@@ -208,8 +166,6 @@
"quota": { "quota": {
"dailyLimit": "Daily Quota Reached", "dailyLimit": "Daily Quota Reached",
"tokenLimit": "Daily Token Limit Reached", "tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.", "tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.", "messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
"messageApiSelfHosted": null, "messageApiSelfHosted": null,
@@ -221,7 +177,6 @@
"configModel": "Use Your API Key", "configModel": "Use Your API Key",
"selfHost": "Self-host", "selfHost": "Self-host",
"sponsor": "Sponsor", "sponsor": "Sponsor",
"learnMore": "Learn more →",
"usedOf": "{used}/{limit}" "usedOf": "{used}/{limit}"
}, },
"tools": { "tools": {
@@ -248,37 +203,8 @@
"thoughtForOne": "Thought for 1 second", "thoughtForOne": "Thought for 1 second",
"thoughtBrief": "Thought for a few seconds" "thoughtBrief": "Thought for a few seconds"
}, },
"dev": {
"title": "Dev: XML Streaming Simulator",
"preset": "Preset:",
"selectPreset": "Select a preset...",
"clear": "Clear",
"placeholder": "Paste mxCell XML here or select a preset...",
"interval": "Interval:",
"chars": "Chars:",
"streaming": "Streaming...",
"simulate": "Simulate",
"stop": "Stop",
"testQuotaToast": "Test Quota Toast",
"simulatingMessage": "[Dev] Simulating XML streaming",
"successMessage": "Successfully displayed the diagram."
},
"about": {
"modelChange": "Model Change & Usage Limits",
"walletCrying": "(Or: Why My Wallet is Crying)",
"seekingSponsorship": "Call for Sponsorship",
"contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
},
"sessionHistory": { "sessionHistory": {
"tooltip": "Chat History",
"newChat": "New Chat",
"empty": "No diagrams yet", "empty": "No diagrams yet",
"emptyHint": "Start a conversation to begin",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"earlier": "Earlier",
"deleteTitle": "Delete this diagram?", "deleteTitle": "Delete this diagram?",
"deleteDescription": "The diagram and its conversation are deleted for good.", "deleteDescription": "The diagram and its conversation are deleted for good.",
"justNow": "Just now", "justNow": "Just now",
@@ -351,20 +277,13 @@
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback" "regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
}, },
"modelConfig": { "modelConfig": {
"configure": "Configure",
"addProvider": "Add provider", "addProvider": "Add provider",
"addModel": "Add Model",
"modelId": "Model ID", "modelId": "Model ID",
"modelLabel": "Display Label",
"streaming": "Enable Streaming",
"deleteProvider": "Delete provider", "deleteProvider": "Delete provider",
"deleteModel": "Delete Model",
"noModels": "No models configured. Add a model to get started.",
"validationError": "The test failed", "validationError": "The test failed",
"addModelFirst": "Add a model first.", "addModelFirst": "Add a model first.",
"displayName": "Display Name", "displayName": "Display Name",
"bedrockApiKey": "Bedrock API key", "bedrockApiKey": "Bedrock API key",
"bedrockApiKeyHint": "With an API key, the access keys below are not needed.",
"awsAccessKeyId": "AWS Access Key ID", "awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key", "awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region", "awsRegion": "AWS Region",
@@ -372,7 +291,6 @@
"apiKey": "API key", "apiKey": "API key",
"enterApiKey": "Enter your API key", "enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key", "enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)", "optional": "(optional)",
"getApiKey": "Get API key", "getApiKey": "Get API key",
"noTools": "no tool calls", "noTools": "no tool calls",
@@ -388,8 +306,6 @@
"modelIdEmpty": "Model ID cannot be empty", "modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists", "modelIdExists": "This model ID already exists",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.", "deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type the provider name...",
"requestFailed": "Request failed ({status})", "requestFailed": "Request failed ({status})",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
@@ -583,10 +499,6 @@
"VALIDATION_TIMEOUT": { "VALIDATION_TIMEOUT": {
"label": "Check Timeout (ms)" "label": "Check Timeout (ms)"
}, },
"ENABLE_HISTORY_XML_REPLACE": {
"label": "Trim Old Diagrams",
"description": "Old diagrams in the chat history are kept as a short marker"
},
"ALLOW_PRIVATE_URLS": { "ALLOW_PRIVATE_URLS": {
"label": "Allow Private URLs", "label": "Allow Private URLs",
"description": "When off, custom model URLs can't point to private networks; safer" "description": "When off, custom model URLs can't point to private networks; safer"
@@ -697,8 +609,7 @@
"lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.", "lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries", "placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams", "recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas", "drawYourself": "Start with a blank canvas"
"openFileHint": "Keep working on a diagram you have"
}, },
"shell": { "shell": {
"waiting": "Waiting for the MCP server…", "waiting": "Waiting for the MCP server…",
+1 -90
View File
@@ -3,48 +3,25 @@
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"close": "閉じる", "close": "閉じる",
"clear": "クリア",
"edit": "編集", "edit": "編集",
"delete": "削除", "delete": "削除",
"loading": "読み込み中.." "loading": "読み込み中.."
}, },
"nav": { "nav": {
"editor": "エディタ",
"newChat": "新しい図", "newChat": "新しい図",
"github": "GitHub",
"settings": "設定", "settings": "設定",
"hidePanel": "チャットパネルを非表示", "hidePanel": "チャットパネルを非表示",
"showPanel": "チャットパネルを表示", "showPanel": "チャットパネルを表示",
"aiChat": "AI チャット" "aiChat": "AI チャット"
}, },
"providers": {
"useServerDefault": "サーバーデフォルトを使用",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": { "chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"inputLabel": "チャット入力", "inputLabel": "チャット入力",
"send": "送信", "send": "送信",
"stopGeneration": "生成を停止", "stopGeneration": "生成を停止",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)", "uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
"minimalStyle": "ミニマル", "minimalStyle": "ミニマル",
"styledMode": "スタイル付き", "styledMode": "スタイル付き",
"minimalTooltip": "高速生成のためミニマルを使用(色なし)",
"regenerate": "応答を再生成", "regenerate": "応答を再生成",
"copyResponse": "応答をコピー", "copyResponse": "応答をコピー",
"copied": "コピーしました!", "copied": "コピーしました!",
@@ -82,17 +59,6 @@
"accessCode": "アクセスコード", "accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力", "accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。", "accessCodeDescription": "このアプリケーションを使用するために必要です。",
"aiProvider": "AI プロバイダー設定",
"aiProviderDescription": "独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。",
"provider": "プロバイダー",
"modelId": "モデル ID",
"apiKey": "API キー",
"apiKeyPlaceholder": "あなたの API キー",
"baseUrl": "ベース URL(オプション)",
"customEndpoint": "カスタムエンドポイント URL",
"overrides": "上書き",
"clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用",
"language": "言語", "language": "言語",
"languageDescription": "インターフェース言語を選択します。", "languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ", "theme": "テーマ",
@@ -103,10 +69,6 @@
"sendShortcutDescription": "メッセージの送信方法を選択します。", "sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信", "enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信", "ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード",
"proxy": "プロキシ設定", "proxy": "プロキシ設定",
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)", "proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
"httpProxy": "HTTP プロキシ", "httpProxy": "HTTP プロキシ",
@@ -170,13 +132,9 @@
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。", "retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。", "continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。", "sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました",
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
"failedToExport": "チャートデータの取得エラー", "failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー", "failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。", "failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。", "sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。", "sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける", "continueWithoutSaving": "保存せずに続ける",
@@ -208,8 +166,6 @@
"quota": { "quota": {
"dailyLimit": "1日の割当量に達しました", "dailyLimit": "1日の割当量に達しました",
"tokenLimit": "1日のトークン制限に達しました", "tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。", "tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。", "messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
"messageApiSelfHosted": null, "messageApiSelfHosted": null,
@@ -221,7 +177,6 @@
"configModel": "APIキーを使用", "configModel": "APIキーを使用",
"selfHost": "セルフホスト", "selfHost": "セルフホスト",
"sponsor": "スポンサー", "sponsor": "スポンサー",
"learnMore": "詳細 →",
"usedOf": "{used}/{limit}" "usedOf": "{used}/{limit}"
}, },
"tools": { "tools": {
@@ -248,37 +203,8 @@
"thoughtForOne": "1 秒考えました", "thoughtForOne": "1 秒考えました",
"thoughtBrief": "数秒考えました" "thoughtBrief": "数秒考えました"
}, },
"dev": {
"title": "開発:XMLストリーミングシミュレーター",
"preset": "プリセット:",
"selectPreset": "プリセットを選択...",
"clear": "クリア",
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
"interval": "間隔:",
"chars": "文字:",
"streaming": "ストリーミング中...",
"simulate": "シミュレート",
"stop": "停止",
"testQuotaToast": "クォータトーストをテスト",
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
"successMessage": "ダイアグラムの表示に成功しました。"
},
"about": {
"modelChange": "モデル変更と利用制限について",
"walletCrying": "(別名:お財布が悲鳴を上げています)",
"seekingSponsorship": "スポンサー募集",
"contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"sessionHistory": { "sessionHistory": {
"tooltip": "チャット履歴",
"newChat": "新しいチャット",
"empty": "まだ図はありません", "empty": "まだ図はありません",
"emptyHint": "会話を始めてください",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "この図を削除しますか?", "deleteTitle": "この図を削除しますか?",
"deleteDescription": "図とその会話は完全に削除されます。", "deleteDescription": "図とその会話は完全に削除されます。",
"justNow": "たった今", "justNow": "たった今",
@@ -306,20 +232,13 @@
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成" "regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
}, },
"modelConfig": { "modelConfig": {
"configure": "設定",
"addProvider": "プロバイダーを追加", "addProvider": "プロバイダーを追加",
"addModel": "モデルを追加",
"modelId": "モデルID", "modelId": "モデルID",
"modelLabel": "表示名",
"streaming": "ストリーミングを有効",
"deleteProvider": "プロバイダーを削除", "deleteProvider": "プロバイダーを削除",
"deleteModel": "モデルを削除",
"noModels": "モデルが設定されていません。モデルを追加してください。",
"validationError": "テストに失敗しました", "validationError": "テストに失敗しました",
"addModelFirst": "先にモデルを追加してください。", "addModelFirst": "先にモデルを追加してください。",
"displayName": "表示名", "displayName": "表示名",
"bedrockApiKey": "Bedrock API キー", "bedrockApiKey": "Bedrock API キー",
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
"awsAccessKeyId": "AWS アクセスキー ID", "awsAccessKeyId": "AWS アクセスキー ID",
"awsSecretAccessKey": "AWS シークレットアクセスキー", "awsSecretAccessKey": "AWS シークレットアクセスキー",
"awsRegion": "AWS リージョン", "awsRegion": "AWS リージョン",
@@ -327,7 +246,6 @@
"apiKey": "API キー", "apiKey": "API キー",
"enterApiKey": "API キーを入力", "enterApiKey": "API キーを入力",
"enterSecretKey": "シークレットアクセスキーを入力", "enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)", "optional": "(オプション)",
"getApiKey": "API キーを取得", "getApiKey": "API キーを取得",
"noTools": "ツール呼び出し非対応", "noTools": "ツール呼び出し非対応",
@@ -343,8 +261,6 @@
"modelIdEmpty": "モデル ID は空にできません", "modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します", "modelIdExists": "このモデル ID は既に存在します",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。", "deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...",
"requestFailed": "リクエストに失敗しました({status})", "requestFailed": "リクエストに失敗しました({status})",
"cancel": "キャンセル", "cancel": "キャンセル",
"delete": "削除", "delete": "削除",
@@ -583,10 +499,6 @@
"VALIDATION_TIMEOUT": { "VALIDATION_TIMEOUT": {
"label": "チェックのタイムアウト(ms)" "label": "チェックのタイムアウト(ms)"
}, },
"ENABLE_HISTORY_XML_REPLACE": {
"label": "古い図を省略",
"description": "会話履歴の古い図は短い印だけ残します"
},
"ALLOW_PRIVATE_URLS": { "ALLOW_PRIVATE_URLS": {
"label": "内部ネットワークへのアクセス", "label": "内部ネットワークへのアクセス",
"description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です" "description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です"
@@ -697,8 +609,7 @@
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。", "lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図", "placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図", "recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める", "drawYourself": "白紙のキャンバスから始める"
"openFileHint": "手元の図の続きを編集"
}, },
"shell": { "shell": {
"waiting": "MCP サーバーを待っています…", "waiting": "MCP サーバーを待っています…",
+1 -90
View File
@@ -3,48 +3,25 @@
"save": "儲存", "save": "儲存",
"cancel": "取消", "cancel": "取消",
"close": "關閉", "close": "關閉",
"clear": "清除",
"edit": "編輯", "edit": "編輯",
"delete": "刪除", "delete": "刪除",
"loading": "載入中..." "loading": "載入中..."
}, },
"nav": { "nav": {
"editor": "編輯器",
"newChat": "新增圖紙", "newChat": "新增圖紙",
"github": "GitHub",
"settings": "設定", "settings": "設定",
"hidePanel": "隱藏聊天面板", "hidePanel": "隱藏聊天面板",
"showPanel": "顯示聊天面板", "showPanel": "顯示聊天面板",
"aiChat": "AI 對話" "aiChat": "AI 對話"
}, },
"providers": {
"useServerDefault": "使用伺服器預設值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
},
"chat": { "chat": {
"placeholder": "描述您的圖表或上傳檔案...", "placeholder": "描述您的圖表或上傳檔案...",
"inputLabel": "聊天輸入框", "inputLabel": "聊天輸入框",
"send": "傳送", "send": "傳送",
"stopGeneration": "停止產生", "stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"uploadFile": "上傳檔案(圖片、PDF、文字)", "uploadFile": "上傳檔案(圖片、PDF、文字)",
"minimalStyle": "簡約", "minimalStyle": "簡約",
"styledMode": "精緻", "styledMode": "精緻",
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
"regenerate": "重新產生回應", "regenerate": "重新產生回應",
"copyResponse": "複製回應", "copyResponse": "複製回應",
"copied": "已複製!", "copied": "已複製!",
@@ -82,17 +59,6 @@
"accessCode": "存取碼", "accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼", "accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。", "accessCodeDescription": "使用此應用程式需要存取碼。",
"aiProvider": "AI 服務商設定",
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
"provider": "服務商",
"modelId": "模型 ID",
"apiKey": "API 金鑰",
"apiKeyPlaceholder": "您的 API 金鑰",
"baseUrl": "基礎 URL(可選)",
"customEndpoint": "自訂端點 URL",
"overrides": "覆寫",
"clearSettings": "清除設定",
"useServerDefault": "使用伺服器預設值",
"language": "語言", "language": "語言",
"languageDescription": "選擇介面語言。", "languageDescription": "選擇介面語言。",
"theme": "主題", "theme": "主題",
@@ -103,10 +69,6 @@
"sendShortcutDescription": "選擇傳送訊息的方式。", "sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送", "enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送", "ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
"diagramActions": "圖表操作",
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
"history": "歷史紀錄",
"download": "下載",
"proxy": "代理設定", "proxy": "代理設定",
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)", "proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
"httpProxy": "HTTP 代理", "httpProxy": "HTTP 代理",
@@ -170,13 +132,9 @@
"retryLimit": "已達自動重試限制({max})。請手動重試。", "retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。", "continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。", "sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
"failedToPersist": "卸載前無法持久化狀態",
"failedToExport": "取得圖表資料時出錯", "failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯", "failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。", "failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。", "sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。", "sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續", "continueWithoutSaving": "不儲存,繼續",
@@ -208,8 +166,6 @@
"quota": { "quota": {
"dailyLimit": "已達每日配額", "dailyLimit": "已達每日配額",
"tokenLimit": "已達每日 token 上限", "tokenLimit": "已達每日 token 上限",
"tpmLimit": "速率限制",
"tpmMessage": "請求過多。請稍等片刻。",
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。", "tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。", "messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
"messageApiSelfHosted": null, "messageApiSelfHosted": null,
@@ -221,7 +177,6 @@
"configModel": "使用自己的 API Key", "configModel": "使用自己的 API Key",
"selfHost": "自行託管", "selfHost": "自行託管",
"sponsor": "贊助", "sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}" "usedOf": "{used}/{limit}"
}, },
"tools": { "tools": {
@@ -248,37 +203,8 @@
"thoughtForOne": "思考了 1 秒", "thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了幾秒鐘" "thoughtBrief": "思考了幾秒鐘"
}, },
"dev": {
"title": "開發:XML 串流模擬器",
"preset": "預設:",
"selectPreset": "選擇預設...",
"clear": "清除",
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
"interval": "間隔:",
"chars": "字元:",
"streaming": "串流傳輸中...",
"simulate": "模擬",
"stop": "停止",
"testQuotaToast": "測試配額提示",
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
"successMessage": "成功顯示圖表。"
},
"about": {
"modelChange": "模型變更與用量限制",
"walletCrying": "(別名:我的錢包頂不住了)",
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
"contactMe": "聯絡我",
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": { "sessionHistory": {
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "還沒有圖紙", "empty": "還沒有圖紙",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除這張圖紙?", "deleteTitle": "刪除這張圖紙?",
"deleteDescription": "圖紙和它的對話會被永久刪除。", "deleteDescription": "圖紙和它的對話會被永久刪除。",
"justNow": "剛剛", "justNow": "剛剛",
@@ -351,20 +277,13 @@
"regenerateWithFeedback": "使用驗證回饋重新產生圖表" "regenerateWithFeedback": "使用驗證回饋重新產生圖表"
}, },
"modelConfig": { "modelConfig": {
"configure": "配置",
"addProvider": "新增服務商", "addProvider": "新增服務商",
"addModel": "新增模型",
"modelId": "模型 ID", "modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除服務商", "deleteProvider": "刪除服務商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"validationError": "測試沒通過", "validationError": "測試沒通過",
"addModelFirst": "先新增一個模型。", "addModelFirst": "先新增一個模型。",
"displayName": "顯示名稱", "displayName": "顯示名稱",
"bedrockApiKey": "Bedrock API Key", "bedrockApiKey": "Bedrock API Key",
"bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。",
"awsAccessKeyId": "AWS 存取金鑰 ID", "awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key", "awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域", "awsRegion": "AWS 區域",
@@ -372,7 +291,6 @@
"apiKey": "API Key", "apiKey": "API Key",
"enterApiKey": "輸入 API Key", "enterApiKey": "輸入 API Key",
"enterSecretKey": "輸入 Secret Access Key", "enterSecretKey": "輸入 Secret Access Key",
"baseUrl": "基礎 URL",
"optional": "(可選)", "optional": "(可選)",
"getApiKey": "取得 API Key", "getApiKey": "取得 API Key",
"noTools": "不支援工具呼叫", "noTools": "不支援工具呼叫",
@@ -388,8 +306,6 @@
"modelIdEmpty": "模型 ID 不能為空", "modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在", "modelIdExists": "此模型 ID 已存在",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。", "deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入服務商名稱...",
"requestFailed": "請求失敗({status})", "requestFailed": "請求失敗({status})",
"cancel": "取消", "cancel": "取消",
"delete": "刪除", "delete": "刪除",
@@ -583,10 +499,6 @@
"VALIDATION_TIMEOUT": { "VALIDATION_TIMEOUT": {
"label": "檢查逾時(毫秒)" "label": "檢查逾時(毫秒)"
}, },
"ENABLE_HISTORY_XML_REPLACE": {
"label": "精簡舊圖",
"description": "對話歷史裡的舊圖表只留簡短標記"
},
"ALLOW_PRIVATE_URLS": { "ALLOW_PRIVATE_URLS": {
"label": "存取內網", "label": "存取內網",
"description": "關閉後,自訂模型位址不能指向內網,更安全" "description": "關閉後,自訂模型位址不能指向內網,更安全"
@@ -697,8 +609,7 @@
"lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。", "lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試", "placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙", "recent": "最近的圖紙",
"drawYourself": "從空白畫布開始", "drawYourself": "從空白畫布開始"
"openFileHint": "接著編輯已有的圖"
}, },
"shell": { "shell": {
"waiting": "正在等待 MCP 伺服器…", "waiting": "正在等待 MCP 伺服器…",

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