mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c43d3b2a | ||
|
|
876d3dec8c | ||
|
|
24d1850518 | ||
|
|
fdc974e85a | ||
|
|
70d392729a | ||
|
|
00aa3c22e6 | ||
|
|
4bd15ffcf6 | ||
|
|
c9c610e929 | ||
|
|
e93f7547c2 | ||
|
|
f257d8ee99 | ||
|
|
467fd25fd1 | ||
|
|
734a31d4eb | ||
|
|
f7178510f4 | ||
|
|
0ed9643af1 | ||
|
|
58cef00873 | ||
|
|
27c49a68d0 | ||
|
|
af842ac287 | ||
|
|
b3dcd91f54 | ||
|
|
5c923adf7b | ||
|
|
0de1cdfe7c | ||
|
|
66c9daf5ca | ||
|
|
a96dcbb21f | ||
|
|
3422b5c55b | ||
|
|
e533aac6c4 | ||
|
|
3b3219c73d | ||
|
|
349ea26d8b | ||
|
|
3ef14318f9 | ||
|
|
b6bd032c2c | ||
|
|
5ed8940b48 | ||
|
|
21807417c5 | ||
|
|
a2ca229830 | ||
|
|
b4c34c9361 | ||
|
|
1f27036de1 | ||
|
|
7be9b5ace8 | ||
|
|
b6f42bcddc | ||
|
|
77c6a2d807 | ||
|
|
7e2edbf8a1 | ||
|
|
20ce02a602 | ||
|
|
a407f66268 | ||
|
|
6e375fb963 | ||
|
|
48c7e43521 | ||
|
|
8171693ef6 | ||
|
|
7fb78c2de6 | ||
|
|
7f84be7001 | ||
|
|
d415f19cf5 | ||
|
|
38fe675d6b | ||
|
|
f89d23880e | ||
|
|
1aa8abcd6e | ||
|
|
899b6a3b5b |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Lint check
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
@@ -4,11 +4,14 @@ 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
|
||||||
|
|||||||
@@ -6,13 +6,6 @@ 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
|
||||||
@@ -23,15 +16,12 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "20"
|
||||||
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
|
||||||
|
|
||||||
@@ -151,7 +141,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "20"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { FaGithub } from "react-icons/fa"
|
||||||
|
import Image from "@/components/image-with-basepath"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "关于 - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||||
|
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutCN() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Navigation */}
|
||||||
|
<header className="bg-white border-b border-gray-200">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Next AI Draw.io
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
编辑器
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/about/cn"
|
||||||
|
className="text-blue-600 font-semibold"
|
||||||
|
>
|
||||||
|
关于
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
aria-label="在GitHub上查看"
|
||||||
|
>
|
||||||
|
<FaGithub className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<article className="prose prose-lg max-w-none">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||||
|
Next AI Draw.io
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
|
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||||
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
|
{/* Bring Your Own Key */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
|
使用自己的 API Key
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||||
|
您也可以使用自己的 API
|
||||||
|
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||||
|
您的 Key
|
||||||
|
仅保存在浏览器本地,不会被存储在服务器上。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-700">
|
||||||
|
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
功能特性
|
||||||
|
</h2>
|
||||||
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong>LLM驱动的图表创建</strong>
|
||||||
|
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>基于图像的图表复制</strong>
|
||||||
|
:上传现有图表或图像,让AI自动复制和增强
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>版本与撤销</strong>
|
||||||
|
:AI
|
||||||
|
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
|
||||||
|
Ctrl+Z 也能撤回
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>交互式聊天界面</strong>
|
||||||
|
:与AI实时对话来完善您的图表
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>AWS架构图支持</strong>
|
||||||
|
:专门支持生成AWS架构图
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>动画连接器</strong>
|
||||||
|
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Examples */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
示例
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 mb-6">
|
||||||
|
以下是一些示例提示词及其生成的图表:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ResNet50 Architecture */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
ResNet50模型架构动画
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
|
<strong>animated</strong> architecture diagram
|
||||||
|
of the ResNet50 model.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
|
<Image
|
||||||
|
src="/resnet50.svg"
|
||||||
|
alt="ResNet50模型架构图"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagram Grid */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
RAG技术图
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
|
architecture diagram for{" "}
|
||||||
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/rag_prod.svg"
|
||||||
|
alt="RAG架构图"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
React和AWS认证流程
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Generate
|
||||||
|
authentication process using React with{" "}
|
||||||
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/auth.svg"
|
||||||
|
alt="认证架构图"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
敏捷Scrum流程
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Generate agile
|
||||||
|
scrum workflow diagram for software
|
||||||
|
development team.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/agile_scrum.svg"
|
||||||
|
alt="敏捷Scrum流程图"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
开放式创新
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Create
|
||||||
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/inno.svg"
|
||||||
|
alt="开放式创新图"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How It Works */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
工作原理
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong>Next.js</strong>:用于前端框架和路由
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||||
|
<code>@ai-sdk/*</code>
|
||||||
|
):用于流式AI响应和多提供商支持
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>draw.io</strong>
|
||||||
|
:内置在应用里的编辑器,应用可以直接控制它
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mt-4">
|
||||||
|
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Multi-Provider Support */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
多提供商支持
|
||||||
|
</h2>
|
||||||
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
|
<li>字节跳动豆包</li>
|
||||||
|
<li>AWS Bedrock(默认)</li>
|
||||||
|
<li>
|
||||||
|
OpenAI / OpenAI兼容API(通过{" "}
|
||||||
|
<code>OPENAI_BASE_URL</code>)
|
||||||
|
</li>
|
||||||
|
<li>Anthropic</li>
|
||||||
|
<li>Google AI</li>
|
||||||
|
<li>Google Vertex AI</li>
|
||||||
|
<li>Azure OpenAI</li>
|
||||||
|
<li>Ollama</li>
|
||||||
|
<li>OpenRouter</li>
|
||||||
|
<li>DeepSeek</li>
|
||||||
|
<li>SiliconFlow</li>
|
||||||
|
<li>ModelScope</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mt-4">
|
||||||
|
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||||
|
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Support */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
支持与联系
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
如果您觉得这个项目有用,请考虑{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
赞助
|
||||||
|
</a>{" "}
|
||||||
|
来帮助托管在线演示站点!
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 mt-2">
|
||||||
|
如需支持或咨询,请在{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
GitHub仓库
|
||||||
|
</a>{" "}
|
||||||
|
上提交issue或联系:me[at]jiang.jp
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
打开编辑器
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<p className="text-center text-gray-600 text-sm">
|
||||||
|
Next AI Draw.io - 开源AI驱动的图表生成器
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { FaGithub } from "react-icons/fa"
|
||||||
|
import Image from "@/components/image-with-basepath"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "概要 - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||||
|
keywords: [
|
||||||
|
"AIダイアグラム",
|
||||||
|
"draw.io",
|
||||||
|
"AWSアーキテクチャ",
|
||||||
|
"GCPダイアグラム",
|
||||||
|
"Azureダイアグラム",
|
||||||
|
"LLM",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutJA() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Navigation */}
|
||||||
|
<header className="bg-white border-b border-gray-200">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Next AI Draw.io
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
エディタ
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/about/ja"
|
||||||
|
className="text-blue-600 font-semibold"
|
||||||
|
>
|
||||||
|
概要
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
aria-label="GitHubで見る"
|
||||||
|
>
|
||||||
|
<FaGithub className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<article className="prose prose-lg max-w-none">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||||
|
Next AI Draw.io
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
|
AI搭載のダイアグラム作成ツール -
|
||||||
|
チャット、描画、可視化
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||||
|
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||||
|
{/* Bring Your Own Key */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
|
自分のAPIキーを使用
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||||
|
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||||
|
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-700">
|
||||||
|
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
機能
|
||||||
|
</h2>
|
||||||
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong>LLM搭載のダイアグラム作成</strong>
|
||||||
|
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>画像ベースのダイアグラム複製</strong>
|
||||||
|
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>バージョンと取り消し</strong>
|
||||||
|
:AI
|
||||||
|
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
|
||||||
|
Ctrl+Z を押しても元に戻せます
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
インタラクティブなチャットインターフェース
|
||||||
|
</strong>
|
||||||
|
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
AWSアーキテクチャダイアグラムサポート
|
||||||
|
</strong>
|
||||||
|
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>アニメーションコネクタ</strong>
|
||||||
|
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Examples */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
例
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 mb-6">
|
||||||
|
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ResNet50 Architecture */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
ResNet50モデルアーキテクチャアニメーション
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
|
<strong>animated</strong> architecture diagram
|
||||||
|
of the ResNet50 model.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
|
<Image
|
||||||
|
src="/resnet50.svg"
|
||||||
|
alt="ResNet50モデルアーキテクチャ図"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagram Grid */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
RAG技術ダイアグラム
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
|
architecture diagram for{" "}
|
||||||
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/rag_prod.svg"
|
||||||
|
alt="RAGアーキテクチャ図"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
ReactとAWSによる認証
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Generate
|
||||||
|
authentication process using React with{" "}
|
||||||
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/auth.svg"
|
||||||
|
alt="認証アーキテクチャ図"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
アジャイルスクラムプロセス
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Generate agile
|
||||||
|
scrum workflow diagram for software
|
||||||
|
development team.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/agile_scrum.svg"
|
||||||
|
alt="アジャイルスクラム図"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
オープンイノベーション
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
<strong>Prompt:</strong> Create
|
||||||
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
|
<Image
|
||||||
|
src="/inno.svg"
|
||||||
|
alt="オープンイノベーション図"
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How It Works */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
仕組み
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
本アプリケーションは以下の技術を使用しています:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong>Next.js</strong>
|
||||||
|
:フロントエンドフレームワークとルーティング
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||||
|
<code>@ai-sdk/*</code>
|
||||||
|
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>draw.io</strong>
|
||||||
|
:アプリに同梱されたエディター。アプリから直接操作できます
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mt-4">
|
||||||
|
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Multi-Provider Support */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
マルチプロバイダーサポート
|
||||||
|
</h2>
|
||||||
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
|
<li>ByteDance Doubao</li>
|
||||||
|
<li>AWS Bedrock(デフォルト)</li>
|
||||||
|
<li>
|
||||||
|
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||||
|
経由)
|
||||||
|
</li>
|
||||||
|
<li>Anthropic</li>
|
||||||
|
<li>Google AI</li>
|
||||||
|
<li>Google Vertex AI</li>
|
||||||
|
<li>Azure OpenAI</li>
|
||||||
|
<li>Ollama</li>
|
||||||
|
<li>OpenRouter</li>
|
||||||
|
<li>DeepSeek</li>
|
||||||
|
<li>SiliconFlow</li>
|
||||||
|
<li>ModelScope</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mt-4">
|
||||||
|
注:<code>claude-sonnet-4-5</code>
|
||||||
|
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Support */}
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
|
サポート&お問い合わせ
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
スポンサー
|
||||||
|
</a>{" "}
|
||||||
|
をご検討ください!
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 mt-2">
|
||||||
|
サポートやお問い合わせについては、{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
GitHubリポジトリ
|
||||||
|
</a>{" "}
|
||||||
|
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
エディタを開く
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<p className="text-center text-gray-600 text-sm">
|
||||||
|
Next AI Draw.io -
|
||||||
|
オープンソースAI搭載ダイアグラムジェネレーター
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+194
-491
@@ -1,420 +1,23 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import type { ReactNode } from "react"
|
import { FaGithub } from "react-icons/fa"
|
||||||
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"
|
|
||||||
|
|
||||||
type ExampleId = "resnet50" | "rag" | "auth" | "scrum" | "inno"
|
export const metadata: Metadata = {
|
||||||
|
title: "About - Next AI Draw.io",
|
||||||
type AboutTexts = {
|
description:
|
||||||
title: string
|
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||||
description: string
|
keywords: [
|
||||||
keywords: string[]
|
"AI diagram",
|
||||||
nav: { editor: string; about: string; github: string }
|
"draw.io",
|
||||||
tagline: string
|
"AWS architecture",
|
||||||
byok: { title: string; body: string; note: string }
|
"GCP diagram",
|
||||||
intro: string
|
"Azure diagram",
|
||||||
// Punctuation differs between Latin and CJK text
|
"LLM",
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const texts: Record<"en" | "zh" | "ja", AboutTexts> = {
|
export default function About() {
|
||||||
en: {
|
|
||||||
title: "About - Next AI Draw.io",
|
|
||||||
description:
|
|
||||||
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
|
||||||
keywords: [
|
|
||||||
"AI diagram",
|
|
||||||
"draw.io",
|
|
||||||
"AWS architecture",
|
|
||||||
"GCP diagram",
|
|
||||||
"Azure diagram",
|
|
||||||
"LLM",
|
|
||||||
],
|
|
||||||
nav: { editor: "Editor", about: "About", github: "View on GitHub" },
|
|
||||||
tagline: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize",
|
|
||||||
byok: {
|
|
||||||
title: "Bring Your Own API Key",
|
|
||||||
body: "You can also use your own API key with any supported provider. Click the Settings icon in the chat panel to configure your provider and API key.",
|
|
||||||
note: "Your key is stored locally in your browser and is never stored on the server.",
|
|
||||||
},
|
|
||||||
intro: "A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.",
|
|
||||||
colon: ": ",
|
|
||||||
parenOpen: " (",
|
|
||||||
parenClose: ")",
|
|
||||||
featuresHeading: "Features",
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
title: "LLM-Powered Diagram Creation",
|
|
||||||
text: "Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Image-Based Diagram Replication",
|
|
||||||
text: "Upload existing diagrams or images and have the AI replicate and enhance them automatically",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Versions and Undo",
|
|
||||||
text: "Every AI change becomes a version in the chat that you can compare, restore or undo; Ctrl+Z on the canvas takes it back too",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Interactive Chat Interface",
|
|
||||||
text: "Communicate with AI to refine your diagrams in real-time",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "AWS Architecture Diagram Support",
|
|
||||||
text: "Specialized support for generating AWS architecture diagrams",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Animated Connectors",
|
|
||||||
text: "Create dynamic and animated connectors between diagram elements for better visualization",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
examplesHeading: "Examples",
|
|
||||||
examplesIntro:
|
|
||||||
"Here are some example prompts and their generated diagrams:",
|
|
||||||
examples: {
|
|
||||||
resnet50: {
|
|
||||||
title: "Animated ResNet50 Model Architecture",
|
|
||||||
alt: "Architecture diagram for ResNet50 model",
|
|
||||||
},
|
|
||||||
rag: {
|
|
||||||
title: "RAG Technique Diagram",
|
|
||||||
alt: "RAG Architecture Diagram",
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
title: "Authentication using React and AWS",
|
|
||||||
alt: "Authentication Architecture Diagram",
|
|
||||||
},
|
|
||||||
scrum: { title: "Agile Scrum Process", alt: "Agile Scrum Diagram" },
|
|
||||||
inno: { title: "Open Innovation", alt: "Open Innovation Diagram" },
|
|
||||||
},
|
|
||||||
howHeading: "How It Works",
|
|
||||||
howIntro: "The application uses the following technologies:",
|
|
||||||
tech: {
|
|
||||||
nextjs: "For the frontend framework and routing",
|
|
||||||
aiSdk: "For streaming AI responses and multi-provider support",
|
|
||||||
drawio: "The editor, bundled with the app so it can be driven directly",
|
|
||||||
},
|
|
||||||
howOutro:
|
|
||||||
"Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.",
|
|
||||||
providersHeading: "Multi-Provider Support",
|
|
||||||
doubao: "ByteDance Doubao",
|
|
||||||
bedrock: "AWS Bedrock (default)",
|
|
||||||
openai: { before: "OpenAI / OpenAI-compatible APIs (via ", after: ")" },
|
|
||||||
modelNote: {
|
|
||||||
before: "Note that ",
|
|
||||||
after: " has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.",
|
|
||||||
},
|
|
||||||
supportHeading: "Support & Contact",
|
|
||||||
sponsor: {
|
|
||||||
before: "If you find this project useful, please consider",
|
|
||||||
link: "sponsoring",
|
|
||||||
after: "to help host the live demo site!",
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
before: "For support or inquiries, please open an issue on the",
|
|
||||||
link: "GitHub repository",
|
|
||||||
after: "or contact: me[at]jiang.jp",
|
|
||||||
},
|
|
||||||
openEditor: "Open Editor",
|
|
||||||
footer: "Next AI Draw.io - Open Source AI-Powered Diagram Generator",
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
title: "关于 - Next AI Draw.io",
|
|
||||||
description:
|
|
||||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
|
||||||
keywords: [
|
|
||||||
"AI图表",
|
|
||||||
"draw.io",
|
|
||||||
"AWS架构",
|
|
||||||
"GCP图表",
|
|
||||||
"Azure图表",
|
|
||||||
"LLM",
|
|
||||||
],
|
|
||||||
nav: { editor: "编辑器", about: "关于", github: "在GitHub上查看" },
|
|
||||||
tagline: "AI驱动的图表创建工具 - 对话、绘制、可视化",
|
|
||||||
byok: {
|
|
||||||
title: "使用自己的 API Key",
|
|
||||||
body: "您也可以使用自己的 API Key,支持多种服务商。点击聊天面板中的设置图标即可配置。",
|
|
||||||
note: "您的 Key 仅保存在浏览器本地,不会被存储在服务器上。",
|
|
||||||
},
|
|
||||||
intro: "一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。",
|
|
||||||
colon: ":",
|
|
||||||
parenOpen: "(",
|
|
||||||
parenClose: ")",
|
|
||||||
featuresHeading: "功能特性",
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
title: "LLM驱动的图表创建",
|
|
||||||
text: "利用大语言模型通过自然语言命令直接创建和操作draw.io图表",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "基于图像的图表复制",
|
|
||||||
text: "上传现有图表或图像,让AI自动复制和增强",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "版本与撤销",
|
|
||||||
text: "AI 的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按 Ctrl+Z 也能撤回",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "交互式聊天界面",
|
|
||||||
text: "与AI实时对话来完善您的图表",
|
|
||||||
},
|
|
||||||
{ title: "AWS架构图支持", text: "专门支持生成AWS架构图" },
|
|
||||||
{
|
|
||||||
title: "动画连接器",
|
|
||||||
text: "在图表元素之间创建动态动画连接器,实现更好的可视化效果",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
examplesHeading: "示例",
|
|
||||||
examplesIntro: "以下是一些示例提示词及其生成的图表:",
|
|
||||||
examples: {
|
|
||||||
resnet50: {
|
|
||||||
title: "ResNet50模型架构动画",
|
|
||||||
alt: "ResNet50模型架构图",
|
|
||||||
},
|
|
||||||
rag: { title: "RAG技术图", alt: "RAG架构图" },
|
|
||||||
auth: { title: "React和AWS认证流程", alt: "认证架构图" },
|
|
||||||
scrum: { title: "敏捷Scrum流程", alt: "敏捷Scrum流程图" },
|
|
||||||
inno: { title: "开放式创新", alt: "开放式创新图" },
|
|
||||||
},
|
|
||||||
howHeading: "工作原理",
|
|
||||||
howIntro: "本应用使用以下技术:",
|
|
||||||
tech: {
|
|
||||||
nextjs: "用于前端框架和路由",
|
|
||||||
aiSdk: "用于流式AI响应和多提供商支持",
|
|
||||||
drawio: "内置在应用里的编辑器,应用可以直接控制它",
|
|
||||||
},
|
|
||||||
howOutro:
|
|
||||||
"图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。",
|
|
||||||
providersHeading: "多提供商支持",
|
|
||||||
doubao: "字节跳动豆包",
|
|
||||||
bedrock: "AWS Bedrock(默认)",
|
|
||||||
openai: { before: "OpenAI / OpenAI兼容API(通过 ", after: ")" },
|
|
||||||
modelNote: {
|
|
||||||
before: "注意:",
|
|
||||||
after: " 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。",
|
|
||||||
},
|
|
||||||
supportHeading: "支持与联系",
|
|
||||||
sponsor: {
|
|
||||||
before: "如果您觉得这个项目有用,请考虑",
|
|
||||||
link: "赞助",
|
|
||||||
after: "来帮助托管在线演示站点!",
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
before: "如需支持或咨询,请在",
|
|
||||||
link: "GitHub仓库",
|
|
||||||
after: "上提交issue或联系:me[at]jiang.jp",
|
|
||||||
},
|
|
||||||
openEditor: "打开编辑器",
|
|
||||||
footer: "Next AI Draw.io - 开源AI驱动的图表生成器",
|
|
||||||
},
|
|
||||||
ja: {
|
|
||||||
title: "概要 - Next AI Draw.io",
|
|
||||||
description:
|
|
||||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
|
||||||
keywords: [
|
|
||||||
"AIダイアグラム",
|
|
||||||
"draw.io",
|
|
||||||
"AWSアーキテクチャ",
|
|
||||||
"GCPダイアグラム",
|
|
||||||
"Azureダイアグラム",
|
|
||||||
"LLM",
|
|
||||||
],
|
|
||||||
nav: { editor: "エディタ", about: "概要", github: "GitHubで見る" },
|
|
||||||
tagline: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化",
|
|
||||||
byok: {
|
|
||||||
title: "自分のAPIキーを使用",
|
|
||||||
body: "お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。",
|
|
||||||
note: "キーはブラウザのローカルに保存され、サーバーには保存されません。",
|
|
||||||
},
|
|
||||||
intro: "AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。",
|
|
||||||
colon: ":",
|
|
||||||
parenOpen: "(",
|
|
||||||
parenClose: ")",
|
|
||||||
featuresHeading: "機能",
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
title: "LLM搭載のダイアグラム作成",
|
|
||||||
text: "大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "画像ベースのダイアグラム複製",
|
|
||||||
text: "既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "バージョンと取り消し",
|
|
||||||
text: "AI の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで Ctrl+Z を押しても元に戻せます",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "インタラクティブなチャットインターフェース",
|
|
||||||
text: "AIとリアルタイムでコミュニケーションしてダイアグラムを改善",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "AWSアーキテクチャダイアグラムサポート",
|
|
||||||
text: "AWSアーキテクチャダイアグラムの生成を専門的にサポート",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "アニメーションコネクタ",
|
|
||||||
text: "より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
examplesHeading: "例",
|
|
||||||
examplesIntro:
|
|
||||||
"以下はいくつかのプロンプト例と生成されたダイアグラムです:",
|
|
||||||
examples: {
|
|
||||||
resnet50: {
|
|
||||||
title: "ResNet50モデルアーキテクチャアニメーション",
|
|
||||||
alt: "ResNet50モデルアーキテクチャ図",
|
|
||||||
},
|
|
||||||
rag: { title: "RAG技術ダイアグラム", alt: "RAGアーキテクチャ図" },
|
|
||||||
auth: {
|
|
||||||
title: "ReactとAWSによる認証",
|
|
||||||
alt: "認証アーキテクチャ図",
|
|
||||||
},
|
|
||||||
scrum: {
|
|
||||||
title: "アジャイルスクラムプロセス",
|
|
||||||
alt: "アジャイルスクラム図",
|
|
||||||
},
|
|
||||||
inno: {
|
|
||||||
title: "オープンイノベーション",
|
|
||||||
alt: "オープンイノベーション図",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
howHeading: "仕組み",
|
|
||||||
howIntro: "本アプリケーションは以下の技術を使用しています:",
|
|
||||||
tech: {
|
|
||||||
nextjs: "フロントエンドフレームワークとルーティング",
|
|
||||||
aiSdk: "ストリーミングAIレスポンスとマルチプロバイダーサポート",
|
|
||||||
drawio: "アプリに同梱されたエディター。アプリから直接操作できます",
|
|
||||||
},
|
|
||||||
howOutro:
|
|
||||||
"ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。",
|
|
||||||
providersHeading: "マルチプロバイダーサポート",
|
|
||||||
doubao: "ByteDance Doubao",
|
|
||||||
bedrock: "AWS Bedrock(デフォルト)",
|
|
||||||
openai: { before: "OpenAI / OpenAI互換API(", after: "経由)" },
|
|
||||||
modelNote: {
|
|
||||||
before: "注:",
|
|
||||||
after: "はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。",
|
|
||||||
},
|
|
||||||
supportHeading: "サポート&お問い合わせ",
|
|
||||||
sponsor: {
|
|
||||||
before: "このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために",
|
|
||||||
link: "スポンサー",
|
|
||||||
after: "をご検討ください!",
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
before: "サポートやお問い合わせについては、",
|
|
||||||
link: "GitHubリポジトリ",
|
|
||||||
after: "でissueを開くか、ご連絡ください:me[at]jiang.jp",
|
|
||||||
},
|
|
||||||
openEditor: "エディタを開く",
|
|
||||||
footer: "Next AI Draw.io - オープンソースAI搭載ダイアグラムジェネレーター",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// The example prompts stay in English in every language
|
|
||||||
const examples: { id: ExampleId; src: string; prompt: ReactNode }[] = [
|
|
||||||
{
|
|
||||||
id: "resnet50",
|
|
||||||
src: "/resnet50.svg",
|
|
||||||
prompt: (
|
|
||||||
<>
|
|
||||||
Give me an <strong>animated</strong> architecture diagram of the
|
|
||||||
ResNet50 model.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rag",
|
|
||||||
src: "/rag_prod.svg",
|
|
||||||
prompt: (
|
|
||||||
<>
|
|
||||||
Generate a RAG architecture diagram for{" "}
|
|
||||||
<strong>chat application</strong>. Use connected diagram for
|
|
||||||
data ingestion
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "auth",
|
|
||||||
src: "/auth.svg",
|
|
||||||
prompt: (
|
|
||||||
<>
|
|
||||||
Generate authentication process using React with{" "}
|
|
||||||
<strong>AWS</strong>. Use Serverless architecture.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "scrum",
|
|
||||||
src: "/agile_scrum.svg",
|
|
||||||
prompt: (
|
|
||||||
<>
|
|
||||||
Generate agile scrum workflow diagram for software development
|
|
||||||
team.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inno",
|
|
||||||
src: "/inno.svg",
|
|
||||||
prompt: (
|
|
||||||
<>
|
|
||||||
Create visualization of Henry Chesbrough's Open Innovation
|
|
||||||
model.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// Traditional Chinese has no About text of its own and shows the Simplified one
|
|
||||||
function textsFor(rawLang: string): AboutTexts {
|
|
||||||
const lang: Locale = hasLocale(rawLang) ? rawLang : "en"
|
|
||||||
return texts[lang === "zh-Hant" ? "zh" : lang]
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = { params: Promise<{ lang: string }> }
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
||||||
const { lang } = await params
|
|
||||||
const t = textsFor(lang)
|
|
||||||
return { title: t.title, description: t.description, keywords: t.keywords }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function About({ params }: Props) {
|
|
||||||
const { lang } = await params
|
|
||||||
const t = textsFor(lang)
|
|
||||||
const [hero, ...grid] = examples
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -422,32 +25,32 @@ export default async function About({ params }: Props) {
|
|||||||
<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={`/${lang}`}
|
href="/"
|
||||||
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={`/${lang}`}
|
href="/"
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
>
|
>
|
||||||
{t.nav.editor}
|
Editor
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/${lang}/about`}
|
href="/about"
|
||||||
className="text-blue-600 font-semibold"
|
className="text-blue-600 font-semibold"
|
||||||
>
|
>
|
||||||
{t.nav.about}
|
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={t.nav.github}
|
aria-label="View on GitHub"
|
||||||
>
|
>
|
||||||
<GitHubIcon className="w-5 h-5" />
|
<FaGithub className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,7 +66,8 @@ export default async function About({ params }: Props) {
|
|||||||
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">
|
||||||
{t.tagline}
|
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||||
|
Visualize
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -473,53 +77,92 @@ export default async function About({ params }: Props) {
|
|||||||
{/* 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">
|
||||||
{t.byok.title}
|
Bring Your Own API Key
|
||||||
</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">
|
||||||
{t.byok.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.
|
||||||
</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">
|
||||||
{t.byok.note}
|
Your key is stored locally in your browser
|
||||||
|
and is never stored on the server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-700">{t.intro}</p>
|
<p className="text-gray-700">
|
||||||
|
A Next.js web application that integrates AI
|
||||||
|
capabilities with draw.io diagrams. Create, modify, and
|
||||||
|
enhance diagrams through natural language commands and
|
||||||
|
AI-assisted visualization.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* 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">
|
||||||
{t.featuresHeading}
|
Features
|
||||||
</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">
|
||||||
{t.features.map((feature) => (
|
<li>
|
||||||
<li key={feature.title}>
|
<strong>LLM-Powered Diagram Creation</strong>:
|
||||||
<strong>{feature.title}</strong>
|
Leverage Large Language Models to create and
|
||||||
{t.colon}
|
manipulate draw.io diagrams directly through natural
|
||||||
{feature.text}
|
language commands
|
||||||
</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">
|
||||||
{t.examplesHeading}
|
Examples
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-6">{t.examplesIntro}</p>
|
<p className="text-gray-700 mb-6">
|
||||||
|
Here are some example prompts and their generated
|
||||||
|
diagrams:
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* First example on its own */}
|
{/* ResNet50 Architecture */}
|
||||||
<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">
|
||||||
{t.examples[hero.id].title}
|
Animated ResNet50 Model Architecture
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> {hero.prompt}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
|
<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={hero.src}
|
src="/resnet50.svg"
|
||||||
alt={t.examples[hero.id].alt}
|
alt="Architecture diagram for ResNet50 model"
|
||||||
width={480}
|
width={480}
|
||||||
height={360}
|
height={360}
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
@@ -529,67 +172,125 @@ export default async function About({ params }: Props) {
|
|||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
{grid.map((example) => (
|
<div className="text-center">
|
||||||
<div key={example.id} 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">
|
RAG Technique Diagram
|
||||||
{t.examples[example.id].title}
|
</h3>
|
||||||
</h3>
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
<strong>Prompt:</strong>{" "}
|
architecture diagram for{" "}
|
||||||
{example.prompt}
|
<strong>chat application</strong>. Use
|
||||||
</p>
|
connected diagram for data ingestion
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src={example.src}
|
<Image
|
||||||
alt={t.examples[example.id].alt}
|
src="/rag_prod.svg"
|
||||||
width={480}
|
alt="RAG Architecture Diagram"
|
||||||
height={360}
|
width={480}
|
||||||
className="max-w-full max-h-full object-contain"
|
height={360}
|
||||||
/>
|
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'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">
|
||||||
{t.howHeading}
|
How It Works
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-4">{t.howIntro}</p>
|
<p className="text-gray-700 mb-4">
|
||||||
|
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>
|
<strong>Next.js</strong>: For the frontend framework
|
||||||
{t.colon}
|
and routing
|
||||||
{t.tech.nextjs}
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Vercel AI SDK</strong>
|
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
|
||||||
{t.parenOpen}
|
<code>@ai-sdk/*</code>): For streaming AI responses
|
||||||
<code>ai</code> + <code>@ai-sdk/*</code>
|
and multi-provider support
|
||||||
{t.parenClose}
|
|
||||||
{t.colon}
|
|
||||||
{t.tech.aiSdk}
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>draw.io</strong>
|
<strong>draw.io</strong>: The editor, bundled with
|
||||||
{t.colon}
|
the app so it can be driven directly
|
||||||
{t.tech.drawio}
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">{t.howOutro}</p>
|
<p className="text-gray-700 mt-4">
|
||||||
|
Diagrams are represented as XML that can be rendered in
|
||||||
|
draw.io. The AI processes your commands and generates or
|
||||||
|
modifies this XML accordingly.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* 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">
|
||||||
{t.providersHeading}
|
Multi-Provider Support
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>{t.doubao}</li>
|
<li>ByteDance Doubao</li>
|
||||||
<li>{t.bedrock}</li>
|
<li>AWS Bedrock (default)</li>
|
||||||
<li>
|
<li>
|
||||||
{t.openai.before}
|
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||||
<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>
|
||||||
@@ -602,47 +303,48 @@ export default async function About({ params }: Props) {
|
|||||||
<li>ModelScope</li>
|
<li>ModelScope</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
{t.modelNote.before}
|
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||||
<code>claude-sonnet-4-5</code>
|
draw.io diagrams with AWS logos, so if you want to
|
||||||
{t.modelNote.after}
|
create AWS architecture diagrams, this is the best
|
||||||
|
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">
|
||||||
{t.supportHeading}
|
Support & Contact
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
{t.sponsor.before}{" "}
|
If you find this project useful, please consider{" "}
|
||||||
<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"
|
||||||
>
|
>
|
||||||
{t.sponsor.link}
|
sponsoring
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
{t.sponsor.after}
|
to help host the live demo site!
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mt-2">
|
<p className="text-gray-700 mt-2">
|
||||||
{t.contact.before}{" "}
|
For support or inquiries, please open an issue on the{" "}
|
||||||
<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"
|
||||||
>
|
>
|
||||||
{t.contact.link}
|
GitHub repository
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
{t.contact.after}
|
or contact: me[at]jiang.jp
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-center">
|
||||||
<Link
|
<Link
|
||||||
href={`/${lang}`}
|
href="/"
|
||||||
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"
|
||||||
>
|
>
|
||||||
{t.openEditor}
|
Open Editor
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -652,7 +354,8 @@ export default async function About({ params }: Props) {
|
|||||||
<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">
|
||||||
{t.footer}
|
Next AI Draw.io - Open Source AI-Powered Diagram
|
||||||
|
Generator
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
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"
|
||||||
|
|
||||||
@@ -7,10 +5,7 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
|||||||
|
|
||||||
// ── Shared types ─────────────────────────────────────────────────────
|
// ── Shared types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// The same shapes the admin API validates and returns. Secret fields of a
|
export type SecretValue = { isSet: true; hint: string }
|
||||||
// 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
|
||||||
@@ -29,6 +24,22 @@ 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
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ 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"
|
||||||
@@ -199,6 +206,26 @@ 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">
|
||||||
|
|||||||
+16
-13
@@ -41,13 +41,6 @@ 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,
|
||||||
@@ -55,19 +48,22 @@ 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: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
|
const lang = (
|
||||||
|
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 驱动的图表生成器",
|
zh: "Next AI Draw.io - AI powered diagram generator",
|
||||||
ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
|
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||||
"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: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
|
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
||||||
ja: "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.",
|
||||||
"zh-Hant":
|
"zh-Hant":
|
||||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||||
}
|
}
|
||||||
@@ -96,7 +92,14 @@ 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: OG_LOCALE[lang],
|
locale:
|
||||||
|
lang === "zh"
|
||||||
|
? "zh_CN"
|
||||||
|
: lang === "zh-Hant"
|
||||||
|
? "zh_HK"
|
||||||
|
: lang === "ja"
|
||||||
|
? "ja_JP"
|
||||||
|
: "en_US",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/architecture.png",
|
url: "/architecture.png",
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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,
|
||||||
@@ -5,7 +6,6 @@ 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,24 +48,37 @@ 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 testModelConnection(
|
return validateModel(
|
||||||
req,
|
new Request(new URL("/api/validate-model", req.url), {
|
||||||
{
|
method: "POST",
|
||||||
provider: resolved.provider,
|
headers: {
|
||||||
apiKey: resolved.apiKey,
|
"Content-Type": "application/json",
|
||||||
baseUrl: resolved.baseUrl || serverUrl,
|
// Checked again there, in place of an access code
|
||||||
modelId: body.modelId,
|
"x-admin-password": req.headers.get("x-admin-password") || "",
|
||||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
// The EdgeOne function checks the access code and Pages
|
||||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
// cookies, and its URL is built from the page's origin
|
||||||
awsRegion: resolved.awsRegion,
|
"x-access-code": req.headers.get("x-access-code") || "",
|
||||||
vertexApiKey: resolved.vertexApiKey,
|
cookie: req.headers.get("cookie") || "",
|
||||||
},
|
...(req.headers.get("origin") && {
|
||||||
!resolved.baseUrl && !!serverUrl,
|
origin: req.headers.get("origin") as string,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: resolved.provider,
|
||||||
|
apiKey: resolved.apiKey,
|
||||||
|
// Without a URL of its own, chat sends the entry's key to
|
||||||
|
// the server's <P>_BASE_URL: test that endpoint, not
|
||||||
|
// another one. It is the server's own, which chat uses
|
||||||
|
// without the checks for a URL a user typed.
|
||||||
|
baseUrl: resolved.baseUrl || serverUrl,
|
||||||
|
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
||||||
|
modelId: body.modelId,
|
||||||
|
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||||
|
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||||
|
awsRegion: resolved.awsRegion,
|
||||||
|
vertexApiKey: resolved.vertexApiKey,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+318
-76
@@ -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,11 +56,50 @@ 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
|
||||||
@@ -72,21 +111,6 @@ 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
|
||||||
@@ -95,19 +119,7 @@ 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()
|
||||||
// The messages in the SDK's UI shape. The route writes its own system
|
const { messages, xml, previousXml, sessionId } = body
|
||||||
// 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)
|
||||||
@@ -126,8 +138,9 @@ 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) => m.role === "user")
|
.find((m: any) => m.role === "user")
|
||||||
const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || ""
|
const userInputText =
|
||||||
|
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({
|
||||||
@@ -143,6 +156,23 @@ 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")
|
||||||
@@ -318,14 +348,16 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
|
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// System prompt, with the model name filled in and the style section chosen
|
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||||
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 = lastUserMessage?.parts.filter(isFileUIPart) ?? []
|
const fileParts =
|
||||||
|
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
|
||||||
@@ -338,37 +370,98 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
${userInputText}
|
${userInputText}
|
||||||
"""`
|
"""`
|
||||||
|
|
||||||
// Convert UIMessages to ModelMessages. A tool call that never got its
|
// Convert UIMessages to ModelMessages and add system message. A tool
|
||||||
// result (the user stopped while it ran) is left out: the SDK would
|
// call that never got its result (the user stopped while it ran) is
|
||||||
// refuse this and every later request of the chat
|
// left out: the SDK would refuse this and every later request of the
|
||||||
// (MissingToolResultsError). Messages left without content (a notice
|
// chat (MissingToolResultsError)
|
||||||
// the chat shows is a data part, which the model never sees) are
|
const modelMessages = await convertToModelMessages(messages, {
|
||||||
// dropped: Bedrock rejects empty content.
|
ignoreIncompleteToolCalls: true,
|
||||||
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(
|
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||||
"[route.ts] incoming",
|
messages.forEach((msg: any, idx: number) => {
|
||||||
JSON.stringify(describeMessages(messages)),
|
console.log(
|
||||||
)
|
`[route.ts] Message ${idx} role:`,
|
||||||
|
msg.role,
|
||||||
|
"parts count:",
|
||||||
|
msg.parts?.length,
|
||||||
|
)
|
||||||
|
if (msg.parts) {
|
||||||
|
msg.parts.forEach((part: any, partIdx: number) => {
|
||||||
|
if (
|
||||||
|
part.type === "tool-invocation" ||
|
||||||
|
part.type === "tool-result"
|
||||||
|
) {
|
||||||
|
console.log(`[route.ts] Part ${partIdx}:`, {
|
||||||
|
type: part.type,
|
||||||
|
toolName: part.toolName,
|
||||||
|
hasInput: !!part.input,
|
||||||
|
inputType: typeof part.input,
|
||||||
|
inputKeys:
|
||||||
|
part.input && typeof part.input === "object"
|
||||||
|
? Object.keys(part.input)
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
let enhancedMessages = dropInvalidToolCalls(modelMessages)
|
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
||||||
|
|
||||||
// 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(
|
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
||||||
"[route.ts] to model",
|
enhancedMessages.forEach((msg: any, idx: number) => {
|
||||||
JSON.stringify(describeMessages(enhancedMessages)),
|
console.log(
|
||||||
)
|
`[route.ts] ModelMsg ${idx} role:`,
|
||||||
|
msg.role,
|
||||||
|
"content count:",
|
||||||
|
msg.content?.length,
|
||||||
|
)
|
||||||
|
if (msg.content) {
|
||||||
|
msg.content.forEach((part: any, partIdx: number) => {
|
||||||
|
if (
|
||||||
|
part.type === "tool-call" ||
|
||||||
|
part.type === "tool-result"
|
||||||
|
) {
|
||||||
|
console.log(`[route.ts] Content ${partIdx}:`, {
|
||||||
|
type: part.type,
|
||||||
|
toolName: part.toolName,
|
||||||
|
hasInput: !!part.input,
|
||||||
|
inputType: typeof part.input,
|
||||||
|
inputValue:
|
||||||
|
part.input === undefined
|
||||||
|
? "undefined"
|
||||||
|
: part.input === null
|
||||||
|
? "null"
|
||||||
|
: "object",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -473,10 +566,13 @@ 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.
|
||||||
// (the client's own system messages were dropped above)
|
// A client's own system messages have string content and were
|
||||||
|
// 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
|
||||||
@@ -523,31 +619,166 @@ 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 (undefined when not configured)
|
// Langfuse telemetry config (returns undefined if not configured)
|
||||||
experimental_telemetry: getTelemetryConfig({
|
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||||
sessionId: validSessionId,
|
experimental_telemetry: getTelemetryConfig({
|
||||||
userId,
|
sessionId: validSessionId,
|
||||||
|
userId,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// The quota counts each step as it ends: a later step that fails or
|
onFinish: ({ text, totalUsage }) => {
|
||||||
// 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: () => endTrace(),
|
onAbort: ({ steps }) => {
|
||||||
tools: chatTools,
|
stopped = true
|
||||||
|
endTrace()
|
||||||
|
// Stopped (or disconnected) after some steps finished: their
|
||||||
|
// tokens were used, or stopping every request after a costly
|
||||||
|
// first step would get around the token limits
|
||||||
|
if (countsQuota) {
|
||||||
|
const tokens = steps.reduce(
|
||||||
|
(sum, step) =>
|
||||||
|
sum +
|
||||||
|
(step.usage.inputTokens || 0) +
|
||||||
|
(step.usage.outputTokens || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (tokens > 0) recordTokenUsage(userId, tokens)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
// Client-side tool that will be executed on the client
|
||||||
|
display_diagram: {
|
||||||
|
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
|
||||||
|
|
||||||
|
VALIDATION RULES (XML will be rejected if violated):
|
||||||
|
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||||
|
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||||
|
3. All mxCell elements must be siblings - never nested
|
||||||
|
4. Every mxCell needs a unique id (start from "2")
|
||||||
|
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
|
||||||
|
6. Escape special chars in values: < > & "
|
||||||
|
|
||||||
|
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
||||||
|
|
||||||
|
Example (generate ONLY this - no wrapper tags):
|
||||||
|
${SWIMLANE_EXAMPLE}
|
||||||
|
|
||||||
|
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use <br> for a line break, never \\n; a literal < or > is written &lt; or &gt;.
|
||||||
|
${STYLE_CLASS_EXAMPLE}
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- For AWS diagrams, use **AWS 2025 icons**.
|
||||||
|
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||||
|
`,
|
||||||
|
inputSchema: z.object({
|
||||||
|
xml: z
|
||||||
|
.string()
|
||||||
|
.describe("XML string to be displayed on draw.io"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
edit_diagram: {
|
||||||
|
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
||||||
|
|
||||||
|
Operations:
|
||||||
|
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
||||||
|
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||||
|
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||||
|
|
||||||
|
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
|
||||||
|
|
||||||
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||||
|
|
||||||
|
Example - Add a rectangle:
|
||||||
|
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
||||||
|
|
||||||
|
Example - Delete container (children & edges auto-deleted):
|
||||||
|
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
||||||
|
inputSchema: z.object({
|
||||||
|
operations: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
operation: z
|
||||||
|
.enum(["update", "add", "delete"])
|
||||||
|
.describe(
|
||||||
|
"Operation to perform: add, update, or delete",
|
||||||
|
),
|
||||||
|
cell_id: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"The id of the mxCell. Must match the id attribute in new_xml.",
|
||||||
|
),
|
||||||
|
new_xml: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Complete mxCell XML element (required for update/add)",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe("Array of operations to apply"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
append_diagram: {
|
||||||
|
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
||||||
|
|
||||||
|
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
||||||
|
|
||||||
|
CRITICAL INSTRUCTIONS:
|
||||||
|
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||||
|
2. Continue from EXACTLY where your previous output stopped
|
||||||
|
3. Complete the remaining mxCell elements
|
||||||
|
4. If still truncated, call append_diagram again with the next fragment
|
||||||
|
|
||||||
|
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
|
||||||
|
inputSchema: z.object({
|
||||||
|
xml: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Continuation XML fragment to append (NO wrapper tags)",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
get_shape_library: {
|
||||||
|
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||||
|
|
||||||
|
Available libraries:
|
||||||
|
${SHAPE_LIBRARY_LIST}
|
||||||
|
|
||||||
|
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||||
|
inputSchema: z.object({
|
||||||
|
library: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ library }) => {
|
||||||
|
// Only known library names reach the file system
|
||||||
|
const result = await getShapeLibrary(
|
||||||
|
library,
|
||||||
|
path.join(process.cwd(), "docs/shape-libraries"),
|
||||||
|
)
|
||||||
|
return result.ok ? result.text : result.error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
...(process.env.TEMPERATURE !== undefined && {
|
...(process.env.TEMPERATURE !== undefined && {
|
||||||
temperature: parseFloat(process.env.TEMPERATURE),
|
temperature: parseFloat(process.env.TEMPERATURE),
|
||||||
}),
|
}),
|
||||||
@@ -559,6 +790,17 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
// 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)
|
||||||
@@ -598,7 +840,7 @@ async function safeHandler(req: Request): Promise<Response> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
response = handleError(error)
|
response = handleError(error)
|
||||||
}
|
}
|
||||||
// Early returns and errors never reach streamText's callbacks,
|
// Early returns, cache hits 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
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# 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,5 +3,8 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { randomUUID } from "crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
import { getLangfuseClient } from "@/lib/langfuse"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
const feedbackSchema = z.object({
|
const feedbackSchema = z.object({
|
||||||
@@ -36,24 +37,75 @@ export async function POST(req: Request) {
|
|||||||
const userId = getUserIdFromRequest(req)
|
const userId = getUserIdFromRequest(req)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Score the session's latest chat trace; without one, a standalone
|
// Find the most recent chat trace for this session to attach the score to
|
||||||
// feedback trace carries the score
|
const tracesResponse = await langfuse.api.trace.list({
|
||||||
await scoreLatestTrace(langfuse, {
|
|
||||||
sessionId,
|
sessionId,
|
||||||
name: "user-feedback",
|
limit: 1,
|
||||||
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)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { randomUUID } from "crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
import { getLangfuseClient } 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),
|
||||||
@@ -32,16 +33,39 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Flag the session's latest chat trace as saved; a session without a
|
const timestamp = new Date().toISOString()
|
||||||
// trace (no chat yet) is skipped
|
|
||||||
const logged = await scoreLatestTrace(langfuse, {
|
// Find the most recent chat trace for this session to attach the save flag
|
||||||
|
const tracesResponse = await langfuse.api.trace.list({
|
||||||
sessionId,
|
sessionId,
|
||||||
name: "diagram-saved",
|
limit: 1,
|
||||||
value: 1,
|
|
||||||
comment: `User saved diagram as ${filename}.${format}`,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response.json({ success: true, logged })
|
const traces = tracesResponse.data || []
|
||||||
|
const latestTrace = traces[0]
|
||||||
|
|
||||||
|
if (latestTrace) {
|
||||||
|
// Add a score to the existing trace to flag that user saved
|
||||||
|
await langfuse.api.ingestion.batch({
|
||||||
|
batch: [
|
||||||
|
{
|
||||||
|
type: "score-create",
|
||||||
|
id: randomUUID(),
|
||||||
|
timestamp,
|
||||||
|
body: {
|
||||||
|
id: randomUUID(),
|
||||||
|
traceId: latestTrace.id,
|
||||||
|
name: "diagram-saved",
|
||||||
|
value: 1,
|
||||||
|
comment: `User saved diagram as ${filename}.${format}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// If no trace found, skip logging (user hasn't chatted yet)
|
||||||
|
|
||||||
|
return Response.json({ success: true, logged: !!latestTrace })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Langfuse save error:", error)
|
console.error("Langfuse save error:", error)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const models = await loadFlattenedServerModels()
|
const models = await loadFlattenedServerModels()
|
||||||
return NextResponse.json({ models })
|
return NextResponse.json({
|
||||||
|
models,
|
||||||
|
hasConfig: models.length > 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* API endpoint for VLM-based diagram validation.
|
* API endpoint for VLM-based diagram validation.
|
||||||
* Accepts a PNG image and answers with the validation result as JSON.
|
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { generateText, Output } from "ai"
|
import { Output, streamText } 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,7 +11,6 @@ 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 {
|
||||||
@@ -36,6 +35,13 @@ 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
|
||||||
@@ -47,7 +53,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 Response.json(DEFAULT_VALID_RESULT)
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: ValidateDiagramRequest = await req.json()
|
const body: ValidateDiagramRequest = await req.json()
|
||||||
@@ -117,7 +123,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 Response.json(DEFAULT_VALID_RESULT)
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||||
@@ -127,7 +133,8 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||||
) || 10000
|
) || 10000
|
||||||
|
|
||||||
const { output, totalUsage } = await generateText({
|
// Stream the VLM response for useObject consumption
|
||||||
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
output: Output.object({ schema: ValidationResultSchema }),
|
output: Output.object({ schema: ValidationResultSchema }),
|
||||||
system: VALIDATION_SYSTEM_PROMPT,
|
system: VALIDATION_SYSTEM_PROMPT,
|
||||||
@@ -147,25 +154,24 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
maxOutputTokens: 1024,
|
maxOutputTokens: 1024,
|
||||||
// Ends with the request: the user pressed Stop, or left
|
abortSignal: AbortSignal.timeout(timeout),
|
||||||
abortSignal: req.signal,
|
onFinish: ({ output, totalUsage }) => {
|
||||||
timeout,
|
if (countsQuota && totalUsage) {
|
||||||
// Its usage and cost show in Langfuse next to the chat's
|
recordTokenUsage(
|
||||||
experimental_telemetry: getTelemetryConfig({
|
userId,
|
||||||
functionId: "validate-diagram",
|
(totalUsage.inputTokens || 0) +
|
||||||
sessionId,
|
(totalUsage.outputTokens || 0),
|
||||||
userId,
|
)
|
||||||
}),
|
}
|
||||||
|
if (sessionId && output) {
|
||||||
|
console.log(
|
||||||
|
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (countsQuota) {
|
|
||||||
await recordTokenUsage(userId, totalUsage.totalTokens ?? 0)
|
return result.toTextStreamResponse()
|
||||||
}
|
|
||||||
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 =
|
||||||
@@ -173,6 +179,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 Response.json(DEFAULT_VALID_RESULT)
|
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+233
-13
@@ -1,29 +1,249 @@
|
|||||||
|
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 {
|
||||||
type TestModelRequest,
|
edgeOneEndpoint,
|
||||||
testModelConnection,
|
getAIModel,
|
||||||
} from "@/lib/test-model-connection"
|
globalBaseUrl,
|
||||||
|
usesServerCredentials,
|
||||||
|
usesServerEndpoint,
|
||||||
|
} from "@/lib/ai-providers"
|
||||||
|
import {
|
||||||
|
checkAndIncrementRequest,
|
||||||
|
isQuotaEnabled,
|
||||||
|
} from "@/lib/dynamo-quota-manager"
|
||||||
|
import { classifyLLMError } from "@/lib/llm-errors"
|
||||||
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
// The Settings dialog's Test button: a model on the user's own credentials
|
interface ValidateRequest {
|
||||||
|
provider: ProviderName
|
||||||
|
apiKey: string
|
||||||
|
baseUrl?: string
|
||||||
|
modelId: string
|
||||||
|
// AWS Bedrock specific
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
awsSessionToken?: string
|
||||||
|
// Vertex AI specific
|
||||||
|
vertexApiKey?: string // Express Mode API key
|
||||||
|
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
||||||
|
serverBaseUrl?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
// Drawing works through tool calls, so the test asks for one
|
||||||
|
const PING_TOOL = tool({
|
||||||
|
description: "Report that the connection works.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const NO_TOOL_CALL_WARNING =
|
||||||
|
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
||||||
|
|
||||||
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 code
|
// Lets the server send requests to arbitrary URLs, so require the access
|
||||||
|
// code, or the admin password (the admin panel's Test button)
|
||||||
const accessError = checkAccessCode(req)
|
const accessError = checkAccessCode(req)
|
||||||
if (accessError) return accessError
|
if (accessError && checkAdminAuth(req)) return accessError
|
||||||
|
|
||||||
let body: TestModelRequest
|
|
||||||
try {
|
try {
|
||||||
body = await req.json()
|
const body: ValidateRequest = await req.json()
|
||||||
} catch {
|
const {
|
||||||
|
provider,
|
||||||
|
apiKey,
|
||||||
|
modelId,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
awsRegion,
|
||||||
|
awsSessionToken,
|
||||||
|
// Note: Express Mode only needs vertexApiKey
|
||||||
|
vertexApiKey,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!provider || !modelId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "Provider and model ID are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// EdgeOne is this site's own function, as in the chat; the admin
|
||||||
|
// panel's Test sends no URL, and a relative one cannot be fetched
|
||||||
|
const baseUrl =
|
||||||
|
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
||||||
|
// The admin panel's Test of an entry without a URL sends the
|
||||||
|
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
||||||
|
// user chose, so no private-address or redirect rules
|
||||||
|
const serverUrl =
|
||||||
|
body.serverBaseUrl === true &&
|
||||||
|
!!baseUrl &&
|
||||||
|
baseUrl === globalBaseUrl(provider) &&
|
||||||
|
!checkAdminAuth(req)
|
||||||
|
|
||||||
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
|
if (
|
||||||
|
baseUrl &&
|
||||||
|
!serverUrl &&
|
||||||
|
!allowPrivateUrls() &&
|
||||||
|
(await isPrivateUrl(baseUrl))
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "Invalid base URL" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate credentials based on provider
|
||||||
|
if (provider === "bedrock") {
|
||||||
|
if (
|
||||||
|
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
|
||||||
|
!awsRegion
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (provider === "vertexai") {
|
||||||
|
if (!vertexApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: "Vertex AI API key is required for Express Mode",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "API key is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The Test button checks the user's own provider. On the server's
|
||||||
|
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||||
|
if (
|
||||||
|
usesServerCredentials(provider, {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
vertexApiKey,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "API key is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the deployment's own endpoints a Test runs a model as a chat
|
||||||
|
// does, so with the quota on it counts as a chat request (an
|
||||||
|
// admin's Test of the server's URL does not)
|
||||||
|
const userId = getUserIdFromRequest(req)
|
||||||
|
if (
|
||||||
|
isQuotaEnabled() &&
|
||||||
|
!serverUrl &&
|
||||||
|
userId !== "anonymous" &&
|
||||||
|
(await usesServerEndpoint(
|
||||||
|
provider,
|
||||||
|
normalizeBaseUrl(body.baseUrl ?? ""),
|
||||||
|
apiKey,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||||
|
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||||
|
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||||
|
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||||
|
})
|
||||||
|
if (!quotaCheck.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: quotaCheck.error },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same model the chat would use. A client base URL makes it
|
||||||
|
// refuse redirects to internal hosts.
|
||||||
|
const { model } = getAIModel({
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
trustedBaseUrl: serverUrl,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
awsRegion,
|
||||||
|
// Temporary AWS credentials need it, as in the chat
|
||||||
|
awsSessionToken,
|
||||||
|
vertexApiKey,
|
||||||
|
// EdgeOne checks the Pages cookies and the access code
|
||||||
|
...(provider === "edgeone" && {
|
||||||
|
headers: {
|
||||||
|
cookie: req.headers.get("cookie") || "",
|
||||||
|
"x-access-code": req.headers.get("x-access-code") || "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Streaming, like the chat (some models only stream). Stop at the
|
||||||
|
// first tool call; a reasoning model that runs out of tokens first
|
||||||
|
// proves the connection but not tool support.
|
||||||
|
const startTime = Date.now()
|
||||||
|
const result = streamText({
|
||||||
|
model,
|
||||||
|
prompt: "Call the ping tool.",
|
||||||
|
tools: { ping: PING_TOOL },
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
maxRetries: 0,
|
||||||
|
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
let calledTool = false
|
||||||
|
let finishReason: string | undefined
|
||||||
|
for await (const part of result.fullStream) {
|
||||||
|
if (part.type === "error") throw part.error
|
||||||
|
// The timeout ends the stream with an abort part, not an error
|
||||||
|
if (part.type === "abort") {
|
||||||
|
const timeout = new Error(
|
||||||
|
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
||||||
|
)
|
||||||
|
timeout.name = "TimeoutError"
|
||||||
|
throw timeout
|
||||||
|
}
|
||||||
|
if (part.type === "tool-call") {
|
||||||
|
calledTool = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (part.type === "finish") finishReason = part.finishReason
|
||||||
|
}
|
||||||
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
valid: true,
|
||||||
|
responseTime,
|
||||||
|
...(!calledTool &&
|
||||||
|
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[validate-model] Error:", error)
|
||||||
|
|
||||||
|
const { code, message } = classifyLLMError(error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Invalid JSON body" },
|
{ valid: false, code, error: message },
|
||||||
{ status: 400 },
|
{ status: 200 }, // Return 200 so client can read error message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A URL in the request is one the user typed, never the server's own
|
|
||||||
return testModelConnection(req, body, false)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-9
@@ -98,7 +98,9 @@
|
|||||||
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -239,6 +241,13 @@
|
|||||||
.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 {
|
||||||
@@ -275,13 +284,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A highlight sweeps across "Thinking..." text (Shimmer) */
|
@keyframes check-pop {
|
||||||
@keyframes shimmer {
|
0% {
|
||||||
from {
|
transform: scale(0.8);
|
||||||
background-position: 100% center;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
50% {
|
||||||
background-position: 0% center;
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,15 +307,15 @@
|
|||||||
.animate-panel-in {
|
.animate-panel-in {
|
||||||
animation: panelIn 0.32s var(--ease-out) both;
|
animation: panelIn 0.32s var(--ease-out) both;
|
||||||
}
|
}
|
||||||
.animate-shimmer {
|
.animate-check-pop {
|
||||||
animation: shimmer var(--shimmer-duration, 2s) linear infinite;
|
animation: check-pop 0.25s var(--ease-spring) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@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-shimmer {
|
.animate-check-pop {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { Cloud } from "lucide-react"
|
||||||
|
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
CommandShortcut,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
||||||
|
|
||||||
|
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||||
|
<Dialog {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
||||||
|
|
||||||
|
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||||
|
<DialogTrigger {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||||
|
title?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorContent = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
title = "Model Selector",
|
||||||
|
...props
|
||||||
|
}: ModelSelectorContentProps) => (
|
||||||
|
<DialogContent className={cn("p-0", className)} {...props}>
|
||||||
|
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||||
|
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
||||||
|
|
||||||
|
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||||
|
<CommandDialog {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
||||||
|
|
||||||
|
export const ModelSelectorInput = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorInputProps) => (
|
||||||
|
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||||
|
|
||||||
|
export const ModelSelectorList = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorListProps) => {
|
||||||
|
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||||
|
const [showShadow, setShowShadow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listElement = listRef.current
|
||||||
|
if (!listElement) return
|
||||||
|
|
||||||
|
const checkScroll = () => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||||
|
// Show shadow if there is more content below
|
||||||
|
// Using a small threshold to handle fractional pixel rendering
|
||||||
|
setShowShadow(
|
||||||
|
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkScroll()
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
listElement.addEventListener("scroll", checkScroll)
|
||||||
|
window.addEventListener("resize", checkScroll)
|
||||||
|
|
||||||
|
// Observe content changes (e.g. async loading of items)
|
||||||
|
const observer = new MutationObserver(checkScroll)
|
||||||
|
observer.observe(listElement, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listElement.removeEventListener("scroll", checkScroll)
|
||||||
|
window.removeEventListener("resize", checkScroll)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<CommandList
|
||||||
|
ref={listRef}
|
||||||
|
className={cn(
|
||||||
|
// Hide scrollbar on all platforms
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{/* Bottom shadow indicator for scrollable content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||||
|
showShadow ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|
||||||
|
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||||
|
<CommandEmpty {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
||||||
|
|
||||||
|
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||||
|
<CommandGroup {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
||||||
|
|
||||||
|
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||||
|
<CommandItem {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
||||||
|
|
||||||
|
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||||
|
<CommandShortcut {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||||
|
typeof CommandSeparator
|
||||||
|
>
|
||||||
|
|
||||||
|
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||||
|
<CommandSeparator {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorLogoProps = Omit<
|
||||||
|
ComponentProps<"img">,
|
||||||
|
"src" | "alt"
|
||||||
|
> & {
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorLogo = ({
|
||||||
|
provider,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorLogoProps) => {
|
||||||
|
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||||
|
if (provider === "amazon-bedrock") {
|
||||||
|
return <Cloud className={cn("size-4", className)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
alt={`${provider} logo`}
|
||||||
|
className={cn("size-4 dark:invert", className)}
|
||||||
|
height={16}
|
||||||
|
src={`https://models.dev/logos/${provider}.svg`}
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
||||||
|
|
||||||
|
export const ModelSelectorLogoGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorLogoGroupProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorNameProps = ComponentProps<"span">
|
||||||
|
|
||||||
|
export const ModelSelectorName = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorNameProps) => (
|
||||||
|
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorSectionHeaderProps = {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorSectionHeader = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
}: ModelSelectorSectionHeaderProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -1,83 +1,186 @@
|
|||||||
"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 { ReactNode } from "react"
|
import type { ComponentProps, ReactNode } from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { createContext, memo, useContext, 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"
|
||||||
|
|
||||||
export type ReasoningProps = {
|
type ReasoningContextValue = {
|
||||||
className?: string
|
isStreaming: boolean
|
||||||
|
isOpen: boolean
|
||||||
|
setIsOpen: (open: boolean) => void
|
||||||
|
duration: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||||
|
|
||||||
|
export const useReasoning = () => {
|
||||||
|
const context = useContext(ReasoningContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Reasoning components must be used within Reasoning")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
/** Open while streaming, then folds once about a second after it ends */
|
open?: boolean
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
|
onOpenChange?: (open: boolean) => void
|
||||||
children: string
|
duration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_CLOSE_DELAY = 1000
|
const AUTO_CLOSE_DELAY = 1000
|
||||||
const MS_IN_S = 1000
|
const MS_IN_S = 1000
|
||||||
|
|
||||||
export function Reasoning({
|
export const Reasoning = memo(
|
||||||
className,
|
({
|
||||||
isStreaming = false,
|
className,
|
||||||
defaultOpen = true,
|
isStreaming = false,
|
||||||
getThinkingMessage,
|
open,
|
||||||
children,
|
defaultOpen = true,
|
||||||
}: ReasoningProps) {
|
onOpenChange,
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
duration: durationProp,
|
||||||
const [duration, setDuration] = useState<number>()
|
children,
|
||||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
...props
|
||||||
const [startTime, setStartTime] = useState<number | null>(null)
|
}: ReasoningProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useControllableState({
|
||||||
|
prop: open,
|
||||||
|
defaultProp: defaultOpen,
|
||||||
|
onChange: onOpenChange,
|
||||||
|
})
|
||||||
|
const [duration, setDuration] = useControllableState({
|
||||||
|
prop: durationProp,
|
||||||
|
defaultProp: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
// Track duration when streaming starts and ends
|
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||||
useEffect(() => {
|
const [startTime, setStartTime] = useState<number | null>(null)
|
||||||
if (isStreaming) {
|
|
||||||
if (startTime === null) {
|
// Track duration when streaming starts and ends
|
||||||
setStartTime(Date.now())
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
|
if (startTime === null) {
|
||||||
|
setStartTime(Date.now())
|
||||||
|
}
|
||||||
|
} else if (startTime !== null) {
|
||||||
|
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||||
|
setStartTime(null)
|
||||||
}
|
}
|
||||||
} else if (startTime !== null) {
|
}, [isStreaming, startTime, setDuration])
|
||||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
|
||||||
setStartTime(null)
|
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||||
|
// Add a small delay before closing to allow user to see the content
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsOpen(false)
|
||||||
|
setHasAutoClosed(true)
|
||||||
|
}, AUTO_CLOSE_DELAY)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
setIsOpen(newOpen)
|
||||||
}
|
}
|
||||||
}, [isStreaming, startTime])
|
|
||||||
|
|
||||||
// Auto-close once when streaming ends, after a short delay so the user
|
return (
|
||||||
// sees the content
|
<ReasoningContext.Provider
|
||||||
useEffect(() => {
|
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
>
|
||||||
const timer = setTimeout(() => {
|
<Collapsible
|
||||||
setIsOpen(false)
|
className={cn("not-prose mb-4", className)}
|
||||||
setHasAutoClosed(true)
|
onOpenChange={handleOpenChange}
|
||||||
}, AUTO_CLOSE_DELAY)
|
open={isOpen}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
</ReasoningContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
export type ReasoningTriggerProps = ComponentProps<
|
||||||
}
|
typeof CollapsibleTrigger
|
||||||
}, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
|
> & {
|
||||||
|
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
className={cn("not-prose mb-4", className)}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
open={isOpen}
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
|
||||||
<BrainIcon className="size-4" />
|
|
||||||
{getThinkingMessage(isStreaming, duration)}
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-transform",
|
|
||||||
isOpen ? "rotate-180" : "rotate-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="mt-4 text-sm data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
|
|
||||||
<div className="whitespace-pre-wrap">{children}</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||||
|
if (isStreaming || duration === 0) {
|
||||||
|
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||||
|
}
|
||||||
|
if (duration === undefined) {
|
||||||
|
return <p>Thought for a few seconds</p>
|
||||||
|
}
|
||||||
|
return <p>Thought for {duration} seconds</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReasoningTrigger = memo(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
getThinkingMessage = defaultGetThinkingMessage,
|
||||||
|
...props
|
||||||
|
}: ReasoningTriggerProps) => {
|
||||||
|
const { isStreaming, isOpen, duration } = useReasoning()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<BrainIcon className="size-4" />
|
||||||
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
"size-4 transition-transform",
|
||||||
|
isOpen ? "rotate-180" : "rotate-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ReasoningContentProps = ComponentProps<
|
||||||
|
typeof CollapsibleContent
|
||||||
|
> & {
|
||||||
|
children: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReasoningContent = memo(
|
||||||
|
({ className, children, ...props }: ReasoningContentProps) => (
|
||||||
|
<CollapsibleContent
|
||||||
|
className={cn(
|
||||||
|
"mt-4 text-sm",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="whitespace-pre-wrap">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Reasoning.displayName = "Reasoning"
|
||||||
|
ReasoningTrigger.displayName = "ReasoningTrigger"
|
||||||
|
ReasoningContent.displayName = "ReasoningContent"
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { type CSSProperties, type ElementType, memo, useMemo } from "react"
|
import { motion } from "motion/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 = {
|
||||||
@@ -18,29 +25,39 @@ 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 (
|
||||||
<Component
|
<MotionComponent
|
||||||
|
animate={{ backgroundPosition: "0% center" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"animate-shimmer relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
"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}
|
||||||
</Component>
|
</MotionComponent>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -155,7 +155,8 @@ export function CanvasStage({
|
|||||||
key={src}
|
key={src}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
src={src}
|
src={src}
|
||||||
css={DRAWIO_CSS}
|
configCss={DRAWIO_CSS}
|
||||||
|
editorCss={DRAWIO_CSS}
|
||||||
dark={isDark}
|
dark={isDark}
|
||||||
onLoad={onDrawioLoad}
|
onLoad={onDrawioLoad}
|
||||||
onAutoSave={handleDiagramAutoSave}
|
onAutoSave={handleDiagramAutoSave}
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ export interface DrawioFrameHandle {
|
|||||||
|
|
||||||
interface DrawioFrameProps {
|
interface DrawioFrameProps {
|
||||||
src: string
|
src: string
|
||||||
/**
|
/** CSS sent with the configure message (works cross-origin too) */
|
||||||
* Theme CSS: sent with the configure message (works cross-origin too)
|
configCss: string
|
||||||
* and, same origin, injected as a <style> so it updates without a reload
|
/** CSS injected once the editor is reachable directly (same origin) */
|
||||||
*/
|
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
|
||||||
@@ -53,14 +52,25 @@ 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 cssRef = useRef(css)
|
const configCssRef = useRef(configCss)
|
||||||
cssRef.current = css
|
configCssRef.current = configCss
|
||||||
|
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
|
||||||
@@ -101,7 +111,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 = cssRef.current
|
style.textContent = editorCssRef.current
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin: the configure message already applied it
|
// Cross-origin: the configure message already applied it
|
||||||
}
|
}
|
||||||
@@ -109,7 +119,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyThemeCss()
|
applyThemeCss()
|
||||||
}, [css])
|
}, [editorCss])
|
||||||
|
|
||||||
// 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 = () => {
|
||||||
@@ -199,7 +209,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
send("configure", {
|
send("configure", {
|
||||||
config: {
|
config: {
|
||||||
...DRAWIO_CONFIG,
|
...DRAWIO_CONFIG,
|
||||||
css: cssRef.current,
|
css: configCssRef.current,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"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, Plus } from "lucide-react"
|
import { Bookmark, Edit2 } from "lucide-react"
|
||||||
import { useEffect, useId, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,32 +16,22 @@ 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 {
|
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||||
createTemplate,
|
|
||||||
type Template,
|
|
||||||
updateTemplate,
|
|
||||||
} from "@/lib/template-storage"
|
|
||||||
|
|
||||||
interface TemplateFormDialogProps {
|
interface TemplateEditDialogProps {
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One form for creating and editing a template
|
export function TemplateEditDialog({
|
||||||
export function TemplateFormDialog({
|
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSuccess,
|
|
||||||
template,
|
template,
|
||||||
initialPrompt = "",
|
onSuccess,
|
||||||
}: TemplateFormDialogProps) {
|
}: TemplateEditDialogProps) {
|
||||||
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("")
|
||||||
@@ -49,22 +39,32 @@ export function TemplateFormDialog({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Fill the form each time the dialog opens, dropping cancelled edits
|
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (open && template) {
|
||||||
setTitle(template?.title || "")
|
setTitle(template.title || "")
|
||||||
setDescription(template?.description || "")
|
setDescription(template.description || "")
|
||||||
setPrompt(template ? template.prompt || "" : initialPrompt)
|
setPrompt(template.prompt || "")
|
||||||
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
|
||||||
// the chat form this dialog may be rendered in
|
// any 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,59 +74,40 @@ export function TemplateFormDialog({
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const failed = isEdit
|
|
||||||
? dict.templates.updateFailed
|
|
||||||
: dict.templates.createFailed
|
|
||||||
try {
|
try {
|
||||||
const fields = {
|
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||||
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
|
|
||||||
// gets a default title from its prompt
|
const updated = await updateTemplate(template.id, updates)
|
||||||
const result = template
|
if (updated) {
|
||||||
? 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(failed)
|
setError(dict.templates.updateFailed)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save template:", err)
|
console.error("Failed to update template:", err)
|
||||||
setError(failed)
|
setError(dict.templates.updateFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<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">
|
||||||
{isEdit ? (
|
<Edit2 className="w-5 h-5" />
|
||||||
<Edit2 className="w-5 h-5" />
|
{dict.templates.editTitle}
|
||||||
) : (
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
{isEdit
|
|
||||||
? dict.templates.editTitle
|
|
||||||
: dict.templates.createTitle}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEdit
|
{dict.templates.editDescription}
|
||||||
? dict.templates.editDescription
|
|
||||||
: dict.templates.createDescription}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -134,14 +115,14 @@ export function TemplateFormDialog({
|
|||||||
{/* Prompt field - required */}
|
{/* Prompt field - required */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`${id}-prompt`}
|
htmlFor="edit-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={`${id}-prompt`}
|
id="edit-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}
|
||||||
@@ -153,13 +134,13 @@ export function TemplateFormDialog({
|
|||||||
{/* Title field - optional */}
|
{/* Title field - optional */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`${id}-title`}
|
htmlFor="edit-title"
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.titleLabel}
|
{dict.templates.titleLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${id}-title`}
|
id="edit-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}
|
||||||
@@ -172,13 +153,13 @@ export function TemplateFormDialog({
|
|||||||
{/* Description field - optional */}
|
{/* Description field - optional */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`${id}-description`}
|
htmlFor="edit-description"
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.descriptionLabel}
|
{dict.templates.descriptionLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`${id}-description`}
|
id="edit-description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -192,7 +173,7 @@ export function TemplateFormDialog({
|
|||||||
<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={`${id}-pinned`}
|
htmlFor="edit-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" />
|
||||||
@@ -203,7 +184,7 @@ export function TemplateFormDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id={`${id}-pinned`}
|
id="edit-pinned"
|
||||||
checked={pinned}
|
checked={pinned}
|
||||||
onCheckedChange={setPinned}
|
onCheckedChange={setPinned}
|
||||||
/>
|
/>
|
||||||
@@ -227,9 +208,7 @@ export function TemplateFormDialog({
|
|||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? dict.common.loading
|
? dict.common.loading
|
||||||
: isEdit
|
: dict.common.save}
|
||||||
? dict.common.save
|
|
||||||
: dict.templates.createButton}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -11,7 +11,6 @@ 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,
|
||||||
@@ -24,33 +23,63 @@ 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 { TemplateFormDialog } from "./TemplateFormDialog"
|
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||||
|
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)
|
||||||
@@ -82,6 +111,10 @@ 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 = () => {
|
||||||
@@ -100,7 +133,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,
|
dict.templates.copySuffix || "(copy)",
|
||||||
)
|
)
|
||||||
if (duplicated) {
|
if (duplicated) {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@@ -146,13 +179,23 @@ export function TemplatePanel({
|
|||||||
|
|
||||||
// Actually send the template
|
// Actually send the template
|
||||||
const sendTemplate = async (template: Template) => {
|
const sendTemplate = async (template: Template) => {
|
||||||
// A run only when it was sent (not while a message is still being
|
if (onSendTemplate) {
|
||||||
// sent, or attachments read)
|
// Sent first, while the chat is the one it was clicked in (the
|
||||||
if (await onSendTemplate(template)) {
|
// counts wait for storage). A run only when it was sent (not
|
||||||
// Increment run count and update lastUsedAt
|
// while a message is still being sent, or attachments read).
|
||||||
await incrementRunCount(template.id)
|
const sending = onSendTemplate(template)
|
||||||
// Reload to show updated stats
|
// Increment click count only when actually sending
|
||||||
loadTemplates()
|
await incrementClickCount(template.id)
|
||||||
|
if (await sending) {
|
||||||
|
// Increment run count and update lastUsedAt
|
||||||
|
await incrementRunCount(template.id)
|
||||||
|
// Reload to show updated stats
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await incrementClickCount(template.id)
|
||||||
|
// Fallback: just fill the input if no send callback provided
|
||||||
|
setInput(template.prompt)
|
||||||
}
|
}
|
||||||
setConfirmSendDialogOpen(false)
|
setConfirmSendDialogOpen(false)
|
||||||
setTemplateToSend(null)
|
setTemplateToSend(null)
|
||||||
@@ -175,7 +218,7 @@ export function TemplatePanel({
|
|||||||
if (templates.length === 0) {
|
if (templates.length === 0) {
|
||||||
setImportMessage({
|
setImportMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: dict.templates.exportEmpty,
|
text: dict.templates.exportEmpty || "No templates to export",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,7 +260,8 @@ export function TemplatePanel({
|
|||||||
if (!file) {
|
if (!file) {
|
||||||
setImportMessage({
|
setImportMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: dict.templates.importNoFile,
|
text:
|
||||||
|
dict.templates.importNoFile || "Please select a JSON file",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -326,7 +370,7 @@ export function TemplatePanel({
|
|||||||
<div className="mt-3">{importMessageBox}</div>
|
<div className="mt-3">{importMessageBox}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TemplateFormDialog
|
<TemplateCreateDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
@@ -456,7 +500,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))} · ${template.lastUsedAt ? formatRelativeTime(template.lastUsedAt, params.lang, dict.sessionHistory.justNow) : dict.templates.neverUsed}`
|
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, 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">
|
||||||
@@ -473,8 +517,10 @@ export function TemplatePanel({
|
|||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
template.pinned
|
template.pinned
|
||||||
? dict.templates.unpin
|
? dict.templates
|
||||||
: dict.templates.pin
|
.unpin || "Unpin"
|
||||||
|
: dict.templates.pin ||
|
||||||
|
"Pin"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Bookmark
|
<Bookmark
|
||||||
@@ -499,7 +545,10 @@ 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={dict.templates.duplicate}
|
title={
|
||||||
|
dict.templates.duplicate ||
|
||||||
|
"Duplicate"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -521,13 +570,13 @@ export function TemplatePanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateFormDialog
|
<TemplateCreateDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateFormDialog
|
<TemplateEditDialog
|
||||||
open={editDialogOpen}
|
open={editDialogOpen}
|
||||||
onOpenChange={setEditDialogOpen}
|
onOpenChange={setEditDialogOpen}
|
||||||
template={templateToEdit}
|
template={templateToEdit}
|
||||||
@@ -542,10 +591,12 @@ 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>
|
||||||
@@ -570,10 +621,12 @@ 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>
|
||||||
@@ -581,7 +634,8 @@ 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>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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"
|
||||||
@@ -158,10 +159,13 @@ 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">
|
||||||
|
|||||||
+200
-122
@@ -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,10 +19,14 @@ 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 { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
import {
|
||||||
|
keepFileVars,
|
||||||
|
useDiagramToolHandlers,
|
||||||
|
} from "@/hooks/use-diagram-tool-handlers"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import {
|
import {
|
||||||
getSelectedAIConfig,
|
getSelectedAIConfig,
|
||||||
@@ -30,18 +34,13 @@ 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"
|
||||||
@@ -81,7 +80,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: ChatUIMessage[]): boolean {
|
function hasToolErrors(messages: UIMessage[]): 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)
|
||||||
@@ -108,23 +107,45 @@ function viewedPageId(): string | null {
|
|||||||
return useCanvasStore.getState().currentPageId
|
return useCanvasStore.getState().currentPageId
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectionOf(message: ChatUIMessage): MessageSelection {
|
// Shapes sent with a user message (also kept in its metadata)
|
||||||
const selected = message.metadata?.selectedCells
|
type MessageSelection = Pick<SelectedCell, "id" | "label">[]
|
||||||
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 loaded = parseDrawioFileContent(await file.text())
|
const raw = await file.text()
|
||||||
return loaded.ok ? withUniquePageIds(loaded.xml) : null
|
// What XML allows before the root element: the declaration, comments,
|
||||||
|
// a DOCTYPE (the parser below expects the root first)
|
||||||
|
let text = raw.replace(XML_PROLOG, "")
|
||||||
|
// Editable SVG: the diagram is stored in the root's `content` attribute.
|
||||||
|
// Parsed whole: its DOCTYPE may define entities the content uses.
|
||||||
|
if (/^<(?:[\w.-]+:)?svg[\s>]/i.test(text)) {
|
||||||
|
const doc = new DOMParser().parseFromString(raw, "image/svg+xml")
|
||||||
|
text = (doc.documentElement?.getAttribute("content") ?? "").replace(
|
||||||
|
XML_PROLOG,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const loaded = parseDrawioFileContent(text)
|
||||||
|
if (!loaded.ok) return null
|
||||||
|
return withUniquePageIds(loaded.xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,13 +190,15 @@ function withUniquePageIds(xml: string): string {
|
|||||||
class FileReadError extends Error {}
|
class FileReadError extends Error {}
|
||||||
|
|
||||||
export interface ChatEngine {
|
export interface ChatEngine {
|
||||||
messages: ChatUIMessage[]
|
messages: UIMessage[]
|
||||||
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
|
||||||
setMessages: (messages: ChatUIMessage[]) => void
|
/** A message is being sent again; the chat is not empty meanwhile */
|
||||||
|
isResending: boolean
|
||||||
|
setMessages: (messages: UIMessage[]) => void
|
||||||
stop: () => void
|
stop: () => void
|
||||||
|
|
||||||
input: string
|
input: string
|
||||||
@@ -244,10 +267,11 @@ export function ChatEngineProvider({
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
fetchDiagramXml,
|
handleExport: onExport,
|
||||||
|
exportResolversRef,
|
||||||
chartXML,
|
chartXML,
|
||||||
chartXMLRef,
|
chartXMLRef: liveChartXMLRef,
|
||||||
latestSvgRef,
|
latestSvg,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
@@ -260,10 +284,26 @@ export function ChatEngineProvider({
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const urlSessionId = searchParams.get("session")
|
const urlSessionId = searchParams.get("session")
|
||||||
|
|
||||||
const vlmValidationEnabled = useSettingsStore((s) => s.vlmValidationEnabled)
|
const settings = useSettingsStore()
|
||||||
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,
|
||||||
@@ -278,6 +318,9 @@ 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(() => {
|
||||||
@@ -297,7 +340,21 @@ 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"),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -335,12 +392,20 @@ 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
|
||||||
@@ -359,6 +424,9 @@ 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)
|
||||||
@@ -390,13 +458,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,
|
chartXMLRef: liveChartXMLRef,
|
||||||
turnPageIdRef,
|
turnPageIdRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart: fetchDiagramXml,
|
onFetchChart,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
validateDiagram: validateWithFallback,
|
validateDiagram: validateWithFallback,
|
||||||
enableVlmValidation: vlmValidationEnabled,
|
enableVlmValidation: settings.vlmValidationEnabled,
|
||||||
sessionId: langfuseSessionId,
|
sessionId: langfuseSessionId,
|
||||||
watchStop: () => {
|
watchStop: () => {
|
||||||
const stopsBefore = stopCountRef.current
|
const stopsBefore = stopCountRef.current
|
||||||
@@ -424,40 +492,9 @@ 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 {
|
||||||
@@ -468,19 +505,19 @@ export function ChatEngineProvider({
|
|||||||
error,
|
error,
|
||||||
setMessages,
|
setMessages,
|
||||||
stop,
|
stop,
|
||||||
} = useChat<ChatUIMessage>({
|
} = useChat({
|
||||||
transport,
|
transport,
|
||||||
onToolCall: async ({ toolCall }) => {
|
onToolCall: async ({ toolCall }) => {
|
||||||
await handleToolCall({ toolCall }, addToolOutputSafely)
|
await handleToolCall({ toolCall }, addToolOutputSafely)
|
||||||
},
|
},
|
||||||
onFinish: ({ message, isAbort, isError, finishReason }) => {
|
onFinish: ({ message, isAbort, isError }) => {
|
||||||
// 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
|
const cut = (message.parts as any[]).filter(
|
||||||
.filter(isToolUIPart)
|
(part) => part.state === "input-streaming" && part.toolCallId,
|
||||||
.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)
|
||||||
}
|
}
|
||||||
@@ -489,16 +526,18 @@ 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) stopToolCall(part)
|
for (const part of cut) {
|
||||||
|
addToolOutputSafely({
|
||||||
|
tool: part.type.replace("tool-", ""),
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "Stopped by user",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isAbort || isError) return
|
if (isAbort || isError || cut.length === 0) return
|
||||||
// The output budget ran out before the model called a tool (its
|
// Ended with no error: undo the preview, and say so, as an
|
||||||
// thinking took it all): nothing was drawn
|
// error does (with Retry)
|
||||||
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()
|
||||||
@@ -507,11 +546,16 @@ export function ChatEngineProvider({
|
|||||||
if (getChatGeneration() !== turnChatRef.current) return
|
if (getChatGeneration() !== turnChatRef.current) return
|
||||||
setMessages((currentMessages) => [
|
setMessages((currentMessages) => [
|
||||||
...currentMessages,
|
...currentMessages,
|
||||||
noticeMessage({
|
{
|
||||||
text: outOfTokens
|
id: `error-${Date.now()}`,
|
||||||
? dict.errors.llm.output_truncated
|
role: "system" as const,
|
||||||
: dict.errors.answerEndedEarly,
|
parts: [
|
||||||
}),
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: dict.errors.answerEndedEarly,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -580,15 +624,19 @@ 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
|
||||||
|
|
||||||
// In the chat, so it is cleared with the conversation; with a
|
// A system message, so it can be cleared with the conversation
|
||||||
// button that opens model settings, on the page of the provider
|
|
||||||
// that refused
|
|
||||||
setMessages((currentMessages) => [
|
setMessages((currentMessages) => [
|
||||||
...currentMessages,
|
...currentMessages,
|
||||||
noticeMessage({
|
{
|
||||||
text,
|
id: `error-${Date.now()}`,
|
||||||
...(openModelConfig && { openModelConfig, providerId }),
|
role: "system" as const,
|
||||||
}),
|
parts: [{ type: "text" as const, text }],
|
||||||
|
// The message shows a button that opens model settings,
|
||||||
|
// on the page of the provider that refused
|
||||||
|
...(openModelConfig && {
|
||||||
|
metadata: { openModelConfig: true, providerId },
|
||||||
|
}),
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
if (isAccessCodeError) openSettings("general")
|
if (isAccessCodeError) openSettings("general")
|
||||||
@@ -618,19 +666,9 @@ 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: ChatUIMessage[]) => {
|
const shouldSendAgain = (messages: UIMessage[]) => {
|
||||||
// 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
|
||||||
@@ -682,6 +720,9 @@ 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
|
||||||
@@ -793,10 +834,16 @@ 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<ChatUIMessage[] | null>(null)
|
const messagesBeforeShownRef = useRef<UIMessage[] | 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
|
||||||
@@ -833,17 +880,26 @@ 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 ChatUIMessage[])
|
setMessages(data.messages as unknown as UIMessage[])
|
||||||
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
||||||
if (data.diagramXml) onDisplayChart(data.diagramXml, true)
|
if (data.diagramXml) {
|
||||||
else clearDiagram()
|
onDisplayChart(data.diagramXml, true)
|
||||||
|
chartXMLRef.current = data.diagramXml
|
||||||
|
} else {
|
||||||
|
clearDiagram()
|
||||||
|
chartXMLRef.current = ""
|
||||||
|
}
|
||||||
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
|
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
|
||||||
useVersionsStore.getState().setVersions(data.versions ?? [])
|
useVersionsStore
|
||||||
|
.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()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -876,6 +932,7 @@ 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
|
||||||
@@ -1140,15 +1197,12 @@ 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
|
||||||
@@ -1186,11 +1240,9 @@ 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 },
|
||||||
@@ -1209,7 +1261,7 @@ export function ChatEngineProvider({
|
|||||||
selectedCells: MessageSelection = [],
|
selectedCells: MessageSelection = [],
|
||||||
generation = getChatGeneration(),
|
generation = getChatGeneration(),
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const chartXml = formatXML(await fetchDiagramXml())
|
const chartXml = formatXML(await onFetchChart())
|
||||||
if (getChatGeneration() !== generation || leavingRef.current) {
|
if (getChatGeneration() !== generation || leavingRef.current) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1292,8 +1344,12 @@ export function ChatEngineProvider({
|
|||||||
})
|
})
|
||||||
if (prepared.ok) {
|
if (prepared.ok) {
|
||||||
const pageId = viewedPageId()
|
const pageId = viewedPageId()
|
||||||
|
const canvasXml = chartXMLRef.current || ""
|
||||||
onDisplayChart(
|
onDisplayChart(
|
||||||
placeOnPage(prepared.xml, chartXMLRef.current, pageId),
|
keepFileVars(
|
||||||
|
placeOnPage(prepared.xml, canvasXml, pageId),
|
||||||
|
canvasXml,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
"commit",
|
"commit",
|
||||||
{ toolCallId, pageId },
|
{ toolCallId, pageId },
|
||||||
@@ -1381,8 +1437,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, so busy is read from
|
// Whether it was sent (it can run after a wait: the template panel
|
||||||
// the ref)
|
// stores its click count first, so busy is read from 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
|
||||||
@@ -1422,6 +1478,11 @@ 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()) {
|
||||||
@@ -1439,17 +1500,22 @@ export function ChatEngineProvider({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const previousXml = getPreviousXml(index)
|
const previousXml = getPreviousXml(index)
|
||||||
onDisplayChart(savedXml, true)
|
restoreDiagramFromSnapshot(savedXml)
|
||||||
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
|
||||||
@@ -1502,7 +1568,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 fetchDiagramXml())
|
const chartXml = formatXML(await onFetchChart())
|
||||||
// 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) {
|
||||||
@@ -1515,13 +1581,16 @@ 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)
|
||||||
@@ -1540,17 +1609,25 @@ 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 streaming = (lastMessage?.parts ?? [])
|
const toolParts = lastMessage?.parts?.filter(
|
||||||
.filter(isToolUIPart)
|
(part: any) =>
|
||||||
.filter(
|
part.type?.startsWith("tool-") &&
|
||||||
(part) =>
|
part.state === "input-streaming" &&
|
||||||
part.state === "input-streaming" &&
|
!processedToolCallsRef.current.has(part.toolCallId),
|
||||||
!processedToolCallsRef.current.has(part.toolCallId),
|
)
|
||||||
)
|
for (const part of (toolParts ?? []) as any[]) {
|
||||||
for (const part of streaming) stopToolCall(part)
|
if (part.toolCallId) {
|
||||||
|
addToolOutputSafely({
|
||||||
|
tool: part.type.replace("tool-", ""),
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "Stopped by user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
stop()
|
stop()
|
||||||
abandonContinuation()
|
abandonContinuation()
|
||||||
}, [messages, stopToolCall, stop, cancelValidation])
|
}, [messages, addToolOutputSafely, stop, cancelValidation])
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Session actions
|
// Session actions
|
||||||
@@ -1860,7 +1937,8 @@ export function ChatEngineProvider({
|
|||||||
error,
|
error,
|
||||||
isBusy,
|
isBusy,
|
||||||
isLeaving,
|
isLeaving,
|
||||||
setMessages: setMessages as (messages: ChatUIMessage[]) => void,
|
isResending,
|
||||||
|
setMessages: setMessages as (messages: UIMessage[]) => void,
|
||||||
stop: handleStop,
|
stop: handleStop,
|
||||||
input,
|
input,
|
||||||
setInput,
|
setInput,
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
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 {
|
||||||
@@ -45,16 +46,24 @@ 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) {
|
||||||
toast.error(
|
showErrorToast(
|
||||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||||
{ duration: 5000 },
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast.error(
|
showErrorToast(
|
||||||
<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, {
|
||||||
@@ -74,7 +83,6 @@ function showValidationErrors(errors: string[], dict: any) {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>,
|
</div>,
|
||||||
{ duration: 5000 },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,11 +277,10 @@ export function Composer({
|
|||||||
next.delete(url)
|
next.delete(url)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
toast.error(
|
showErrorToast(
|
||||||
<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)
|
||||||
@@ -512,7 +519,7 @@ export function Composer({
|
|||||||
onSubmit={handleUrlExtract}
|
onSubmit={handleUrlExtract}
|
||||||
isExtracting={isExtractingUrl}
|
isExtracting={isExtractingUrl}
|
||||||
/>
|
/>
|
||||||
<TemplateFormDialog
|
<TemplateCreateDialog
|
||||||
open={saveTemplateOpen}
|
open={saveTemplateOpen}
|
||||||
onOpenChange={setSaveTemplateOpen}
|
onOpenChange={setSaveTemplateOpen}
|
||||||
onSuccess={() => setSaveTemplateOpen(false)}
|
onSuccess={() => setSaveTemplateOpen(false)}
|
||||||
@@ -525,6 +532,10 @@ 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)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileUp, History, Pencil } from "lucide-react"
|
import { FileText, 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,6 +34,7 @@ function ExampleTile({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
picture,
|
picture,
|
||||||
|
plain,
|
||||||
testId,
|
testId,
|
||||||
}: {
|
}: {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
@@ -41,6 +42,8 @@ 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 (
|
||||||
@@ -51,7 +54,12 @@ 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 className="flex h-16 w-full items-center justify-center overflow-hidden rounded-lg sheet-light bg-white">
|
<span
|
||||||
|
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">
|
||||||
@@ -212,13 +220,18 @@ export function LobbyHero({
|
|||||||
description={example.description}
|
description={example.description}
|
||||||
testId={`example-${example.key}`}
|
testId={`example-${example.key}`}
|
||||||
picture={
|
picture={
|
||||||
// biome-ignore lint/performance/noImgElement: static preview
|
example.image ? (
|
||||||
<img
|
// biome-ignore lint/performance/noImgElement: static preview
|
||||||
src={getAssetUrl(example.image)}
|
<img
|
||||||
alt=""
|
src={getAssetUrl(example.image)}
|
||||||
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
alt=""
|
||||||
/>
|
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileText className="size-5 text-muted-foreground" />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
plain={!example.image}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isToolUIPart, type UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
BookmarkPlus,
|
BookmarkPlus,
|
||||||
@@ -16,15 +16,18 @@ 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 { Reasoning } from "@/components/ai-elements/reasoning"
|
import {
|
||||||
|
Reasoning,
|
||||||
|
ReasoningContent,
|
||||||
|
ReasoningTrigger,
|
||||||
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
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"
|
||||||
@@ -288,7 +291,7 @@ function UserMessage({
|
|||||||
{text && <MessageText text={text} />}
|
{text && <MessageText text={text} />}
|
||||||
</div>
|
</div>
|
||||||
{saveTemplate && (
|
{saveTemplate && (
|
||||||
<TemplateFormDialog
|
<TemplateCreateDialog
|
||||||
open
|
open
|
||||||
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
||||||
onSuccess={() => setSaveTemplate(false)}
|
onSuccess={() => setSaveTemplate(false)}
|
||||||
@@ -370,7 +373,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 (isToolUIPart(part))
|
else if (part.type?.startsWith("tool-"))
|
||||||
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
|
||||||
@@ -395,9 +398,13 @@ function AssistantMessage({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
isStreaming={isStreamingReasoning}
|
isStreaming={isStreamingReasoning}
|
||||||
defaultOpen={!isRestored}
|
defaultOpen={!isRestored}
|
||||||
getThinkingMessage={thinkingMessage}
|
|
||||||
>
|
>
|
||||||
{(part as { text: string }).text}
|
<ReasoningTrigger
|
||||||
|
getThinkingMessage={thinkingMessage}
|
||||||
|
/>
|
||||||
|
<ReasoningContent>
|
||||||
|
{(part as { text: string }).text}
|
||||||
|
</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -569,10 +576,9 @@ export function MessageList() {
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
// A notice (a failed request) is an assistant message without an answer
|
const lastAssistantIndex = messages
|
||||||
const lastAssistantIndex = messages.findLastIndex(
|
.map((m) => m.role)
|
||||||
(m) => m.role === "assistant" && !noticeOf(m),
|
.lastIndexOf("assistant")
|
||||||
)
|
|
||||||
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -581,7 +587,6 @@ 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}
|
||||||
@@ -593,18 +598,33 @@ export function MessageList() {
|
|||||||
index={index}
|
index={index}
|
||||||
isLast={index === lastUserIndex}
|
isLast={index === lastUserIndex}
|
||||||
/>
|
/>
|
||||||
) : notice ? (
|
) : message.role === "assistant" ? (
|
||||||
|
<AssistantMessage
|
||||||
|
message={message}
|
||||||
|
index={index}
|
||||||
|
isLastMessage={index === messages.length - 1}
|
||||||
|
isLastAssistant={index === lastAssistantIndex}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ErrorNotice
|
<ErrorNotice
|
||||||
text={notice.text}
|
text={getMessageTextContent(message)}
|
||||||
openModelConfig={!!notice.openModelConfig}
|
openModelConfig={
|
||||||
onOpenModelConfig={() =>
|
!!(
|
||||||
|
message.metadata as
|
||||||
|
| { openModelConfig?: boolean }
|
||||||
|
| undefined
|
||||||
|
)?.openModelConfig
|
||||||
|
}
|
||||||
|
onOpenModelConfig={() => {
|
||||||
|
const { providerId } =
|
||||||
|
(message.metadata as
|
||||||
|
| { providerId?: string }
|
||||||
|
| undefined) ?? {}
|
||||||
openSettings(
|
openSettings(
|
||||||
"models",
|
"models",
|
||||||
notice.providerId
|
providerId ? { providerId } : "list",
|
||||||
? { providerId: notice.providerId }
|
|
||||||
: "list",
|
|
||||||
)
|
)
|
||||||
}
|
}}
|
||||||
onRetry={
|
onRetry={
|
||||||
index === messages.length - 1 &&
|
index === messages.length - 1 &&
|
||||||
!engine.isBusy &&
|
!engine.isBusy &&
|
||||||
@@ -613,13 +633,6 @@ export function MessageList() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<AssistantMessage
|
|
||||||
message={message}
|
|
||||||
index={index}
|
|
||||||
isLastMessage={index === messages.length - 1}
|
|
||||||
isLastAssistant={index === lastAssistantIndex}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { getToolName } from "ai"
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
@@ -165,7 +164,7 @@ function ToolRow({
|
|||||||
tone === "error" && "border-destructive/25",
|
tone === "error" && "border-destructive/25",
|
||||||
)}
|
)}
|
||||||
data-testid="tool-row"
|
data-testid="tool-row"
|
||||||
data-tool-name={getToolName(part)}
|
data-tool-name={part.type?.replace("tool-", "")}
|
||||||
data-tool-state={part.state}
|
data-tool-state={part.state}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -247,7 +246,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 = getToolName(part)
|
const toolName = part.type?.replace("tool-", "")
|
||||||
const { state, errorText, output } = part
|
const { state, errorText, output } = part
|
||||||
|
|
||||||
const versionIndex = versions.findIndex(
|
const versionIndex = versions.findIndex(
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
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 {
|
||||||
* A tool part as the UI reads it, in any state: input, output and errorText
|
type: string
|
||||||
* are each there or not, instead of one shape per state.
|
toolCallId: string
|
||||||
*/
|
state?: string
|
||||||
export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & {
|
input?: {
|
||||||
input?:
|
xml?: string
|
||||||
| ({
|
operations?: DiagramOperation[]
|
||||||
xml?: string
|
} & Record<string, unknown>
|
||||||
operations?: DiagramOperation[]
|
|
||||||
} & Record<string, unknown>)
|
|
||||||
| undefined
|
|
||||||
output?: string
|
output?: string
|
||||||
errorText?: string
|
errorText?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, replacePageModel } from "@/lib/diagram-pages"
|
import { pageSelectorFor } from "@/lib/diagram-pages"
|
||||||
import { extractCompleteMxCells } from "@/lib/utils"
|
import {
|
||||||
|
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 {
|
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||||
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,31 +91,32 @@ 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
|
||||||
// The same wrapping, fixes and expansion as the final diagram
|
const convertedXml = addDefaultStyles(
|
||||||
// (prepareNewDiagram), so the preview matches what is committed
|
|
||||||
const model = addDefaultStyles(
|
|
||||||
applyStyleClasses(
|
applyStyleClasses(
|
||||||
expandCompactCells(
|
expandCompactCells(convertToLegalXml(completeCells)),
|
||||||
autoFixXml(wrapCellsInModel(completeCells)).fixed,
|
|
||||||
),
|
|
||||||
classes,
|
classes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (model === previousXML.current) return
|
if (convertedXml === 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(model, "text/xml")
|
const testDoc = new DOMParser().parseFromString(
|
||||||
|
`<root>${convertedXml}</root>`,
|
||||||
|
"text/xml",
|
||||||
|
)
|
||||||
if (testDoc.querySelector("parsererror")) return
|
if (testDoc.querySelector("parsererror")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Replace the model's page so other pages stay intact; an
|
// Replace the model's page's cells so other pages stay
|
||||||
// empty canvas gets the blank mxfile to put the page in
|
// intact. An empty canvas gets a default mxfile to put the
|
||||||
const replacedXML = replacePageModel(
|
// cells in.
|
||||||
chartXML,
|
const baseXML = chartXML || BLANK_MXFILE
|
||||||
|
const replacedXML = replaceNodes(
|
||||||
|
baseXML,
|
||||||
|
convertedXml,
|
||||||
turnPageIdRef?.current,
|
turnPageIdRef?.current,
|
||||||
model,
|
|
||||||
)
|
)
|
||||||
previousXML.current = model
|
previousXML.current = convertedXml
|
||||||
loadDiagram(replacedXML, true, "preview")
|
loadDiagram(replacedXML, true, "preview")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing XML:", error)
|
console.error("Error processing XML:", error)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"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
|
||||||
@@ -134,12 +135,15 @@ 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)
|
||||||
@@ -176,7 +180,7 @@ export function DevXmlSimulator({
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: "[Dev] Simulating XML streaming",
|
text: dict.dev.simulatingMessage,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -226,7 +230,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 = "Successfully displayed the diagram."
|
lastMsg.parts[0].output = dict.dev.successMessage
|
||||||
lastMsg.parts[0].input = { xml }
|
lastMsg.parts[0].input = { xml }
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
@@ -243,12 +247,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">
|
||||||
Dev: XML Streaming Simulator
|
{dict.dev.title}
|
||||||
</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">
|
||||||
Preset:
|
{dict.dev.preset}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -260,7 +264,7 @@ export function DevXmlSimulator({
|
|||||||
defaultValue=""
|
defaultValue=""
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Select a preset...
|
{dict.dev.selectPreset}
|
||||||
</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}>
|
||||||
@@ -273,19 +277,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"
|
||||||
>
|
>
|
||||||
Clear
|
{dict.dev.clear}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={devXml}
|
value={devXml}
|
||||||
onChange={(e) => setDevXml(e.target.value)}
|
onChange={(e) => setDevXml(e.target.value)}
|
||||||
placeholder="Paste mxCell XML here or select a preset..."
|
placeholder={dict.dev.placeholder}
|
||||||
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">
|
||||||
Interval:
|
{dict.dev.interval}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -304,7 +308,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">
|
||||||
Chars:
|
{dict.dev.chars}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -328,8 +332,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
|
||||||
? "Streaming..."
|
? dict.dev.streaming
|
||||||
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||||
</button>
|
</button>
|
||||||
{isSimulating && (
|
{isSimulating && (
|
||||||
<button
|
<button
|
||||||
@@ -339,7 +343,16 @@ 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"
|
||||||
>
|
>
|
||||||
Stop
|
{dict.dev.stop}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onShowQuotaToast && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShowQuotaToast}
|
||||||
|
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||||
|
>
|
||||||
|
{dict.dev.testQuotaToast}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// GitHub mark as an inline SVG, so no icon package is needed for one icon
|
|
||||||
export function GitHubIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -43,6 +43,7 @@ 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,
|
||||||
@@ -163,6 +164,7 @@ 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(),
|
||||||
@@ -439,6 +441,7 @@ 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,
|
||||||
@@ -656,6 +659,10 @@ 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
|
||||||
@@ -997,6 +1004,7 @@ 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,
|
||||||
@@ -1381,7 +1389,10 @@ export function ModelConfigDialog({
|
|||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteConfirmOpen}
|
open={deleteConfirmOpen}
|
||||||
onOpenChange={setDeleteConfirmOpen}
|
onOpenChange={(open) => {
|
||||||
|
setDeleteConfirmOpen(open)
|
||||||
|
if (!open) setDeleteConfirmText("")
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="border-destructive/30">
|
<AlertDialogContent className="border-destructive/30">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -1399,11 +1410,39 @@ 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}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
disabled={
|
||||||
|
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>
|
||||||
|
|||||||
+333
-367
@@ -10,38 +10,28 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
type ComponentProps,
|
ModelSelectorContent,
|
||||||
type ReactNode,
|
ModelSelectorEmpty,
|
||||||
useEffect,
|
ModelSelectorGroup,
|
||||||
useMemo,
|
ModelSelectorInput,
|
||||||
useRef,
|
ModelSelectorItem,
|
||||||
useState,
|
ModelSelectorList,
|
||||||
} from "react"
|
ModelSelectorLogo,
|
||||||
|
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 { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
CommandSeparator,
|
|
||||||
} from "@/components/ui/command"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import type { FlattenedModel, ProviderName } from "@/lib/types/model-config"
|
import {
|
||||||
|
type FlattenedModel,
|
||||||
|
PROVIDER_LOGO_MAP,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
@@ -54,22 +44,25 @@ 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, ProviderGroup> {
|
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
||||||
const groups = new Map<string, ProviderGroup>()
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{ provider: string; models: FlattenedModel[] }
|
||||||
|
>()
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
const existing = groups.get(model.providerLabel)
|
// For server models, strip "Server · " prefix for cleaner grouping
|
||||||
|
const key =
|
||||||
|
model.source === "server"
|
||||||
|
? model.providerLabel.replace(/^Server · /, "")
|
||||||
|
: model.providerLabel
|
||||||
|
const existing = groups.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.models.push(model)
|
existing.models.push(model)
|
||||||
} else {
|
} else {
|
||||||
groups.set(model.providerLabel, {
|
groups.set(key, { provider: model.provider, models: [model] })
|
||||||
provider: model.provider,
|
|
||||||
models: [model],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
@@ -95,72 +88,6 @@ 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,
|
||||||
@@ -219,52 +146,6 @@ 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}`
|
||||||
@@ -305,244 +186,329 @@ 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">
|
||||||
<Dialog
|
<ModelSelectorRoot
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(next) => {
|
onOpenChange={(next) => {
|
||||||
if (next) toSettingsRef.current = false
|
if (next) toSettingsRef.current = false
|
||||||
setOpen(next)
|
setOpen(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* The dialog trigger must wrap the button itself: Radix
|
<ModelSelectorTrigger asChild>
|
||||||
Tooltip's root renders no element to pass its props to */}
|
<ButtonWithTooltip
|
||||||
<Tooltip>
|
tooltipContent={tooltipContent}
|
||||||
<TooltipTrigger asChild>
|
variant="ghost"
|
||||||
<DialogTrigger asChild>
|
size="sm"
|
||||||
<Button
|
disabled={disabled}
|
||||||
variant="ghost"
|
className={cn(
|
||||||
size="sm"
|
"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",
|
||||||
disabled={disabled}
|
!showLabel && "px-1.5 justify-center",
|
||||||
className={cn(
|
)}
|
||||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
// accessibility: expose label to screen readers
|
||||||
!showLabel && "px-1.5 justify-center",
|
aria-label={tooltipContent}
|
||||||
)}
|
data-testid="model-selector"
|
||||||
// accessibility: expose label to screen readers
|
>
|
||||||
aria-label={tooltipContent}
|
{selectedModel ? (
|
||||||
data-testid="model-selector"
|
<ProviderLogo
|
||||||
>
|
provider={selectedModel.provider}
|
||||||
{selectedModel ? (
|
className="size-3.5 flex-shrink-0 opacity-80"
|
||||||
<ProviderLogo
|
/>
|
||||||
provider={selectedModel.provider}
|
) : (
|
||||||
className="size-3.5 flex-shrink-0 opacity-80"
|
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||||
/>
|
)}
|
||||||
) : (
|
{/* show/hide visible label based on measured width */}
|
||||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
{showLabel ? (
|
||||||
)}
|
<span className="min-w-0 truncate text-xs">
|
||||||
{/* show/hide visible label based on measured width */}
|
{selectedModel
|
||||||
{showLabel ? (
|
? shortModelName(selectedModel.modelId)
|
||||||
<span className="min-w-0 truncate text-xs">
|
: dict.modelConfig.serverDefault}
|
||||||
{selectedModel
|
</span>
|
||||||
? shortModelName(
|
) : (
|
||||||
selectedModel.modelId,
|
// Keep an sr-only label for screen readers when hidden
|
||||||
)
|
<span className="sr-only">
|
||||||
: dict.modelConfig.serverDefault}
|
{selectedModel
|
||||||
</span>
|
? selectedModel.modelId
|
||||||
) : (
|
: dict.modelConfig.serverDefault}
|
||||||
// Keep an sr-only label for screen readers when hidden
|
</span>
|
||||||
<span className="sr-only">
|
)}
|
||||||
{selectedModel
|
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
? selectedModel.modelId
|
</ButtonWithTooltip>
|
||||||
: dict.modelConfig.serverDefault}
|
</ModelSelectorTrigger>
|
||||||
</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>
|
|
||||||
|
|
||||||
<DialogContent
|
<ModelSelectorContent
|
||||||
className="p-0"
|
title={dict.modelConfig.selectModel}
|
||||||
onCloseAutoFocus={(e) => {
|
onCloseAutoFocus={(e) => {
|
||||||
if (!toSettingsRef.current) return
|
if (!toSettingsRef.current) return
|
||||||
toSettingsRef.current = false
|
toSettingsRef.current = false
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">
|
<ModelSelectorInput
|
||||||
{dict.modelConfig.selectModel}
|
placeholder={dict.modelConfig.searchModels}
|
||||||
</DialogTitle>
|
/>
|
||||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<CommandInput
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
className="h-auto py-3.5"
|
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||||
placeholder={dict.modelConfig.searchModels}
|
<ModelSelectorEmpty>
|
||||||
/>
|
{dict.modelConfig.noModelsFound}
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
</ModelSelectorEmpty>
|
||||||
<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 && (
|
||||||
<CommandGroup
|
<ModelSelectorGroup
|
||||||
heading={dict.modelConfig.default}
|
heading={dict.modelConfig.default}
|
||||||
|
>
|
||||||
|
<ModelSelectorItem
|
||||||
|
value="__server_default__"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
!selectedModelId && "bg-accent",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<CommandItem
|
<Check
|
||||||
value="__server_default__"
|
|
||||||
onSelect={handleSelect}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer",
|
"mr-2 h-4 w-4",
|
||||||
!selectedModelId &&
|
!selectedModelId
|
||||||
"bg-accent",
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<Check
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
className={cn(
|
<ModelSelectorName>
|
||||||
"mr-2 h-4 w-4",
|
{dict.modelConfig.serverDefault}
|
||||||
!selectedModelId
|
</ModelSelectorName>
|
||||||
? "opacity-100"
|
</ModelSelectorItem>
|
||||||
: "opacity-0",
|
</ModelSelectorGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Models Section */}
|
||||||
|
{serverModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ModelSelectorSectionHeader
|
||||||
|
icon={<Monitor />}
|
||||||
|
label={
|
||||||
|
dict.modelConfig.serverModels
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{Array.from(
|
||||||
|
groupedServerModels.entries(),
|
||||||
|
).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
models: providerModels,
|
||||||
|
},
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={`server-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map(
|
||||||
|
(model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
// Unique value so same-named models highlight
|
||||||
|
// separately; keywords keep search by name
|
||||||
|
value={model.id}
|
||||||
|
keywords={[
|
||||||
|
model.modelId,
|
||||||
|
providerLabel,
|
||||||
|
]}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect(
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId ===
|
||||||
|
model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[
|
||||||
|
provider
|
||||||
|
] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{
|
||||||
|
model.modelId
|
||||||
|
}
|
||||||
|
</ModelSelectorName>
|
||||||
|
{model.isDefault && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.serverDefaultModel
|
||||||
|
}
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.default
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ModelSelectorItem>
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
/>
|
</ModelSelectorGroup>
|
||||||
<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 */}
|
{/* User Models Section */}
|
||||||
{serverModels.length > 0 && (
|
{userModels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
{serverModels.length > 0 && (
|
||||||
icon={<Monitor />}
|
<ModelSelectorSeparator />
|
||||||
label={
|
)}
|
||||||
dict.modelConfig
|
<ModelSelectorSectionHeader
|
||||||
.serverModels
|
icon={<User />}
|
||||||
}
|
label={dict.modelConfig.userModels}
|
||||||
/>
|
/>
|
||||||
{renderGroups(
|
{Array.from(
|
||||||
groupedServerModels,
|
groupedUserModels.entries(),
|
||||||
"server",
|
).map(
|
||||||
(model) =>
|
([
|
||||||
model.isDefault && (
|
providerLabel,
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict.modelConfig
|
|
||||||
.serverDefaultModel
|
|
||||||
}
|
|
||||||
className="ml-auto text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dict.modelConfig
|
|
||||||
.default
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Models Section */}
|
|
||||||
{userModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
{serverModels.length > 0 && (
|
|
||||||
<CommandSeparator />
|
|
||||||
)}
|
|
||||||
<SectionHeader
|
|
||||||
icon={<User />}
|
|
||||||
label={
|
|
||||||
dict.modelConfig.userModels
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{renderGroups(
|
|
||||||
groupedUserModels,
|
|
||||||
"user",
|
|
||||||
(model) =>
|
|
||||||
model.validated !==
|
|
||||||
true && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto shrink-0 pl-2 text-xs",
|
|
||||||
model.validated ===
|
|
||||||
false
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{model.validated ===
|
|
||||||
false
|
|
||||||
? dict
|
|
||||||
.modelConfig
|
|
||||||
.modelFailed
|
|
||||||
: dict
|
|
||||||
.modelConfig
|
|
||||||
.modelUntested}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ModelList>
|
|
||||||
</div>
|
|
||||||
{/* 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
|
provider,
|
||||||
.addProviderEntry
|
models: providerModels,
|
||||||
}
|
},
|
||||||
</span>
|
]) => (
|
||||||
</button>
|
<ModelSelectorGroup
|
||||||
)}
|
key={`user-${providerLabel}`}
|
||||||
{onConfigure && (
|
heading={providerLabel}
|
||||||
<button
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
type="button"
|
>
|
||||||
onClick={() =>
|
{providerModels.map(
|
||||||
goToSettings(onConfigure)
|
(model) => (
|
||||||
}
|
<ModelSelectorItem
|
||||||
className={footerButton}
|
key={model.id}
|
||||||
>
|
value={model.id}
|
||||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
keywords={[
|
||||||
<span className="truncate">
|
model.modelId,
|
||||||
{
|
providerLabel,
|
||||||
dict.modelConfig
|
]}
|
||||||
.configureModels
|
onSelect={() =>
|
||||||
}
|
handleSelect(
|
||||||
</span>
|
model.id,
|
||||||
</button>
|
)
|
||||||
)}
|
}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</Command>
|
{/* Pinned footer: add a provider, configure models
|
||||||
</DialogContent>
|
(z-10 above list shadow). Buttons, outside the
|
||||||
</Dialog>
|
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>
|
||||||
|
</ModelSelectorContent>
|
||||||
|
</ModelSelectorRoot>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ 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
|
||||||
@@ -65,6 +68,9 @@ 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
|
||||||
@@ -85,9 +91,11 @@ export function ProviderCredentialsFields({
|
|||||||
name,
|
name,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
|
bedrockApiKey,
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
renderSecret,
|
renderSecret,
|
||||||
|
footer,
|
||||||
settingsLayout,
|
settingsLayout,
|
||||||
clearSecrets,
|
clearSecrets,
|
||||||
bedrockFilled,
|
bedrockFilled,
|
||||||
@@ -261,8 +269,10 @@ export function ProviderCredentialsFields({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// EdgeOne needs no credentials
|
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||||
if (provider === "edgeone") return null
|
if (provider === "edgeone") {
|
||||||
|
return <div className="space-y-5">{footer}</div>
|
||||||
|
}
|
||||||
|
|
||||||
if (settingsLayout) {
|
if (settingsLayout) {
|
||||||
return (
|
return (
|
||||||
@@ -437,6 +447,7 @@ export function ProviderCredentialsFields({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -447,6 +458,26 @@ 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
|
||||||
@@ -504,6 +535,8 @@ export function ProviderCredentialsFields({
|
|||||||
{baseUrlField}
|
{baseUrlField}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { GitHubIcon } from "@/components/icons/github"
|
import { FaGithub } from "react-icons/fa"
|
||||||
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,10 +28,16 @@ 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
|
||||||
? dict.quota.messageToken
|
? isSelfHosted
|
||||||
: dict.quota.messageApi
|
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
||||||
|
: dict.quota.messageToken
|
||||||
|
: isSelfHosted
|
||||||
|
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
||||||
|
: dict.quota.messageApi
|
||||||
|
|
||||||
const tipHtml = isSelfHosted ? dict.quota.tipSelfHosted : dict.quota.tip
|
const tipHtml = isSelfHosted
|
||||||
|
? (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") {
|
||||||
@@ -46,7 +52,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-float animate-message-in"
|
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in"
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
@@ -109,7 +115,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"
|
||||||
>
|
>
|
||||||
<GitHubIcon className="w-3.5 h-3.5" />
|
<FaGithub className="w-3.5 h-3.5" />
|
||||||
{dict.quota.selfHost}
|
{dict.quota.selfHost}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Github,
|
||||||
Info,
|
Info,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -12,7 +13,6 @@ 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`
|
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
||||||
|
|
||||||
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"
|
||||||
>
|
>
|
||||||
<GitHubIcon className="size-3.5" />
|
<Github className="size-3.5" />
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
{showAbout && (
|
{showAbout && (
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ 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>) {
|
||||||
@@ -136,6 +144,9 @@ function AlertDialogCancel({
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ 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,
|
||||||
@@ -22,6 +29,32 @@ 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
|
||||||
@@ -131,12 +164,30 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ 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
|
||||||
@@ -59,7 +65,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-pop",
|
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||||
// 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",
|
||||||
@@ -145,10 +151,13 @@ function DialogDescription({
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,10 @@ function PopoverContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent }
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
|
|||||||
@@ -55,4 +55,4 @@ function ScrollBar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ScrollArea }
|
export { ScrollArea, ScrollBar }
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ 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>) {
|
||||||
@@ -81,6 +87,19 @@ 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,
|
||||||
@@ -105,6 +124,19 @@ 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
|
||||||
@@ -144,7 +176,12 @@ function SelectScrollDownButton({
|
|||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ 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 */
|
||||||
@@ -22,7 +24,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
{
|
{
|
||||||
label,
|
label,
|
||||||
shortcut,
|
shortcut,
|
||||||
|
active = false,
|
||||||
tooltipSide = "bottom",
|
tooltipSide = "bottom",
|
||||||
|
size = "md",
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
@@ -36,12 +40,17 @@ 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 size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
"inline-flex 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}
|
||||||
@@ -64,3 +73,8 @@ 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 />
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
+146
-117
@@ -78,16 +78,20 @@ 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>
|
||||||
// The diagram's latest SVG export, a fallback thumbnail for saves
|
latestSvg: string
|
||||||
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
|
||||||
/** The XML of the page on screen, exported from draw.io */
|
// Returns the export's tag (empty when draw.io is not there yet)
|
||||||
fetchDiagramXml: () => Promise<string>
|
handleExport: () => string
|
||||||
|
// Pending exports by tag; a plain export's resolver gets the XML of the
|
||||||
|
// page on screen
|
||||||
|
exportResolversRef: React.MutableRefObject<
|
||||||
|
Record<string, (data: string, xml?: string) => void>
|
||||||
|
>
|
||||||
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
|
||||||
@@ -97,7 +101,7 @@ interface DiagramContextType {
|
|||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
successMessage?: string,
|
successMessage?: string,
|
||||||
) => Promise<void>
|
) => 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>
|
||||||
@@ -131,11 +135,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" | "fetch" | "save"
|
type ExportTag = "thumbnail" | "validation" | "version"
|
||||||
|
|
||||||
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 latestSvgRef = useRef("")
|
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||||
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)
|
||||||
@@ -148,7 +152,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, (result: ExportResult) => void>
|
Record<string, (data: string, xml?: string) => void>
|
||||||
>({})
|
>({})
|
||||||
const exportSeqRef = useRef(0)
|
const exportSeqRef = useRef(0)
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
@@ -213,15 +217,26 @@ 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<ExportResult | null>((resolve) => {
|
new Promise<string | null>((resolve) => {
|
||||||
const id = `${tag}-${++exportSeqRef.current}`
|
const id = `${tag}-${++exportSeqRef.current}`
|
||||||
const finish = (value: ExportResult | null) => {
|
const finish = (value: string | null) => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
delete exportResolversRef.current[id]
|
delete exportResolversRef.current[id]
|
||||||
resolve(value)
|
resolve(value)
|
||||||
@@ -234,18 +249,20 @@ 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 effect depends on it
|
// One identity: the chat's auto-save depends on it, and each thumbnail
|
||||||
|
// renders this provider again (latestSvg), which would otherwise start
|
||||||
|
// the next save
|
||||||
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 result = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||||
if (result?.data?.startsWith("data:image/svg")) {
|
if (svgData?.startsWith("data:image/svg")) {
|
||||||
// Kept for later saves that take no fresh thumbnail
|
// Update latestSvg so it's available for future saves
|
||||||
latestSvgRef.current = result.data
|
setLatestSvg(svgData)
|
||||||
return result.data
|
return svgData
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}, [requestTaggedExport])
|
}, [requestTaggedExport])
|
||||||
@@ -253,8 +270,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 result = await requestTaggedExport("version", "svg", 4000)
|
const svgData = await requestTaggedExport("version", "svg", 4000)
|
||||||
return result?.data?.startsWith("data:image/svg") ? result.data : null
|
return svgData?.startsWith("data:image/svg") ? svgData : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current diagram as PNG for VLM validation
|
// Capture current diagram as PNG for VLM validation
|
||||||
@@ -263,27 +280,9 @@ 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 result = await requestTaggedExport("validation", "png", 5000)
|
const pngData = await requestTaggedExport("validation", "png", 5000)
|
||||||
// PNG data should be a base64 data URL
|
// PNG data should be a base64 data URL
|
||||||
return result?.data?.startsWith("data:image/png") ? result.data : null
|
return pngData?.startsWith("data:image/png") ? pngData : 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
|
||||||
@@ -302,7 +301,8 @@ 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] = finish
|
exportResolversRef.current[id] = (data, xml) =>
|
||||||
|
finish({ data, xml })
|
||||||
drawioRef.current.exportDiagram({ ...request, message: id })
|
drawioRef.current.exportDiagram({ ...request, message: id })
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@@ -406,11 +406,34 @@ 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 (!tag) return
|
if (/^(thumbnail|validation|version|save|export)-/.test(tag ?? "")) {
|
||||||
exportResolversRef.current[tag]?.({ data: data.data, xml: data.xml })
|
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't write chartXML here: exports don't change the diagram, and
|
||||||
|
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||||
|
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||||
|
// up to date with the full uncompressed multi-page document (#879).
|
||||||
|
// The chat gets the page the user is viewing (the first one with
|
||||||
|
// an external draw.io, which cannot tell us)
|
||||||
|
const extractedXML = extractDiagramXML(
|
||||||
|
data.data,
|
||||||
|
useCanvasStore.getState().currentPageId,
|
||||||
|
)
|
||||||
|
setLatestSvg(data.data)
|
||||||
|
|
||||||
|
// The chat's own export (onFetchChart), not another one in flight
|
||||||
|
const resolve =
|
||||||
|
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
||||||
|
if (resolve) {
|
||||||
|
delete exportResolversRef.current[tag as string]
|
||||||
|
resolve(extractedXML)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The frame registers this callback once per iframe mount, so it must
|
// The frame registers this callback once per iframe mount, so it must
|
||||||
@@ -436,10 +459,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)
|
||||||
latestSvgRef.current = ""
|
setLatestSvg("")
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDiagramToFile = async (
|
const saveDiagramToFile = (
|
||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
@@ -454,80 +477,85 @@ 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. A
|
// Each save has its own tag, so two at once never swap results
|
||||||
// large PNG takes a while to render.
|
const tag = `save-${++exportSeqRef.current}`
|
||||||
const result = await requestTaggedExport("save", drawioFormat, 30000)
|
exportResolversRef.current[tag] = (
|
||||||
if (!result?.data) {
|
exportData: string,
|
||||||
console.warn("Diagram export for saving timed out")
|
fullDiagramXML?: string,
|
||||||
return
|
) => {
|
||||||
}
|
delete exportResolversRef.current[tag]
|
||||||
const exportData = result.data
|
let fileContent: string | Blob
|
||||||
const fullDiagramXML = result.xml
|
let mimeType: string
|
||||||
let fileContent: string | Blob
|
let extension: string
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (flags the trace)
|
// Export diagram - callback will be handled in handleDiagramExport
|
||||||
logSaveToLangfuse(filename, format, sessionId)
|
drawioRef.current.exportDiagram({
|
||||||
|
format: drawioFormat,
|
||||||
// Handle download
|
message: tag,
|
||||||
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)
|
||||||
@@ -558,9 +586,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
chartXML,
|
chartXML,
|
||||||
chartXMLRef,
|
chartXMLRef,
|
||||||
latestSvgRef,
|
latestSvg,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
fetchDiagramXml,
|
handleExport,
|
||||||
|
exportResolversRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
handleDiagramAutoSave,
|
||||||
|
|||||||
@@ -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`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
|
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
||||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
||||||
|
|
||||||
### 说明
|
### 说明
|
||||||
|
|||||||
@@ -68,15 +68,47 @@ cp env.example .env.local
|
|||||||
|
|
||||||
## 第三步 — 选择部署类型
|
## 第三步 — 选择部署类型
|
||||||
|
|
||||||
### 选项 A:不使用 R2 部署(默认,免费)
|
### 选项 A:不使用 R2 部署(简单,免费)
|
||||||
|
|
||||||
仓库自带的就是这套配置:`open-next.config.ts` 为 `defineCloudflareConfig({})`,`wrangler.jsonc` 中没有 `r2_buckets`。无需改动任何文件,直接跳至 **第四步**。
|
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
|
||||||
|
|
||||||
|
**1. 使用简单的 `open-next.config.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({})
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 使用简单的 `wrangler.jsonc`(不包含 r2_buckets):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "next-ai-draw-io-worker",
|
||||||
|
"compatibility_date": "2025-12-08",
|
||||||
|
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "next-ai-draw-io-worker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
直接跳至 **第四步**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 选项 B:使用 R2 部署(可选,用于 ISR 缓存)
|
### 选项 B:使用 R2 部署(完整的 ISR 支持)
|
||||||
|
|
||||||
R2 为 OpenNext 提供存放 **增量静态再生 (ISR)** 页面的空间。本应用目前没有 ISR 页面,只有在您自己添加了此类页面时才需要这一步。需要在您的 Cloudflare 账户中绑定支付方式。
|
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
|
||||||
|
|
||||||
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
||||||
|
|
||||||
@@ -95,7 +127,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. 在 `wrangler.jsonc` 中加入 `r2_buckets`:**
|
**3. 配置 `wrangler.jsonc`(包含 R2):**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -125,8 +157,6 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
||||||
|
|
||||||
> **注意:** 定时重新验证(页面或 fetch 上的 `revalidate`)还需要在 `open-next.config.ts` 中配置 `queue`,请参阅 OpenNext 的 Cloudflare 缓存文档。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
||||||
@@ -234,4 +264,4 @@ npm run preview
|
|||||||
| API 路由 | 支持 | 支持 |
|
| API 路由 | 支持 | 支持 |
|
||||||
| 配置复杂度 | 简单 | 中等 |
|
| 配置复杂度 | 简单 | 中等 |
|
||||||
|
|
||||||
仓库默认 **不使用 R2**。只有当您添加了需要 ISR 缓存的页面时,才选择 **使用 R2**。
|
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。
|
||||||
|
|||||||
@@ -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`). May also be an array of env var names: each request picks one that has a value at random, spreading load over several keys |
|
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
||||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
| `baseUrlEnv` | No | Custom base URL env var name |
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|||||||
@@ -68,15 +68,47 @@ Fill in the same values there.
|
|||||||
|
|
||||||
## Step 3 — Choose your deployment type
|
## Step 3 — Choose your deployment type
|
||||||
|
|
||||||
### Option A: Deploy WITHOUT R2 (Default, Free)
|
### Option A: Deploy WITHOUT R2 (Simple, Free)
|
||||||
|
|
||||||
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**.
|
If you don't need ISR caching, you can deploy without R2:
|
||||||
|
|
||||||
|
**1. Use simple `open-next.config.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({})
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "next-ai-draw-io-worker",
|
||||||
|
"compatibility_date": "2025-12-08",
|
||||||
|
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "next-ai-draw-io-worker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip to **Step 4**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Option B: Deploy WITH R2 (Optional, ISR caching)
|
### Option B: Deploy WITH R2 (Full ISR Support)
|
||||||
|
|
||||||
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.
|
R2 enables **Incremental Static Regeneration (ISR)** caching. 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:
|
||||||
|
|
||||||
@@ -95,7 +127,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Add `r2_buckets` to `wrangler.jsonc`:**
|
**3. Configure `wrangler.jsonc` (with R2):**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -125,8 +157,6 @@ 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)
|
||||||
@@ -234,4 +264,4 @@ npm run preview
|
|||||||
| API Routes | Yes | Yes |
|
| API Routes | Yes | Yes |
|
||||||
| Setup Complexity | Simple | Moderate |
|
| Setup Complexity | Simple | Moderate |
|
||||||
|
|
||||||
The repository defaults to **without R2**. Choose **with R2** only if you add pages that need ISR caching.
|
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
|
||||||
|
|||||||
@@ -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` などの標準変数)。環境変数名の配列も指定でき、リクエストごとに値のある変数から1つがランダムに選ばれます(複数キーへの負荷分散) |
|
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
||||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
||||||
|
|
||||||
### 備考
|
### 備考
|
||||||
|
|||||||
@@ -68,15 +68,47 @@ cp env.example .env.local
|
|||||||
|
|
||||||
## ステップ 3 — デプロイタイプの選択
|
## ステップ 3 — デプロイタイプの選択
|
||||||
|
|
||||||
### オプション A: R2 なしでのデプロイ(デフォルト、無料)
|
### オプション A: R2 なしでのデプロイ(シンプル、無料)
|
||||||
|
|
||||||
リポジトリはこの構成のままです。`open-next.config.ts` は `defineCloudflareConfig({})` で、`wrangler.jsonc` に `r2_buckets` はありません。ファイルを変更する必要はないので、**ステップ 4** へ進んでください。
|
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
|
||||||
|
|
||||||
|
**1. シンプルな `open-next.config.ts` を使用:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({})
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. シンプルな `wrangler.jsonc` を使用(r2_buckets なし):**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "next-ai-draw-io-worker",
|
||||||
|
"compatibility_date": "2025-12-08",
|
||||||
|
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "next-ai-draw-io-worker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ステップ 4** へ進んでください。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### オプション B: R2 ありでのデプロイ(オプション、ISR キャッシュ用)
|
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
|
||||||
|
|
||||||
R2 は OpenNext が **Incremental Static Regeneration (ISR)** ページを保存する場所になります。このアプリには現在 ISR ページがないため、自分で ISR ページを追加した場合にだけ必要です。Cloudflare アカウントに支払い方法の登録が必要です。
|
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
|
||||||
|
|
||||||
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
||||||
|
|
||||||
@@ -95,7 +127,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. `wrangler.jsonc` に `r2_buckets` を追加:**
|
**3. `wrangler.jsonc` の設定(R2 あり):**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -125,8 +157,6 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
||||||
|
|
||||||
> **注意:** 時間ベースの再検証(ページや fetch の `revalidate`)には、`open-next.config.ts` で `queue` の設定も必要です。OpenNext の Cloudflare キャッシュドキュメントを参照してください。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
||||||
@@ -234,4 +264,4 @@ npm run preview
|
|||||||
| API ルート | あり | あり |
|
| API ルート | あり | あり |
|
||||||
| 設定の複雑さ | シンプル | 普通 |
|
| 設定の複雑さ | シンプル | 普通 |
|
||||||
|
|
||||||
リポジトリのデフォルトは **R2 なし** です。ISR キャッシュが必要なページを追加した場合にだけ **R2 あり** を選んでください。
|
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ 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),
|
||||||
@@ -37,6 +36,21 @@ 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()
|
||||||
|
|
||||||
@@ -47,6 +61,12 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -125,16 +145,8 @@ export async function onRequest({ request, env }: any) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stream is taken out so it is not passed on: AI.chatCompletions
|
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
||||||
// only streams, and every caller reads a stream
|
parseResult.data
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
model,
|
|
||||||
stream: _,
|
|
||||||
tools,
|
|
||||||
tool_choice,
|
|
||||||
...extraParams
|
|
||||||
} = parseResult.data
|
|
||||||
|
|
||||||
// Validate messages
|
// Validate messages
|
||||||
const userMessages = messages.filter(
|
const userMessages = messages.filter(
|
||||||
@@ -153,13 +165,14 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve model
|
// Resolve model
|
||||||
const selectedModel = model || ALLOWED_MODELS[0]
|
const requestedModel = 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: ${selectedModel}.`,
|
message: `Invalid model: ${requestedModel}.`,
|
||||||
type: "invalid_request_error",
|
type: "invalid_request_error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -168,10 +181,39 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}`,
|
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
Vendored
+69
-3
@@ -2,6 +2,29 @@
|
|||||||
* 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
|
||||||
@@ -12,6 +35,7 @@ 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 */
|
||||||
@@ -24,17 +48,31 @@ 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 (proxy
|
* Call back after the server restarted on the same port (another
|
||||||
* change); returns a function that stops the calls
|
* preset); 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 */
|
||||||
@@ -42,7 +80,35 @@ 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 { ProxyConfig }
|
export type {
|
||||||
|
ApplyPresetResult,
|
||||||
|
ConfigPreset,
|
||||||
|
ProxyConfig,
|
||||||
|
SetProxyResult,
|
||||||
|
SetUserLocaleResult,
|
||||||
|
}
|
||||||
|
|||||||
+161
-2
@@ -1,9 +1,23 @@
|
|||||||
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
import {
|
||||||
|
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()
|
||||||
@@ -11,6 +25,75 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +113,15 @@ 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
|
||||||
@@ -45,6 +137,19 @@ 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
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -91,6 +196,9 @@ 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,
|
||||||
@@ -131,3 +239,54 @@ 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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+551
-38
@@ -1,57 +1,570 @@
|
|||||||
import { app } from "electron"
|
import { randomUUID } from "node:crypto"
|
||||||
import * as fs from "fs"
|
import {
|
||||||
import * as path from "path"
|
existsSync,
|
||||||
|
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
|
/**
|
||||||
export type UserLocale = (typeof USER_LOCALES)[number]
|
* Fields that contain sensitive data and should be encrypted
|
||||||
|
*/
|
||||||
|
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
|
||||||
|
|
||||||
// Earlier versions kept their configuration presets in this file too; the
|
/**
|
||||||
// name stays so the saved language survives the upgrade
|
* Prefix to identify encrypted values
|
||||||
const CONFIG_FILE = "config-presets.json"
|
*/
|
||||||
|
const ENCRYPTED_PREFIX = "encrypted:"
|
||||||
|
|
||||||
function getConfigPath(): string {
|
/**
|
||||||
return path.join(app.getPath("userData"), CONFIG_FILE)
|
* Check if safeStorage encryption is available
|
||||||
|
*/
|
||||||
|
function isEncryptionAvailable(): boolean {
|
||||||
|
return safeStorage.isEncryptionAvailable()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the config file as an object ({} if it is missing or invalid)
|
* Track if we've already warned about plaintext storage
|
||||||
*/
|
*/
|
||||||
function loadConfig(): Record<string, unknown> {
|
let hasWarnedAboutPlaintext = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a sensitive value using safeStorage
|
||||||
|
* Warns if encryption is not available (API key stored in plaintext)
|
||||||
|
*/
|
||||||
|
function encryptValue(value: string): string {
|
||||||
|
// Already encrypted (a value that could not be decrypted): keep it as is
|
||||||
|
// instead of wrapping it in a second layer of encryption
|
||||||
|
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEncryptionAvailable()) {
|
||||||
|
if (!hasWarnedAboutPlaintext) {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ SECURITY WARNING: safeStorage not available. " +
|
||||||
|
"API keys will be stored in PLAINTEXT. " +
|
||||||
|
"On Linux, install gnome-keyring or similar for secure storage.",
|
||||||
|
)
|
||||||
|
hasWarnedAboutPlaintext = true
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configPath = getConfigPath()
|
const encrypted = safeStorage.encryptString(value)
|
||||||
if (fs.existsSync(configPath)) {
|
return ENCRYPTED_PREFIX + encrypted.toString("base64")
|
||||||
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
|
||||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
console.error("Ignoring invalid config file:", data)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load config file:", error)
|
console.error("Encryption failed:", error)
|
||||||
|
// Fail secure: don't store if encryption fails
|
||||||
|
throw new Error(
|
||||||
|
"Failed to encrypt API key. Cannot securely store credentials.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu locale the user chose, if any
|
* 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)
|
||||||
*/
|
*/
|
||||||
export function getUserLocale(): UserLocale | undefined {
|
function decryptValue(value: string): string {
|
||||||
const { userLocale } = loadConfig()
|
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||||
return (USER_LOCALES as readonly unknown[]).includes(userLocale)
|
return value
|
||||||
? (userLocale as UserLocale)
|
}
|
||||||
: undefined
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the menu locale the user chose
|
* Encrypt sensitive fields in a config object
|
||||||
*/
|
*/
|
||||||
export function setUserLocale(locale: UserLocale): void {
|
function encryptConfig(
|
||||||
const config = { ...loadConfig(), userLocale: locale }
|
config: Record<string, string | undefined>,
|
||||||
const configPath = getConfigPath()
|
): Record<string, string | undefined> {
|
||||||
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
const encrypted = { ...config }
|
||||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
for (const field of SENSITIVE_FIELDS) {
|
||||||
// a truncated file
|
if (encrypted[field]) {
|
||||||
const tempPath = `${configPath}.tmp`
|
encrypted[field] = encryptValue(encrypted[field] as string)
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
}
|
||||||
fs.renameSync(tempPath, configPath)
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values that env vars had before a preset first set them
|
||||||
|
* (from the system or .env files), and the keys the active preset set
|
||||||
|
*/
|
||||||
|
const originalEnv: Record<string, string | undefined> = {}
|
||||||
|
let presetEnvKeys: string[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the env vars of the previous preset with those of the given preset
|
||||||
|
* (null leaves no preset applied). Restoring first means switching presets
|
||||||
|
* never leaves the previous preset's base URL, model or key behind.
|
||||||
|
*/
|
||||||
|
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
||||||
|
for (const key of presetEnvKeys) {
|
||||||
|
if (originalEnv[key] === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = originalEnv[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = preset ? presetToEnv(preset) : {}
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (!(key in originalEnv)) {
|
||||||
|
originalEnv[key] = process.env[key]
|
||||||
|
}
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
presetEnvKeys = Object.keys(env)
|
||||||
|
|
||||||
|
writeDevPresetEnv(env)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development only: write the active preset's env vars (decrypted and mapped)
|
||||||
|
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
||||||
|
* this file changes. The dev server can't decrypt the config file itself.
|
||||||
|
*/
|
||||||
|
function writeDevPresetEnv(env: Record<string, string>): void {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
||||||
|
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
||||||
|
encoding: "utf-8",
|
||||||
|
mode: 0o600,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to write dev preset env:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply preset environment variables to the current process
|
||||||
|
* Returns the environment variables that were applied
|
||||||
|
*/
|
||||||
|
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||||
|
const data = loadPresets()
|
||||||
|
const preset = data.presets.find((p) => p.id === id)
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedEnv = setPresetEnv(preset)
|
||||||
|
|
||||||
|
// Set as current preset
|
||||||
|
data.currentPresetId = id
|
||||||
|
savePresets(data)
|
||||||
|
|
||||||
|
return appliedEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the saved current preset's environment variables (used at startup)
|
||||||
|
*/
|
||||||
|
export function applyCurrentPresetToEnv(): void {
|
||||||
|
setPresetEnv(getCurrentPreset())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's preferred locale from config
|
||||||
|
* Returns undefined if not set
|
||||||
|
*/
|
||||||
|
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
||||||
|
const data = loadPresets()
|
||||||
|
return data.userLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user's preferred locale in config
|
||||||
|
*/
|
||||||
|
export function setUserLocale(
|
||||||
|
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
||||||
|
): void {
|
||||||
|
const data = loadPresets()
|
||||||
|
data.userLocale = locale === null ? undefined : locale
|
||||||
|
savePresets(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,13 +28,69 @@ 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 parsed = parse(fs.readFileSync(filePath, "utf-8"))
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
const lines = content.split("\n")
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
|
||||||
|
const equalIndex = trimmed.indexOf("=")
|
||||||
|
if (equalIndex === -1) continue
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, equalIndex).trim()
|
||||||
|
let value = trimmed.slice(equalIndex + 1).trim()
|
||||||
|
|
||||||
|
const quote = value[0]
|
||||||
|
const closingQuote =
|
||||||
|
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
||||||
|
if (
|
||||||
|
closingQuote > 0 &&
|
||||||
|
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
||||||
|
) {
|
||||||
|
// Quoted value, then nothing or a comment: keep what is
|
||||||
|
// inside the quotes, as dotenv reads it
|
||||||
|
value = value.slice(1, closingQuote)
|
||||||
|
} else {
|
||||||
|
// Unquoted value: drop an inline comment ("value # comment").
|
||||||
|
// A value quoted from start to end with quotes inside (JSON
|
||||||
|
// with an apostrophe) loses only the outer two, as in dotenv.
|
||||||
|
value = value.replace(/\s+#.*$/, "")
|
||||||
|
if (
|
||||||
|
closingQuote > 0 &&
|
||||||
|
value.length > 1 &&
|
||||||
|
value.endsWith(quote)
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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,
|
||||||
@@ -34,8 +36,14 @@ 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()
|
||||||
|
|||||||
+171
-11
@@ -1,6 +1,17 @@
|
|||||||
import { app, type IpcMainInvokeEvent, ipcMain } from "electron"
|
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||||
import { setUserLocale, USER_LOCALES, type UserLocale } from "./config-manager"
|
import {
|
||||||
|
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 {
|
||||||
@@ -12,9 +23,37 @@ import {
|
|||||||
import { isAppUrl } from "./window-manager"
|
import { isAppUrl } from "./window-manager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register an IPC handler that only answers the app's own pages (on the app
|
* Allowed configuration keys for presets
|
||||||
* server). A main window that somehow ends up on an external site still
|
* This whitelist prevents arbitrary environment variable injection
|
||||||
* 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,
|
||||||
@@ -22,7 +61,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)) {
|
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
||||||
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))
|
||||||
@@ -33,6 +72,12 @@ 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
|
||||||
@@ -42,6 +87,116 @@ 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", () => {
|
||||||
@@ -73,7 +228,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 }
|
return { success: true, devMode: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: restart Next.js server to pick up new env vars
|
// Production: restart Next.js server to pick up new env vars
|
||||||
@@ -92,15 +247,20 @@ 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) => {
|
||||||
if (!(USER_LOCALES as readonly string[]).includes(locale)) {
|
// Validate locale is one of the supported values
|
||||||
|
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 UserLocale)
|
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
||||||
// Rebuild the menu to reflect the new locale
|
// Rebuild the menu to reflect the new locale
|
||||||
buildAppMenu()
|
rebuildAppMenu()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+113
-27
@@ -3,35 +3,71 @@
|
|||||||
* 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, type UserLocale } from "./config-manager"
|
import { getUserLocale } from "./config-manager"
|
||||||
|
|
||||||
export type MenuLocale = UserLocale
|
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
||||||
|
|
||||||
|
export interface MenuTranslations {
|
||||||
|
// App menu (macOS only)
|
||||||
|
settings: string
|
||||||
|
|
||||||
const en = {
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "File",
|
file: string
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
edit: "Edit",
|
edit: string
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
view: "View",
|
view: string
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: string
|
||||||
|
switchPreset: string
|
||||||
|
managePresets: string
|
||||||
|
addConfigurationPreset: string
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "Window",
|
window: string
|
||||||
|
|
||||||
// Help menu
|
// Help menu
|
||||||
help: "Help",
|
help: string
|
||||||
documentation: "Documentation",
|
documentation: string
|
||||||
reportIssue: "Report Issue",
|
reportIssue: string
|
||||||
}
|
}
|
||||||
|
|
||||||
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: "文件",
|
||||||
|
|
||||||
@@ -41,6 +77,12 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "查看",
|
view: "查看",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "配置",
|
||||||
|
switchPreset: "切换预设",
|
||||||
|
managePresets: "管理预设...",
|
||||||
|
addConfigurationPreset: "添加配置预设...",
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "窗口",
|
window: "窗口",
|
||||||
|
|
||||||
@@ -51,6 +93,9 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ja: {
|
ja: {
|
||||||
|
// App menu
|
||||||
|
settings: "設定...",
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "ファイル",
|
file: "ファイル",
|
||||||
|
|
||||||
@@ -60,6 +105,12 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "表示",
|
view: "表示",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "設定",
|
||||||
|
switchPreset: "プリセット切り替え",
|
||||||
|
managePresets: "プリセット管理...",
|
||||||
|
addConfigurationPreset: "設定プリセットを追加...",
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "ウインドウ",
|
window: "ウインドウ",
|
||||||
|
|
||||||
@@ -70,6 +121,9 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"zh-Hant": {
|
"zh-Hant": {
|
||||||
|
// App menu
|
||||||
|
settings: "設定...",
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "檔案",
|
file: "檔案",
|
||||||
|
|
||||||
@@ -79,6 +133,12 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "檢視",
|
view: "檢視",
|
||||||
|
|
||||||
|
// Configuration menu
|
||||||
|
configuration: "配置",
|
||||||
|
switchPreset: "切換預設",
|
||||||
|
managePresets: "管理預設...",
|
||||||
|
addConfigurationPreset: "新增配置預設...",
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "視窗",
|
window: "視窗",
|
||||||
|
|
||||||
@@ -90,22 +150,33 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get menu translations for a locale (English for an unknown one)
|
* Get menu translations for a given locale
|
||||||
|
* Falls back to English if locale is not supported
|
||||||
*/
|
*/
|
||||||
export function getMenuTranslations(locale: MenuLocale): MenuTranslations {
|
export function getMenuTranslations(locale: string): MenuTranslations {
|
||||||
return translations[locale] ?? translations.en
|
// Check for zh-Hant before normalizing
|
||||||
|
if (
|
||||||
|
locale === "zh-Hant" ||
|
||||||
|
locale.toLowerCase().startsWith("zh-hant") ||
|
||||||
|
locale.toLowerCase().startsWith("zh-tw") ||
|
||||||
|
locale.toLowerCase().startsWith("zh-hk")
|
||||||
|
) {
|
||||||
|
return translations["zh-Hant"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
||||||
|
const normalized = locale.toLowerCase().split("-")[0]
|
||||||
|
|
||||||
|
if (normalized === "zh") return translations.zh
|
||||||
|
if (normalized === "ja") return translations.ja
|
||||||
|
return translations.en
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get locale from stored preference or system default
|
* Detect system locale from Electron app
|
||||||
* Checks config file for user's language preference first
|
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
||||||
*/
|
*/
|
||||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
export function detectSystemLocale(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
|
||||||
@@ -117,9 +188,24 @@ export function getPreferredLocale(appLocale: string): MenuLocale {
|
|||||||
return "zh-Hant"
|
return "zh-Hant"
|
||||||
}
|
}
|
||||||
|
|
||||||
const language = lower.split("-")[0]
|
const normalized = lower.split("-")[0]
|
||||||
|
|
||||||
if (language === "zh") return "zh"
|
if (normalized === "zh") return "zh"
|
||||||
if (language === "ja") return "ja"
|
if (normalized === "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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// proxy changes) can't leave two servers running
|
// preset switches) 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,6 +21,18 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -49,8 +61,7 @@ export function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer(): Promise<string> {
|
async function startServer(): Promise<string> {
|
||||||
// The standalone server shipped in the packaged app's resources
|
const resourcePath = getResourcePath()
|
||||||
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}`)
|
||||||
@@ -64,7 +75,7 @@ async function startServer(): Promise<string> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find an available port (the fixed ports 61337 / 13370, then fallbacks)
|
// Find an available port (random in production, fixed in development)
|
||||||
const port = await findAvailablePort()
|
const port = await findAvailablePort()
|
||||||
console.log(`Using port: ${port}`)
|
console.log(`Using port: ${port}`)
|
||||||
|
|
||||||
@@ -75,7 +86,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 local server's env keys are the user's own, not a shared server's
|
// The preset keys are the user's own, not a server's
|
||||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +97,10 @@ 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
|
||||||
env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache")
|
if (app.isPackaged) {
|
||||||
|
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)) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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)
|
||||||
@@ -106,8 +108,9 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - Uses the legacy port (61337), then 13370, to preserve localStorage;
|
* - In development: uses fixed port (6002)
|
||||||
* 13370 first when only it has saved chats
|
* - In production: uses the legacy port (61337), then 13370, to preserve
|
||||||
|
* localStorage; 13370 first when only it has saved chats
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - 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)
|
||||||
*
|
*
|
||||||
@@ -115,7 +118,10 @@ 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 preferredPort = PORT_CONFIG.production
|
const isDev = !app.isPackaged
|
||||||
|
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) {
|
||||||
@@ -129,18 +135,19 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// First the port where a chat was last saved. Without one, the legacy
|
// In production, first the port where a chat was last saved. Without
|
||||||
// port first to preserve existing users' data, unless only the new port
|
// one, the legacy port first to preserve existing users' data, unless
|
||||||
// has data: their app started on 13370 while Windows reserved 61337,
|
// only the new port has data: their app started on 13370 while Windows
|
||||||
// and 61337 being free now would hide it
|
// reserved 61337, and 61337 being free now would hide it
|
||||||
const chatPort = readChatPort()
|
const chatPort = isDev ? null : readChatPort()
|
||||||
const candidates =
|
const candidates = isDev
|
||||||
chatPort !== null
|
? [preferredPort]
|
||||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
: chatPort !== null
|
||||||
: hasStoredData(PORT_CONFIG.production) &&
|
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
: hasStoredData(PORT_CONFIG.production) &&
|
||||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||||
|
: [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
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import { app, BrowserWindow, ipcMain } from "electron"
|
||||||
|
|
||||||
|
let settingsWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and show the settings window
|
||||||
|
*/
|
||||||
|
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
|
||||||
|
// If settings window already exists, focus it
|
||||||
|
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||||
|
settingsWindow.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine path to settings preload script
|
||||||
|
// In compiled output: dist-electron/preload/settings.js
|
||||||
|
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
|
||||||
|
|
||||||
|
// Determine path to settings HTML
|
||||||
|
// In packaged app: app.asar/dist-electron/settings/index.html
|
||||||
|
// In development: electron/settings/index.html
|
||||||
|
const settingsHtmlPath = app.isPackaged
|
||||||
|
? path.join(__dirname, "..", "settings", "index.html")
|
||||||
|
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
|
||||||
|
|
||||||
|
settingsWindow = new BrowserWindow({
|
||||||
|
width: 600,
|
||||||
|
height: 700,
|
||||||
|
minWidth: 500,
|
||||||
|
minHeight: 500,
|
||||||
|
parent: parentWindow,
|
||||||
|
modal: false,
|
||||||
|
show: false,
|
||||||
|
title: "Settings - Next AI Draw.io",
|
||||||
|
webPreferences: {
|
||||||
|
preload: preloadPath,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
settingsWindow.loadFile(settingsHtmlPath)
|
||||||
|
|
||||||
|
settingsWindow.once("ready-to-show", () => {
|
||||||
|
settingsWindow?.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
settingsWindow.on("closed", () => {
|
||||||
|
settingsWindow = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the settings window if it exists
|
||||||
|
*/
|
||||||
|
export function closeSettingsWindow(): void {
|
||||||
|
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||||
|
settingsWindow.close()
|
||||||
|
settingsWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if settings window is open
|
||||||
|
*/
|
||||||
|
export function isSettingsWindowOpen(): boolean {
|
||||||
|
return settingsWindow !== null && !settingsWindow.isDestroyed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register settings window IPC handlers
|
||||||
|
*/
|
||||||
|
export function registerSettingsWindowHandlers(): void {
|
||||||
|
ipcMain.on("settings:close", () => {
|
||||||
|
closeSettingsWindow()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -107,9 +107,9 @@ export function getAppUrl(): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Point the main window at the restarted app server (it can come up on a
|
* Point the main window at the restarted app server (it can come up on a
|
||||||
* different port). On the same port the page is told so it can fetch the
|
* different port). On the same port the page fetches the new preset's
|
||||||
* server's models again; it is not reloaded, which would drop unsent
|
* server models instead of sending the old preset's choice; it is not
|
||||||
* attachments.
|
* reloaded, which would drop unsent attachments.
|
||||||
*/
|
*/
|
||||||
export function setAppUrl(url: string): void {
|
export function setAppUrl(url: string): void {
|
||||||
if (url === appUrl) {
|
if (url === appUrl) {
|
||||||
|
|||||||
@@ -4,15 +4,27 @@ 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),
|
||||||
|
|
||||||
@@ -21,7 +33,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 (proxy change)
|
// The server restarted on the same port (another preset)
|
||||||
onServerRestarted: (callback: () => void) => {
|
onServerRestarted: (callback: () => void) => {
|
||||||
const listener = () => callback()
|
const listener = () => callback()
|
||||||
ipcRenderer.on("server-restarted", listener)
|
ipcRenderer.on("server-restarted", listener)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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"),
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
+5
-18
@@ -8,7 +8,6 @@ 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
|
||||||
|
|
||||||
@@ -29,6 +28,8 @@ 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)
|
||||||
@@ -141,18 +142,6 @@ 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
|
||||||
@@ -167,7 +156,9 @@ 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)
|
||||||
# PDF file upload to extract text and generate diagrams
|
# Enable 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)
|
||||||
@@ -193,10 +184,6 @@ 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
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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 {
|
||||||
@@ -6,7 +5,6 @@ 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"
|
||||||
@@ -16,18 +14,57 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// useChat's addToolOutput, typed with the chat's tools
|
type AddToolOutputSuccess = {
|
||||||
type AddToolOutputFn = ChatAddToolOutputFunction<ChatUIMessage>
|
tool: string
|
||||||
|
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
|
||||||
|
|
||||||
@@ -120,6 +157,12 @@ 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,
|
||||||
@@ -161,9 +204,12 @@ 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. Either way it keeps the file's variables unless it sets its own.
|
// the file, and gets its variables
|
||||||
const onAiPage = (drawnXml: string) =>
|
const onAiPage = (drawnXml: string) =>
|
||||||
placeOnPage(drawnXml, chartXMLRef.current, aiPageId())
|
keepFileVars(
|
||||||
|
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()
|
||||||
@@ -182,8 +228,20 @@ 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
|
||||||
@@ -240,6 +298,11 @@ 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,
|
||||||
@@ -255,6 +318,12 @@ ${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 &&
|
||||||
@@ -279,6 +348,12 @@ ${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
|
||||||
@@ -304,6 +379,12 @@ ${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(
|
||||||
@@ -326,6 +407,11 @@ ${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,
|
||||||
@@ -341,6 +427,12 @@ ${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
|
||||||
@@ -382,11 +474,21 @@ ${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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ export function useDictionary() {
|
|||||||
}
|
}
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default useDictionary
|
||||||
|
|||||||
@@ -18,6 +18,56 @@ 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)
|
||||||
|
|
||||||
@@ -46,6 +96,7 @@ 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 {
|
||||||
@@ -55,6 +106,17 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +163,7 @@ export interface UseModelConfigReturn {
|
|||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
models: FlattenedModel[]
|
models: FlattenedModel[]
|
||||||
|
selectedModel: FlattenedModel | undefined
|
||||||
selectedModelId: string | undefined
|
selectedModelId: string | undefined
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -118,6 +181,7 @@ 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 {
|
||||||
@@ -217,7 +281,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
modelId: m.modelId,
|
modelId: m.modelId,
|
||||||
provider: m.provider,
|
provider: m.provider,
|
||||||
providerLabel: m.providerLabel,
|
providerLabel: `Server · ${m.providerLabel}`,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
baseUrl: undefined,
|
baseUrl: undefined,
|
||||||
awsAccessKeyId: undefined,
|
awsAccessKeyId: undefined,
|
||||||
@@ -227,11 +291,17 @@ 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) => ({
|
||||||
@@ -356,10 +426,15 @@ 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,
|
||||||
@@ -368,6 +443,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
addModel,
|
addModel,
|
||||||
updateModel,
|
updateModel,
|
||||||
deleteModel,
|
deleteModel,
|
||||||
|
resetConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +491,24 @@ 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) return { ...empty, accessCode }
|
if (!stored) {
|
||||||
|
// 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 {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getAllSessionMetadata,
|
getAllSessionMetadata,
|
||||||
getSession,
|
getSession,
|
||||||
isIndexedDBAvailable,
|
isIndexedDBAvailable,
|
||||||
|
migrateFromLocalStorage,
|
||||||
readSessionCount,
|
readSessionCount,
|
||||||
renameSession as renameSessionInDB,
|
renameSession as renameSessionInDB,
|
||||||
type SessionMetadata,
|
type SessionMetadata,
|
||||||
@@ -19,7 +20,10 @@ import {
|
|||||||
saveSession,
|
saveSession,
|
||||||
} from "@/lib/session-storage"
|
} from "@/lib/session-storage"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import type { DiagramVersion } from "@/stores/versions-store"
|
import {
|
||||||
|
type DiagramVersion,
|
||||||
|
versionsFromLegacyHistory,
|
||||||
|
} from "@/stores/versions-store"
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
messages: StoredMessage[]
|
messages: StoredMessage[]
|
||||||
@@ -31,6 +35,13 @@ 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 {
|
||||||
@@ -181,6 +192,9 @@ 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)
|
||||||
@@ -320,7 +334,7 @@ export function useSessionManager(
|
|||||||
xmlSnapshots: session.xmlSnapshots,
|
xmlSnapshots: session.xmlSnapshots,
|
||||||
diagramXml: session.diagramXml,
|
diagramXml: session.diagramXml,
|
||||||
thumbnailDataUrl: session.thumbnailDataUrl,
|
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||||
versions: session.versions ?? [],
|
versions: getSessionVersions(session),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentSessionId, changeChat],
|
[currentSessionId, changeChat],
|
||||||
@@ -403,7 +417,9 @@ export function useSessionManager(
|
|||||||
thumbnailDataUrl:
|
thumbnailDataUrl:
|
||||||
data.thumbnailDataUrl ??
|
data.thumbnailDataUrl ??
|
||||||
currentSession.thumbnailDataUrl,
|
currentSession.thumbnailDataUrl,
|
||||||
versions: data.versions ?? currentSession.versions ?? [],
|
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:
|
||||||
|
|||||||
+115
-45
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for VLM-based diagram validation: one request, one JSON result.
|
* Hook for VLM-based diagram validation using AI SDK's useObject.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -12,6 +13,8 @@ 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,
|
||||||
@@ -19,56 +22,85 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
|||||||
suggestions: [],
|
suggestions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useValidateDiagram() {
|
interface UseValidateDiagramOptions {
|
||||||
// The running check's controller; a new check or cancel() aborts it
|
onSuccess?: (result: ValidationResult) => void
|
||||||
const controllerRef = useRef<AbortController | null>(null)
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, with fallback: a failed check gives the
|
* Validate a diagram image.
|
||||||
* default valid result so it never blocks the user. A cancelled check
|
* Returns a promise that resolves with the validation result.
|
||||||
* is passed on as its AbortError.
|
|
||||||
*/
|
*/
|
||||||
const validateWithFallback = useCallback(
|
const validate = useCallback(
|
||||||
async (
|
async (
|
||||||
imageData: string,
|
imageData: string,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
): Promise<ValidationResult> => {
|
): Promise<ValidationResult> => {
|
||||||
controllerRef.current?.abort()
|
// Reject any pending validation to prevent promise leaks
|
||||||
const controller = new AbortController()
|
if (pendingValidationRef.current) {
|
||||||
controllerRef.current = controller
|
pendingValidationRef.current.reject(
|
||||||
try {
|
new Error("Validation superseded by new request"),
|
||||||
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) {
|
pendingValidationRef.current = null
|
||||||
throw new Error(`Validation request failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
return ValidationResultSchema.parse(await res.json())
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as Error)?.name === "AbortError") throw error
|
|
||||||
console.warn(
|
|
||||||
"[useValidateDiagram] Validation failed, using fallback:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
return DEFAULT_VALID_RESULT
|
|
||||||
} finally {
|
|
||||||
if (controllerRef.current === controller) {
|
|
||||||
controllerRef.current = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Store the promise handlers
|
||||||
|
pendingValidationRef.current = { resolve, reject }
|
||||||
|
|
||||||
|
// Submit the validation request
|
||||||
|
submit({ imageData, sessionId })
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[],
|
[submit],
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,9 +108,47 @@ export function useValidateDiagram() {
|
|||||||
* an AbortError, so the tool handler can finish at once.
|
* an AbortError, so the tool handler can finish at once.
|
||||||
*/
|
*/
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
controllerRef.current?.abort()
|
const pending = pendingValidationRef.current
|
||||||
controllerRef.current = null
|
if (!pending) return
|
||||||
}, [])
|
pendingValidationRef.current = null
|
||||||
|
stop()
|
||||||
|
pending.reject(new DOMException("Validation cancelled", "AbortError"))
|
||||||
|
}, [stop])
|
||||||
|
|
||||||
return { validateWithFallback, cancel }
|
/**
|
||||||
|
* 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(
|
||||||
|
async (
|
||||||
|
imageData: string,
|
||||||
|
sessionId?: string,
|
||||||
|
): Promise<ValidationResult> => {
|
||||||
|
try {
|
||||||
|
return await validate(imageData, sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error)?.name === "AbortError") throw error
|
||||||
|
console.warn(
|
||||||
|
"[useValidateDiagram] Validation failed, using fallback:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return DEFAULT_VALID_RESULT
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Validation functions
|
||||||
|
validate,
|
||||||
|
validateWithFallback,
|
||||||
|
stop,
|
||||||
|
cancel,
|
||||||
|
|
||||||
|
// State
|
||||||
|
isValidating: isLoading,
|
||||||
|
partialResult: object as ValidationResult | undefined,
|
||||||
|
error,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function loadAdminProviders(): StoredAdminProvider[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MaskedAdminProvider = Omit<
|
export type MaskedAdminProvider = Omit<
|
||||||
StoredAdminProvider,
|
StoredAdminProvider,
|
||||||
(typeof SECRET_FIELDS)[number]
|
(typeof SECRET_FIELDS)[number]
|
||||||
> & {
|
> & {
|
||||||
@@ -278,9 +278,15 @@ 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>(["AI_PROVIDER", "AI_MODEL"])
|
const keys = new Set<string>([
|
||||||
|
"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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
type SettingType = "string" | "secret" | "number" | "boolean"
|
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
|
||||||
|
|
||||||
export interface SettingDef {
|
export interface SettingDef {
|
||||||
key: string
|
key: string
|
||||||
@@ -18,6 +18,7 @@ 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
|
||||||
@@ -122,6 +123,13 @@ 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",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ globalState.__adminSettingsState ??= {
|
|||||||
}
|
}
|
||||||
const state = globalState.__adminSettingsState
|
const state = globalState.__adminSettingsState
|
||||||
|
|
||||||
function getSettingsPath(): string {
|
export 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")
|
||||||
|
|||||||
+118
-68
@@ -30,7 +30,9 @@ import {
|
|||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
|
|
||||||
const AIHUBMIX_APP_CODE = "MSBS9675"
|
export type { ProviderName }
|
||||||
|
|
||||||
|
export const AIHUBMIX_APP_CODE = "MSBS9675"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
@@ -54,7 +56,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.
|
||||||
*/
|
*/
|
||||||
function normalizeMiniMaxBaseURL(rawUrl: string): {
|
export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
isAnthropicCompatible: boolean
|
isAnthropicCompatible: boolean
|
||||||
} {
|
} {
|
||||||
@@ -127,19 +129,21 @@ 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
|
// User provides their own API key - only use user's baseUrl or default
|
||||||
return userBaseUrl || undefined
|
return userBaseUrl || defaultBaseUrl || undefined
|
||||||
}
|
}
|
||||||
// No user API key - fall back to server config
|
// No user API key - fall back to server config
|
||||||
return userBaseUrl || serverBaseUrl || undefined
|
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -240,39 +244,6 @@ 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
|
||||||
@@ -300,27 +271,41 @@ function buildProviderOptions(
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "openai": {
|
case "openai": {
|
||||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT as
|
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
||||||
| "minimal"
|
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
||||||
| "low"
|
|
||||||
| "medium"
|
|
||||||
| "high"
|
|
||||||
| undefined
|
|
||||||
// Reasoning models (the o-series, gpt-5 and later) need
|
|
||||||
// reasoningSummary to return thoughts; 'auto' as the default
|
|
||||||
// since not all models support 'detailed'
|
|
||||||
const isReasoning =
|
|
||||||
!!modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)
|
|
||||||
const reasoningSummary =
|
|
||||||
(process.env.OPENAI_REASONING_SUMMARY as
|
|
||||||
| "auto"
|
|
||||||
| "detailed"
|
|
||||||
| undefined) || (isReasoning ? "auto" : undefined)
|
|
||||||
|
|
||||||
if (reasoningSummary || reasoningEffort) {
|
// Reasoning models (the o-series, gpt-5 and later) need
|
||||||
|
// reasoningSummary to return thoughts
|
||||||
|
if (modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)) {
|
||||||
options.openai = {
|
options.openai = {
|
||||||
...(reasoningSummary && { reasoningSummary }),
|
// Auto-enable reasoning summary for reasoning models
|
||||||
...(reasoningEffort && { reasoningEffort }),
|
// Use 'auto' as default since not all models support 'detailed'
|
||||||
|
reasoningSummary:
|
||||||
|
(reasoningSummary as "auto" | "detailed") || "auto",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally configure reasoning effort
|
||||||
|
if (reasoningEffort) {
|
||||||
|
options.openai.reasoningEffort = reasoningEffort as
|
||||||
|
| "minimal"
|
||||||
|
| "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
|
||||||
@@ -348,21 +333,85 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "google": {
|
case "google": {
|
||||||
const google = geminiThinkingOptions(
|
const thinkingBudgetVal = parseIntSafe(
|
||||||
modelId,
|
process.env.GOOGLE_THINKING_BUDGET,
|
||||||
"GOOGLE_THINKING_BUDGET",
|
"GOOGLE_THINKING_BUDGET",
|
||||||
"GOOGLE_THINKING_LEVEL",
|
1024,
|
||||||
|
100000,
|
||||||
)
|
)
|
||||||
if (google) options.google = google
|
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL
|
||||||
|
|
||||||
|
// 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 google = geminiThinkingOptions(
|
const thinkingBudget = parseIntSafe(
|
||||||
modelId,
|
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
|
||||||
"GOOGLE_VERTEX_THINKING_BUDGET",
|
"GOOGLE_VERTEX_THINKING_BUDGET",
|
||||||
"GOOGLE_VERTEX_THINKING_LEVEL",
|
1024,
|
||||||
|
100000,
|
||||||
)
|
)
|
||||||
if (google) options.google = google
|
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
|
||||||
|
|
||||||
|
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": {
|
||||||
@@ -449,7 +498,7 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map of provider to required environment variable
|
// Map of provider to required environment variable
|
||||||
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
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",
|
||||||
@@ -984,7 +1033,8 @@ 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.
|
// own variable), else to the SDK's local default: the desktop
|
||||||
|
// app's "Ollama (Local)" preset puts its key field there too.
|
||||||
const baseURL =
|
const baseURL =
|
||||||
overrides?.baseUrl ||
|
overrides?.baseUrl ||
|
||||||
(overrides?.apiKey
|
(overrides?.apiKey
|
||||||
@@ -1153,7 +1203,7 @@ export function usesServerCredentials(
|
|||||||
provider: ProviderName,
|
provider: ProviderName,
|
||||||
overrides?: ClientOverrides,
|
overrides?: ClientOverrides,
|
||||||
): boolean {
|
): boolean {
|
||||||
// The desktop app's local server only has the user's own keys (its .env)
|
// The desktop app's local server holds the user's own preset keys
|
||||||
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 ?? "")
|
||||||
|
|||||||
@@ -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,6 +904,7 @@ 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 !== "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,35 @@ 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).
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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: < > & "
|
|
||||||
|
|
||||||
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
|
||||||
|
|
||||||
Example (generate ONLY this - no wrapper tags):
|
|
||||||
${SWIMLANE_EXAMPLE}
|
|
||||||
|
|
||||||
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use <br> for a line break, never \\n; a literal < or > is written &lt; or &gt;.
|
|
||||||
${STYLE_CLASS_EXAMPLE}
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- For AWS diagrams, use **AWS 2025 icons**.
|
|
||||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
|
||||||
`,
|
|
||||||
inputSchema: z.object({
|
|
||||||
xml: z.string().describe("XML string to be displayed on draw.io"),
|
|
||||||
}),
|
|
||||||
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>
|
|
||||||
+15
-10
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { modelOfPage, pageElement } from "@/lib/diagram-pages"
|
||||||
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 {
|
||||||
@@ -37,12 +32,18 @@ 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 = parseXml(xml)
|
const doc = parse(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)
|
||||||
@@ -303,10 +304,14 @@ export function isSameDocument(a: string, b: string): boolean {
|
|||||||
model: modelOfPage(d),
|
model: modelOfPage(d),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
const docA = parseXml(a)
|
const docA = parse(a)
|
||||||
const docB = parseXml(b)
|
const docB = parse(b)
|
||||||
if (!docA || !docB) return a === b
|
if (!docA || !docB) return a === b
|
||||||
if (!sameFileVars(fileVars(docA), fileVars(docB))) return false
|
const varsOf = (doc: Document) =>
|
||||||
|
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
|
||||||
|
|||||||
+17
-35
@@ -12,20 +12,12 @@ import {
|
|||||||
type PageSelector,
|
type PageSelector,
|
||||||
} from "@/packages/mcp-server/src/pages.ts"
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
|
||||||
/** The parsed XML, or null when it is empty or does not parse */
|
function parse(xml: string): Document | null {
|
||||||
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,
|
||||||
@@ -48,7 +40,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 parseXml(inflated)?.querySelector("mxGraphModel") ?? null
|
return parse(inflated)?.querySelector("mxGraphModel") ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +52,7 @@ export function pageSelectorFor(
|
|||||||
pageId: string | null | undefined,
|
pageId: string | null | undefined,
|
||||||
): PageSelector {
|
): PageSelector {
|
||||||
if (!pageId) return {}
|
if (!pageId) return {}
|
||||||
const doc = parseXml(xml)
|
const doc = parse(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,
|
||||||
@@ -77,7 +69,7 @@ export function pageModelXml(
|
|||||||
xml: string,
|
xml: string,
|
||||||
pageId: string | null | undefined,
|
pageId: string | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
const doc = parseXml(xml)
|
const doc = parse(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
|
||||||
@@ -98,15 +90,15 @@ export function replacePageModel(
|
|||||||
pageId: string | null | undefined,
|
pageId: string | null | undefined,
|
||||||
modelXml: string,
|
modelXml: string,
|
||||||
): string {
|
): string {
|
||||||
const model = parseXml(modelXml)
|
const model = parse(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 = parseXml(fileXml)
|
let doc = parse(fileXml)
|
||||||
if (doc?.documentElement.nodeName !== "mxfile") doc = parseXml(BLANK_MXFILE)
|
if (doc?.documentElement.nodeName !== "mxfile") doc = parse(BLANK_MXFILE)
|
||||||
let diagram = pageElement(doc as Document, pageId)
|
let diagram = pageElement(doc as Document, pageId)
|
||||||
if (!diagram) {
|
if (!diagram) {
|
||||||
doc = parseXml(BLANK_MXFILE)
|
doc = parse(BLANK_MXFILE)
|
||||||
diagram = pageElement(doc as Document, null)
|
diagram = pageElement(doc as Document, null)
|
||||||
}
|
}
|
||||||
const target = diagram as Element
|
const target = diagram as Element
|
||||||
@@ -120,37 +112,27 @@ 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. A file with several pages replaces the whole document: the
|
* canvas file; file variables the drawn file sets replace the canvas
|
||||||
* model wrote the pages on purpose. Either way, file variables the drawn
|
* file's. A file with several pages replaces the whole document: the model
|
||||||
* file sets replace the canvas file's, and a drawn file without any keeps
|
* wrote the pages on purpose.
|
||||||
* 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 = parseXml(drawnXml)
|
const doc = parse(drawnXml)
|
||||||
const drawnFile =
|
const drawnFile =
|
||||||
doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null
|
doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null
|
||||||
const severalPages =
|
if (drawnFile && drawnFile.getElementsByTagName("diagram").length > 1) {
|
||||||
(drawnFile?.getElementsByTagName("diagram").length ?? 0) > 1
|
return drawnXml
|
||||||
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 = parseXml(placed) as Document
|
const result = parse(placed) as Document
|
||||||
result.documentElement.setAttribute("vars", vars)
|
result.documentElement.setAttribute("vars", vars)
|
||||||
return new XMLSerializer().serializeToString(result)
|
return new XMLSerializer().serializeToString(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Types and utilities for VLM-based diagram validation.
|
* Types and utilities for VLM-based diagram validation.
|
||||||
* The validation itself runs in the useValidateDiagram hook (a JSON request to /api/validate-diagram).
|
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Re-export types from the schema file (single source of truth)
|
// Re-export types from the schema file (single source of truth)
|
||||||
export type { ValidationResult } from "./validation-schema"
|
export type { ValidationIssue, ValidationResult } from "./validation-schema"
|
||||||
|
|
||||||
import type { ValidationResult } from "./validation-schema"
|
import type { ValidationResult } from "./validation-schema"
|
||||||
|
|
||||||
|
|||||||
@@ -666,6 +666,18 @@ 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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -678,6 +690,7 @@ 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,
|
||||||
|
|||||||
+62
-46
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ConditionalCheckFailedException,
|
ConditionalCheckFailedException,
|
||||||
DynamoDBClient,
|
DynamoDBClient,
|
||||||
|
GetItemCommand,
|
||||||
UpdateItemCommand,
|
UpdateItemCommand,
|
||||||
} from "@aws-sdk/client-dynamodb"
|
} from "@aws-sdk/client-dynamodb"
|
||||||
|
|
||||||
@@ -107,61 +108,76 @@ 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 - the exception's Item holds the current counts
|
// Condition failed - need to determine which limit was exceeded
|
||||||
if (e instanceof ConditionalCheckFailedException) {
|
if (e instanceof ConditionalCheckFailedException) {
|
||||||
const item = e.Item
|
// Get current counts to determine which limit was hit
|
||||||
const storedMinute = item?.lastMinute?.S
|
try {
|
||||||
|
const getResult = await client.send(
|
||||||
|
new GetItemCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Key: {
|
||||||
|
PK: { S: pk },
|
||||||
|
SK: { S: sk },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const reqCount = Number(item?.reqCount?.N || 0)
|
const item = getResult.Item
|
||||||
const tokenCount = Number(item?.tokenCount?.N || 0)
|
const storedMinute = item?.lastMinute?.S
|
||||||
const tpmCount =
|
|
||||||
storedMinute !== currentMinute
|
|
||||||
? 0
|
|
||||||
: Number(item?.tpmCount?.N || 0)
|
|
||||||
|
|
||||||
// Determine which limit was exceeded
|
const reqCount = Number(item?.reqCount?.N || 0)
|
||||||
if (limits.requests > 0 && reqCount >= limits.requests) {
|
const tokenCount = Number(item?.tokenCount?.N || 0)
|
||||||
return {
|
const tpmCount =
|
||||||
allowed: false,
|
storedMinute !== currentMinute
|
||||||
type: "request",
|
? 0
|
||||||
error: "Daily request limit exceeded",
|
: Number(item?.tpmCount?.N || 0)
|
||||||
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
|
// Determine which limit was exceeded
|
||||||
// Fail safe by allowing (could be a TPM reset race)
|
if (limits.requests > 0 && reqCount >= limits.requests) {
|
||||||
console.warn(
|
return {
|
||||||
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
allowed: false,
|
||||||
)
|
type: "request",
|
||||||
return { allowed: true }
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other DynamoDB errors - fail open
|
// Other DynamoDB errors - fail open
|
||||||
|
|||||||
@@ -3,25 +3,48 @@
|
|||||||
"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!",
|
||||||
@@ -59,6 +82,17 @@
|
|||||||
"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",
|
||||||
@@ -69,6 +103,10 @@
|
|||||||
"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",
|
||||||
@@ -132,9 +170,13 @@
|
|||||||
"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",
|
||||||
@@ -166,6 +208,8 @@
|
|||||||
"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,
|
||||||
@@ -177,6 +221,7 @@
|
|||||||
"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": {
|
||||||
@@ -203,8 +248,37 @@
|
|||||||
"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",
|
||||||
@@ -277,13 +351,20 @@
|
|||||||
"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",
|
||||||
@@ -291,6 +372,7 @@
|
|||||||
"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",
|
||||||
@@ -306,6 +388,8 @@
|
|||||||
"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",
|
||||||
@@ -499,6 +583,10 @@
|
|||||||
"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"
|
||||||
@@ -609,7 +697,8 @@
|
|||||||
"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…",
|
||||||
|
|||||||
@@ -3,25 +3,48 @@
|
|||||||
"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": "コピーしました!",
|
||||||
@@ -59,6 +82,17 @@
|
|||||||
"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": "テーマ",
|
||||||
@@ -69,6 +103,10 @@
|
|||||||
"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 プロキシ",
|
||||||
@@ -132,9 +170,13 @@
|
|||||||
"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": "保存せずに続ける",
|
||||||
@@ -166,6 +208,8 @@
|
|||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "1日の割当量に達しました",
|
"dailyLimit": "1日の割当量に達しました",
|
||||||
"tokenLimit": "1日のトークン制限に達しました",
|
"tokenLimit": "1日のトークン制限に達しました",
|
||||||
|
"tpmLimit": "レート制限",
|
||||||
|
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||||
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
@@ -177,6 +221,7 @@
|
|||||||
"configModel": "APIキーを使用",
|
"configModel": "APIキーを使用",
|
||||||
"selfHost": "セルフホスト",
|
"selfHost": "セルフホスト",
|
||||||
"sponsor": "スポンサー",
|
"sponsor": "スポンサー",
|
||||||
|
"learnMore": "詳細 →",
|
||||||
"usedOf": "{used}/{limit}"
|
"usedOf": "{used}/{limit}"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -203,8 +248,37 @@
|
|||||||
"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": "たった今",
|
||||||
@@ -232,13 +306,20 @@
|
|||||||
"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 リージョン",
|
||||||
@@ -246,6 +327,7 @@
|
|||||||
"apiKey": "API キー",
|
"apiKey": "API キー",
|
||||||
"enterApiKey": "API キーを入力",
|
"enterApiKey": "API キーを入力",
|
||||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||||
|
"baseUrl": "ベース URL",
|
||||||
"optional": "(オプション)",
|
"optional": "(オプション)",
|
||||||
"getApiKey": "API キーを取得",
|
"getApiKey": "API キーを取得",
|
||||||
"noTools": "ツール呼び出し非対応",
|
"noTools": "ツール呼び出し非対応",
|
||||||
@@ -261,6 +343,8 @@
|
|||||||
"modelIdEmpty": "モデル ID は空にできません",
|
"modelIdEmpty": "モデル ID は空にできません",
|
||||||
"modelIdExists": "このモデル ID は既に存在します",
|
"modelIdExists": "このモデル ID は既に存在します",
|
||||||
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
||||||
|
"typeToConfirm": "確認のため「{name}」と入力",
|
||||||
|
"typeProviderName": "プロバイダー名を入力...",
|
||||||
"requestFailed": "リクエストに失敗しました({status})",
|
"requestFailed": "リクエストに失敗しました({status})",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
@@ -499,6 +583,10 @@
|
|||||||
"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 に内部アドレスを使えなくなり、安全です"
|
||||||
@@ -609,7 +697,8 @@
|
|||||||
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
|
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
|
||||||
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
|
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
|
||||||
"recent": "最近の図",
|
"recent": "最近の図",
|
||||||
"drawYourself": "白紙のキャンバスから始める"
|
"drawYourself": "白紙のキャンバスから始める",
|
||||||
|
"openFileHint": "手元の図の続きを編集"
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"waiting": "MCP サーバーを待っています…",
|
"waiting": "MCP サーバーを待っています…",
|
||||||
|
|||||||
@@ -3,25 +3,48 @@
|
|||||||
"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": "已複製!",
|
||||||
@@ -59,6 +82,17 @@
|
|||||||
"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": "主題",
|
||||||
@@ -69,6 +103,10 @@
|
|||||||
"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 代理",
|
||||||
@@ -132,9 +170,13 @@
|
|||||||
"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": "不儲存,繼續",
|
||||||
@@ -166,6 +208,8 @@
|
|||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "已達每日配額",
|
"dailyLimit": "已達每日配額",
|
||||||
"tokenLimit": "已達每日 token 上限",
|
"tokenLimit": "已達每日 token 上限",
|
||||||
|
"tpmLimit": "速率限制",
|
||||||
|
"tpmMessage": "請求過多。請稍等片刻。",
|
||||||
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
|
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
|
||||||
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
@@ -177,6 +221,7 @@
|
|||||||
"configModel": "使用自己的 API Key",
|
"configModel": "使用自己的 API Key",
|
||||||
"selfHost": "自行託管",
|
"selfHost": "自行託管",
|
||||||
"sponsor": "贊助",
|
"sponsor": "贊助",
|
||||||
|
"learnMore": "了解更多 →",
|
||||||
"usedOf": "{used}/{limit}"
|
"usedOf": "{used}/{limit}"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -203,8 +248,37 @@
|
|||||||
"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": "剛剛",
|
||||||
@@ -277,13 +351,20 @@
|
|||||||
"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 區域",
|
||||||
@@ -291,6 +372,7 @@
|
|||||||
"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": "不支援工具呼叫",
|
||||||
@@ -306,6 +388,8 @@
|
|||||||
"modelIdEmpty": "模型 ID 不能為空",
|
"modelIdEmpty": "模型 ID 不能為空",
|
||||||
"modelIdExists": "此模型 ID 已存在",
|
"modelIdExists": "此模型 ID 已存在",
|
||||||
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
|
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
|
||||||
|
"typeToConfirm": "輸入「{name}」以確認",
|
||||||
|
"typeProviderName": "輸入服務商名稱...",
|
||||||
"requestFailed": "請求失敗({status})",
|
"requestFailed": "請求失敗({status})",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
@@ -499,6 +583,10 @@
|
|||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "檢查逾時(毫秒)"
|
"label": "檢查逾時(毫秒)"
|
||||||
},
|
},
|
||||||
|
"ENABLE_HISTORY_XML_REPLACE": {
|
||||||
|
"label": "精簡舊圖",
|
||||||
|
"description": "對話歷史裡的舊圖表只留簡短標記"
|
||||||
|
},
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "存取內網",
|
"label": "存取內網",
|
||||||
"description": "關閉後,自訂模型位址不能指向內網,更安全"
|
"description": "關閉後,自訂模型位址不能指向內網,更安全"
|
||||||
@@ -609,7 +697,8 @@
|
|||||||
"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
Reference in New Issue
Block a user