mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e785bcb5 | ||
|
|
60105ddb51 |
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
|
|
||||||
- name: Lint check
|
|
||||||
run: npm run check
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
@@ -4,14 +4,11 @@ on:
|
|||||||
push:
|
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,6 +6,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-unit:
|
lint-and-unit:
|
||||||
name: Lint & Unit Tests
|
name: Lint & Unit Tests
|
||||||
@@ -16,12 +23,15 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|
||||||
@@ -141,7 +151,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
import type { Metadata } from "next"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { FaGithub } from "react-icons/fa"
|
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "关于 - Next AI Draw.io",
|
|
||||||
description:
|
|
||||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
|
||||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutCN() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{/* Navigation */}
|
|
||||||
<header className="bg-white border-b border-gray-200">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Next AI Draw.io
|
|
||||||
</Link>
|
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
编辑器
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/about/cn"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
关于
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
aria-label="在GitHub上查看"
|
|
||||||
>
|
|
||||||
<FaGithub className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<article className="prose prose-lg max-w-none">
|
|
||||||
{/* Title */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
|
||||||
Next AI Draw.io
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
|
||||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
|
||||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
|
||||||
{/* Bring Your Own Key */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
|
||||||
使用自己的 API Key
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
|
||||||
您也可以使用自己的 API
|
|
||||||
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
|
||||||
您的 Key
|
|
||||||
仅保存在浏览器本地,不会被存储在服务器上。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-700">
|
|
||||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
功能特性
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>LLM驱动的图表创建</strong>
|
|
||||||
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>基于图像的图表复制</strong>
|
|
||||||
:上传现有图表或图像,让AI自动复制和增强
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>版本与撤销</strong>
|
|
||||||
:AI
|
|
||||||
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
|
|
||||||
Ctrl+Z 也能撤回
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>交互式聊天界面</strong>
|
|
||||||
:与AI实时对话来完善您的图表
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>AWS架构图支持</strong>
|
|
||||||
:专门支持生成AWS架构图
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>动画连接器</strong>
|
|
||||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Examples */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
示例
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
以下是一些示例提示词及其生成的图表:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* ResNet50 Architecture */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
ResNet50模型架构动画
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
|
||||||
<strong>animated</strong> architecture diagram
|
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
|
||||||
<Image
|
|
||||||
src="/resnet50.svg"
|
|
||||||
alt="ResNet50模型架构图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
RAG技术图
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
|
||||||
architecture diagram for{" "}
|
|
||||||
<strong>chat application</strong>. Use
|
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/rag_prod.svg"
|
|
||||||
alt="RAG架构图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
React和AWS认证流程
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate
|
|
||||||
authentication process using React with{" "}
|
|
||||||
<strong>AWS</strong>. Use Serverless
|
|
||||||
architecture.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/auth.svg"
|
|
||||||
alt="认证架构图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
敏捷Scrum流程
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate agile
|
|
||||||
scrum workflow diagram for software
|
|
||||||
development team.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/agile_scrum.svg"
|
|
||||||
alt="敏捷Scrum流程图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
开放式创新
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Create
|
|
||||||
visualization of Henry Chesbrough's
|
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/inno.svg"
|
|
||||||
alt="开放式创新图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* How It Works */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
工作原理
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>Next.js</strong>:用于前端框架和路由
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
|
||||||
<code>@ai-sdk/*</code>
|
|
||||||
):用于流式AI响应和多提供商支持
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>draw.io</strong>
|
|
||||||
:内置在应用里的编辑器,应用可以直接控制它
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
多提供商支持
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
|
||||||
<li>字节跳动豆包</li>
|
|
||||||
<li>AWS Bedrock(默认)</li>
|
|
||||||
<li>
|
|
||||||
OpenAI / OpenAI兼容API(通过{" "}
|
|
||||||
<code>OPENAI_BASE_URL</code>)
|
|
||||||
</li>
|
|
||||||
<li>Anthropic</li>
|
|
||||||
<li>Google AI</li>
|
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
|
||||||
<li>Ollama</li>
|
|
||||||
<li>OpenRouter</li>
|
|
||||||
<li>DeepSeek</li>
|
|
||||||
<li>SiliconFlow</li>
|
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
|
||||||
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
支持与联系
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700">
|
|
||||||
如果您觉得这个项目有用,请考虑{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
赞助
|
|
||||||
</a>{" "}
|
|
||||||
来帮助托管在线演示站点!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 mt-2">
|
|
||||||
如需支持或咨询,请在{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
GitHub仓库
|
|
||||||
</a>{" "}
|
|
||||||
上提交issue或联系:me[at]jiang.jp
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
打开编辑器
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<p className="text-center text-gray-600 text-sm">
|
|
||||||
Next AI Draw.io - 开源AI驱动的图表生成器
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
import type { Metadata } from "next"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { FaGithub } from "react-icons/fa"
|
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "概要 - Next AI Draw.io",
|
|
||||||
description:
|
|
||||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
|
||||||
keywords: [
|
|
||||||
"AIダイアグラム",
|
|
||||||
"draw.io",
|
|
||||||
"AWSアーキテクチャ",
|
|
||||||
"GCPダイアグラム",
|
|
||||||
"Azureダイアグラム",
|
|
||||||
"LLM",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutJA() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{/* Navigation */}
|
|
||||||
<header className="bg-white border-b border-gray-200">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Next AI Draw.io
|
|
||||||
</Link>
|
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
エディタ
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/about/ja"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
概要
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
aria-label="GitHubで見る"
|
|
||||||
>
|
|
||||||
<FaGithub className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<article className="prose prose-lg max-w-none">
|
|
||||||
{/* Title */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
|
||||||
Next AI Draw.io
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
|
||||||
AI搭載のダイアグラム作成ツール -
|
|
||||||
チャット、描画、可視化
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
|
||||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
|
||||||
{/* Bring Your Own Key */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
|
||||||
自分のAPIキーを使用
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
|
||||||
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
|
||||||
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-700">
|
|
||||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
機能
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>LLM搭載のダイアグラム作成</strong>
|
|
||||||
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>画像ベースのダイアグラム複製</strong>
|
|
||||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>バージョンと取り消し</strong>
|
|
||||||
:AI
|
|
||||||
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
|
|
||||||
Ctrl+Z を押しても元に戻せます
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>
|
|
||||||
インタラクティブなチャットインターフェース
|
|
||||||
</strong>
|
|
||||||
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>
|
|
||||||
AWSアーキテクチャダイアグラムサポート
|
|
||||||
</strong>
|
|
||||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>アニメーションコネクタ</strong>
|
|
||||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Examples */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
例
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* ResNet50 Architecture */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
ResNet50モデルアーキテクチャアニメーション
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
|
||||||
<strong>animated</strong> architecture diagram
|
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
|
||||||
<Image
|
|
||||||
src="/resnet50.svg"
|
|
||||||
alt="ResNet50モデルアーキテクチャ図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
RAG技術ダイアグラム
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
|
||||||
architecture diagram for{" "}
|
|
||||||
<strong>chat application</strong>. Use
|
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/rag_prod.svg"
|
|
||||||
alt="RAGアーキテクチャ図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
ReactとAWSによる認証
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate
|
|
||||||
authentication process using React with{" "}
|
|
||||||
<strong>AWS</strong>. Use Serverless
|
|
||||||
architecture.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/auth.svg"
|
|
||||||
alt="認証アーキテクチャ図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
アジャイルスクラムプロセス
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate agile
|
|
||||||
scrum workflow diagram for software
|
|
||||||
development team.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/agile_scrum.svg"
|
|
||||||
alt="アジャイルスクラム図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
オープンイノベーション
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Create
|
|
||||||
visualization of Henry Chesbrough's
|
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/inno.svg"
|
|
||||||
alt="オープンイノベーション図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* How It Works */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
仕組み
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-4">
|
|
||||||
本アプリケーションは以下の技術を使用しています:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>Next.js</strong>
|
|
||||||
:フロントエンドフレームワークとルーティング
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
|
||||||
<code>@ai-sdk/*</code>
|
|
||||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>draw.io</strong>
|
|
||||||
:アプリに同梱されたエディター。アプリから直接操作できます
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
マルチプロバイダーサポート
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
|
||||||
<li>ByteDance Doubao</li>
|
|
||||||
<li>AWS Bedrock(デフォルト)</li>
|
|
||||||
<li>
|
|
||||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
|
||||||
経由)
|
|
||||||
</li>
|
|
||||||
<li>Anthropic</li>
|
|
||||||
<li>Google AI</li>
|
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
|
||||||
<li>Ollama</li>
|
|
||||||
<li>OpenRouter</li>
|
|
||||||
<li>DeepSeek</li>
|
|
||||||
<li>SiliconFlow</li>
|
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
注:<code>claude-sonnet-4-5</code>
|
|
||||||
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
サポート&お問い合わせ
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700">
|
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
スポンサー
|
|
||||||
</a>{" "}
|
|
||||||
をご検討ください!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 mt-2">
|
|
||||||
サポートやお問い合わせについては、{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
GitHubリポジトリ
|
|
||||||
</a>{" "}
|
|
||||||
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
エディタを開く
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<p className="text-center text-gray-600 text-sm">
|
|
||||||
Next AI Draw.io -
|
|
||||||
オープンソースAI搭載ダイアグラムジェネレーター
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+491
-194
@@ -1,23 +1,420 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import type { ReactNode } from "react"
|
||||||
|
import { GitHubIcon } from "@/components/icons/github"
|
||||||
import Image from "@/components/image-with-basepath"
|
import Image from "@/components/image-with-basepath"
|
||||||
|
import type { Locale } from "@/lib/i18n/config"
|
||||||
|
import { hasLocale } from "@/lib/i18n/dictionaries"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
type ExampleId = "resnet50" | "rag" | "auth" | "scrum" | "inno"
|
||||||
title: "About - Next AI Draw.io",
|
|
||||||
description:
|
type AboutTexts = {
|
||||||
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
title: string
|
||||||
keywords: [
|
description: string
|
||||||
"AI diagram",
|
keywords: string[]
|
||||||
"draw.io",
|
nav: { editor: string; about: string; github: string }
|
||||||
"AWS architecture",
|
tagline: string
|
||||||
"GCP diagram",
|
byok: { title: string; body: string; note: string }
|
||||||
"Azure diagram",
|
intro: string
|
||||||
"LLM",
|
// Punctuation differs between Latin and CJK text
|
||||||
],
|
colon: string
|
||||||
|
parenOpen: string
|
||||||
|
parenClose: string
|
||||||
|
featuresHeading: string
|
||||||
|
features: { title: string; text: string }[]
|
||||||
|
examplesHeading: string
|
||||||
|
examplesIntro: string
|
||||||
|
examples: Record<ExampleId, { title: string; alt: string }>
|
||||||
|
howHeading: string
|
||||||
|
howIntro: string
|
||||||
|
tech: { nextjs: string; aiSdk: string; drawio: string }
|
||||||
|
howOutro: string
|
||||||
|
providersHeading: string
|
||||||
|
doubao: string
|
||||||
|
bedrock: string
|
||||||
|
// Text around the OPENAI_BASE_URL code span
|
||||||
|
openai: { before: string; after: string }
|
||||||
|
// Text around the claude-sonnet-4-5 code span
|
||||||
|
modelNote: { before: string; after: string }
|
||||||
|
supportHeading: string
|
||||||
|
// Text around the sponsor link
|
||||||
|
sponsor: { before: string; link: string; after: string }
|
||||||
|
// Text around the GitHub repository link
|
||||||
|
contact: { before: string; link: string; after: string }
|
||||||
|
openEditor: string
|
||||||
|
footer: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function About() {
|
const texts: Record<"en" | "zh" | "ja", AboutTexts> = {
|
||||||
|
en: {
|
||||||
|
title: "About - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||||
|
keywords: [
|
||||||
|
"AI diagram",
|
||||||
|
"draw.io",
|
||||||
|
"AWS architecture",
|
||||||
|
"GCP diagram",
|
||||||
|
"Azure diagram",
|
||||||
|
"LLM",
|
||||||
|
],
|
||||||
|
nav: { editor: "Editor", about: "About", github: "View on GitHub" },
|
||||||
|
tagline: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize",
|
||||||
|
byok: {
|
||||||
|
title: "Bring Your Own API Key",
|
||||||
|
body: "You can also use your own API key with any supported provider. Click the Settings icon in the chat panel to configure your provider and API key.",
|
||||||
|
note: "Your key is stored locally in your browser and is never stored on the server.",
|
||||||
|
},
|
||||||
|
intro: "A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.",
|
||||||
|
colon: ": ",
|
||||||
|
parenOpen: " (",
|
||||||
|
parenClose: ")",
|
||||||
|
featuresHeading: "Features",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: "LLM-Powered Diagram Creation",
|
||||||
|
text: "Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image-Based Diagram Replication",
|
||||||
|
text: "Upload existing diagrams or images and have the AI replicate and enhance them automatically",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Versions and Undo",
|
||||||
|
text: "Every AI change becomes a version in the chat that you can compare, restore or undo; Ctrl+Z on the canvas takes it back too",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Interactive Chat Interface",
|
||||||
|
text: "Communicate with AI to refine your diagrams in real-time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AWS Architecture Diagram Support",
|
||||||
|
text: "Specialized support for generating AWS architecture diagrams",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Animated Connectors",
|
||||||
|
text: "Create dynamic and animated connectors between diagram elements for better visualization",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
examplesHeading: "Examples",
|
||||||
|
examplesIntro:
|
||||||
|
"Here are some example prompts and their generated diagrams:",
|
||||||
|
examples: {
|
||||||
|
resnet50: {
|
||||||
|
title: "Animated ResNet50 Model Architecture",
|
||||||
|
alt: "Architecture diagram for ResNet50 model",
|
||||||
|
},
|
||||||
|
rag: {
|
||||||
|
title: "RAG Technique Diagram",
|
||||||
|
alt: "RAG Architecture Diagram",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "Authentication using React and AWS",
|
||||||
|
alt: "Authentication Architecture Diagram",
|
||||||
|
},
|
||||||
|
scrum: { title: "Agile Scrum Process", alt: "Agile Scrum Diagram" },
|
||||||
|
inno: { title: "Open Innovation", alt: "Open Innovation Diagram" },
|
||||||
|
},
|
||||||
|
howHeading: "How It Works",
|
||||||
|
howIntro: "The application uses the following technologies:",
|
||||||
|
tech: {
|
||||||
|
nextjs: "For the frontend framework and routing",
|
||||||
|
aiSdk: "For streaming AI responses and multi-provider support",
|
||||||
|
drawio: "The editor, bundled with the app so it can be driven directly",
|
||||||
|
},
|
||||||
|
howOutro:
|
||||||
|
"Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.",
|
||||||
|
providersHeading: "Multi-Provider Support",
|
||||||
|
doubao: "ByteDance Doubao",
|
||||||
|
bedrock: "AWS Bedrock (default)",
|
||||||
|
openai: { before: "OpenAI / OpenAI-compatible APIs (via ", after: ")" },
|
||||||
|
modelNote: {
|
||||||
|
before: "Note that ",
|
||||||
|
after: " has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.",
|
||||||
|
},
|
||||||
|
supportHeading: "Support & Contact",
|
||||||
|
sponsor: {
|
||||||
|
before: "If you find this project useful, please consider",
|
||||||
|
link: "sponsoring",
|
||||||
|
after: "to help host the live demo site!",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
before: "For support or inquiries, please open an issue on the",
|
||||||
|
link: "GitHub repository",
|
||||||
|
after: "or contact: me[at]jiang.jp",
|
||||||
|
},
|
||||||
|
openEditor: "Open Editor",
|
||||||
|
footer: "Next AI Draw.io - Open Source AI-Powered Diagram Generator",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: "关于 - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||||
|
keywords: [
|
||||||
|
"AI图表",
|
||||||
|
"draw.io",
|
||||||
|
"AWS架构",
|
||||||
|
"GCP图表",
|
||||||
|
"Azure图表",
|
||||||
|
"LLM",
|
||||||
|
],
|
||||||
|
nav: { editor: "编辑器", about: "关于", github: "在GitHub上查看" },
|
||||||
|
tagline: "AI驱动的图表创建工具 - 对话、绘制、可视化",
|
||||||
|
byok: {
|
||||||
|
title: "使用自己的 API Key",
|
||||||
|
body: "您也可以使用自己的 API Key,支持多种服务商。点击聊天面板中的设置图标即可配置。",
|
||||||
|
note: "您的 Key 仅保存在浏览器本地,不会被存储在服务器上。",
|
||||||
|
},
|
||||||
|
intro: "一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。",
|
||||||
|
colon: ":",
|
||||||
|
parenOpen: "(",
|
||||||
|
parenClose: ")",
|
||||||
|
featuresHeading: "功能特性",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: "LLM驱动的图表创建",
|
||||||
|
text: "利用大语言模型通过自然语言命令直接创建和操作draw.io图表",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "基于图像的图表复制",
|
||||||
|
text: "上传现有图表或图像,让AI自动复制和增强",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "版本与撤销",
|
||||||
|
text: "AI 的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按 Ctrl+Z 也能撤回",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "交互式聊天界面",
|
||||||
|
text: "与AI实时对话来完善您的图表",
|
||||||
|
},
|
||||||
|
{ title: "AWS架构图支持", text: "专门支持生成AWS架构图" },
|
||||||
|
{
|
||||||
|
title: "动画连接器",
|
||||||
|
text: "在图表元素之间创建动态动画连接器,实现更好的可视化效果",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
examplesHeading: "示例",
|
||||||
|
examplesIntro: "以下是一些示例提示词及其生成的图表:",
|
||||||
|
examples: {
|
||||||
|
resnet50: {
|
||||||
|
title: "ResNet50模型架构动画",
|
||||||
|
alt: "ResNet50模型架构图",
|
||||||
|
},
|
||||||
|
rag: { title: "RAG技术图", alt: "RAG架构图" },
|
||||||
|
auth: { title: "React和AWS认证流程", alt: "认证架构图" },
|
||||||
|
scrum: { title: "敏捷Scrum流程", alt: "敏捷Scrum流程图" },
|
||||||
|
inno: { title: "开放式创新", alt: "开放式创新图" },
|
||||||
|
},
|
||||||
|
howHeading: "工作原理",
|
||||||
|
howIntro: "本应用使用以下技术:",
|
||||||
|
tech: {
|
||||||
|
nextjs: "用于前端框架和路由",
|
||||||
|
aiSdk: "用于流式AI响应和多提供商支持",
|
||||||
|
drawio: "内置在应用里的编辑器,应用可以直接控制它",
|
||||||
|
},
|
||||||
|
howOutro:
|
||||||
|
"图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。",
|
||||||
|
providersHeading: "多提供商支持",
|
||||||
|
doubao: "字节跳动豆包",
|
||||||
|
bedrock: "AWS Bedrock(默认)",
|
||||||
|
openai: { before: "OpenAI / OpenAI兼容API(通过 ", after: ")" },
|
||||||
|
modelNote: {
|
||||||
|
before: "注意:",
|
||||||
|
after: " 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。",
|
||||||
|
},
|
||||||
|
supportHeading: "支持与联系",
|
||||||
|
sponsor: {
|
||||||
|
before: "如果您觉得这个项目有用,请考虑",
|
||||||
|
link: "赞助",
|
||||||
|
after: "来帮助托管在线演示站点!",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
before: "如需支持或咨询,请在",
|
||||||
|
link: "GitHub仓库",
|
||||||
|
after: "上提交issue或联系:me[at]jiang.jp",
|
||||||
|
},
|
||||||
|
openEditor: "打开编辑器",
|
||||||
|
footer: "Next AI Draw.io - 开源AI驱动的图表生成器",
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
title: "概要 - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||||
|
keywords: [
|
||||||
|
"AIダイアグラム",
|
||||||
|
"draw.io",
|
||||||
|
"AWSアーキテクチャ",
|
||||||
|
"GCPダイアグラム",
|
||||||
|
"Azureダイアグラム",
|
||||||
|
"LLM",
|
||||||
|
],
|
||||||
|
nav: { editor: "エディタ", about: "概要", github: "GitHubで見る" },
|
||||||
|
tagline: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化",
|
||||||
|
byok: {
|
||||||
|
title: "自分のAPIキーを使用",
|
||||||
|
body: "お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。",
|
||||||
|
note: "キーはブラウザのローカルに保存され、サーバーには保存されません。",
|
||||||
|
},
|
||||||
|
intro: "AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。",
|
||||||
|
colon: ":",
|
||||||
|
parenOpen: "(",
|
||||||
|
parenClose: ")",
|
||||||
|
featuresHeading: "機能",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: "LLM搭載のダイアグラム作成",
|
||||||
|
text: "大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "画像ベースのダイアグラム複製",
|
||||||
|
text: "既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "バージョンと取り消し",
|
||||||
|
text: "AI の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで Ctrl+Z を押しても元に戻せます",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "インタラクティブなチャットインターフェース",
|
||||||
|
text: "AIとリアルタイムでコミュニケーションしてダイアグラムを改善",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AWSアーキテクチャダイアグラムサポート",
|
||||||
|
text: "AWSアーキテクチャダイアグラムの生成を専門的にサポート",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "アニメーションコネクタ",
|
||||||
|
text: "より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
examplesHeading: "例",
|
||||||
|
examplesIntro:
|
||||||
|
"以下はいくつかのプロンプト例と生成されたダイアグラムです:",
|
||||||
|
examples: {
|
||||||
|
resnet50: {
|
||||||
|
title: "ResNet50モデルアーキテクチャアニメーション",
|
||||||
|
alt: "ResNet50モデルアーキテクチャ図",
|
||||||
|
},
|
||||||
|
rag: { title: "RAG技術ダイアグラム", alt: "RAGアーキテクチャ図" },
|
||||||
|
auth: {
|
||||||
|
title: "ReactとAWSによる認証",
|
||||||
|
alt: "認証アーキテクチャ図",
|
||||||
|
},
|
||||||
|
scrum: {
|
||||||
|
title: "アジャイルスクラムプロセス",
|
||||||
|
alt: "アジャイルスクラム図",
|
||||||
|
},
|
||||||
|
inno: {
|
||||||
|
title: "オープンイノベーション",
|
||||||
|
alt: "オープンイノベーション図",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
howHeading: "仕組み",
|
||||||
|
howIntro: "本アプリケーションは以下の技術を使用しています:",
|
||||||
|
tech: {
|
||||||
|
nextjs: "フロントエンドフレームワークとルーティング",
|
||||||
|
aiSdk: "ストリーミングAIレスポンスとマルチプロバイダーサポート",
|
||||||
|
drawio: "アプリに同梱されたエディター。アプリから直接操作できます",
|
||||||
|
},
|
||||||
|
howOutro:
|
||||||
|
"ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。",
|
||||||
|
providersHeading: "マルチプロバイダーサポート",
|
||||||
|
doubao: "ByteDance Doubao",
|
||||||
|
bedrock: "AWS Bedrock(デフォルト)",
|
||||||
|
openai: { before: "OpenAI / OpenAI互換API(", after: "経由)" },
|
||||||
|
modelNote: {
|
||||||
|
before: "注:",
|
||||||
|
after: "はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。",
|
||||||
|
},
|
||||||
|
supportHeading: "サポート&お問い合わせ",
|
||||||
|
sponsor: {
|
||||||
|
before: "このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために",
|
||||||
|
link: "スポンサー",
|
||||||
|
after: "をご検討ください!",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
before: "サポートやお問い合わせについては、",
|
||||||
|
link: "GitHubリポジトリ",
|
||||||
|
after: "でissueを開くか、ご連絡ください:me[at]jiang.jp",
|
||||||
|
},
|
||||||
|
openEditor: "エディタを開く",
|
||||||
|
footer: "Next AI Draw.io - オープンソースAI搭載ダイアグラムジェネレーター",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The example prompts stay in English in every language
|
||||||
|
const examples: { id: ExampleId; src: string; prompt: ReactNode }[] = [
|
||||||
|
{
|
||||||
|
id: "resnet50",
|
||||||
|
src: "/resnet50.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Give me an <strong>animated</strong> architecture diagram of the
|
||||||
|
ResNet50 model.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rag",
|
||||||
|
src: "/rag_prod.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Generate a RAG architecture diagram for{" "}
|
||||||
|
<strong>chat application</strong>. Use connected diagram for
|
||||||
|
data ingestion
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auth",
|
||||||
|
src: "/auth.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Generate authentication process using React with{" "}
|
||||||
|
<strong>AWS</strong>. Use Serverless architecture.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scrum",
|
||||||
|
src: "/agile_scrum.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Generate agile scrum workflow diagram for software development
|
||||||
|
team.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inno",
|
||||||
|
src: "/inno.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Create visualization of Henry Chesbrough's Open Innovation
|
||||||
|
model.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Traditional Chinese has no About text of its own and shows the Simplified one
|
||||||
|
function textsFor(rawLang: string): AboutTexts {
|
||||||
|
const lang: Locale = hasLocale(rawLang) ? rawLang : "en"
|
||||||
|
return texts[lang === "zh-Hant" ? "zh" : lang]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ lang: string }> }
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { lang } = await params
|
||||||
|
const t = textsFor(lang)
|
||||||
|
return { title: t.title, description: t.description, keywords: t.keywords }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function About({ params }: Props) {
|
||||||
|
const { lang } = await params
|
||||||
|
const t = textsFor(lang)
|
||||||
|
const [hero, ...grid] = examples
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -25,32 +422,32 @@ export default function About() {
|
|||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${lang}`}
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
Next AI Draw.io
|
Next AI Draw.io
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${lang}`}
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
>
|
>
|
||||||
Editor
|
{t.nav.editor}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href={`/${lang}/about`}
|
||||||
className="text-blue-600 font-semibold"
|
className="text-blue-600 font-semibold"
|
||||||
>
|
>
|
||||||
About
|
{t.nav.about}
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
aria-label="View on GitHub"
|
aria-label={t.nav.github}
|
||||||
>
|
>
|
||||||
<FaGithub className="w-5 h-5" />
|
<GitHubIcon className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +463,7 @@ export default function About() {
|
|||||||
Next AI Draw.io
|
Next AI Draw.io
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
{t.tagline}
|
||||||
Visualize
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,92 +473,53 @@ export default function About() {
|
|||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
Bring Your Own API Key
|
{t.byok.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||||
You can also use your own API key with any
|
{t.byok.body}
|
||||||
supported provider. Click the Settings icon
|
|
||||||
in the chat panel to configure your provider
|
|
||||||
and API key.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||||
Your key is stored locally in your browser
|
{t.byok.note}
|
||||||
and is never stored on the server.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">{t.intro}</p>
|
||||||
A Next.js web application that integrates AI
|
|
||||||
capabilities with draw.io diagrams. Create, modify, and
|
|
||||||
enhance diagrams through natural language commands and
|
|
||||||
AI-assisted visualization.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Features
|
{t.featuresHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
{t.features.map((feature) => (
|
||||||
<strong>LLM-Powered Diagram Creation</strong>:
|
<li key={feature.title}>
|
||||||
Leverage Large Language Models to create and
|
<strong>{feature.title}</strong>
|
||||||
manipulate draw.io diagrams directly through natural
|
{t.colon}
|
||||||
language commands
|
{feature.text}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
))}
|
||||||
<strong>Image-Based Diagram Replication</strong>:
|
|
||||||
Upload existing diagrams or images and have the AI
|
|
||||||
replicate and enhance them automatically
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Versions and Undo</strong>: Every AI change
|
|
||||||
becomes a version in the chat that you can compare,
|
|
||||||
restore or undo; Ctrl+Z on the canvas takes it back
|
|
||||||
too
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Interactive Chat Interface</strong>:
|
|
||||||
Communicate with AI to refine your diagrams in
|
|
||||||
real-time
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>AWS Architecture Diagram Support</strong>:
|
|
||||||
Specialized support for generating AWS architecture
|
|
||||||
diagrams
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Animated Connectors</strong>: Create dynamic
|
|
||||||
and animated connectors between diagram elements for
|
|
||||||
better visualization
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Examples */}
|
{/* Examples */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Examples
|
{t.examplesHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-6">
|
<p className="text-gray-700 mb-6">{t.examplesIntro}</p>
|
||||||
Here are some example prompts and their generated
|
|
||||||
diagrams:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* First example on its own */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Animated ResNet50 Model Architecture
|
{t.examples[hero.id].title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>Prompt:</strong> {hero.prompt}
|
||||||
<strong>animated</strong> architecture diagram
|
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
<Image
|
<Image
|
||||||
src="/resnet50.svg"
|
src={hero.src}
|
||||||
alt="Architecture diagram for ResNet50 model"
|
alt={t.examples[hero.id].alt}
|
||||||
width={480}
|
width={480}
|
||||||
height={360}
|
height={360}
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
@@ -172,125 +529,67 @@ export default function About() {
|
|||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
{grid.map((example) => (
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<div key={example.id} className="text-center">
|
||||||
RAG Technique Diagram
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
</h3>
|
{t.examples[example.id].title}
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
</h3>
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
architecture diagram for{" "}
|
<strong>Prompt:</strong>{" "}
|
||||||
<strong>chat application</strong>. Use
|
{example.prompt}
|
||||||
connected diagram for data ingestion
|
</p>
|
||||||
</p>
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src={example.src}
|
||||||
src="/rag_prod.svg"
|
alt={t.examples[example.id].alt}
|
||||||
alt="RAG Architecture Diagram"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="max-w-full max-h-full object-contain"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Authentication using React and AWS
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate
|
|
||||||
authentication process using React with{" "}
|
|
||||||
<strong>AWS</strong>. Use Serverless
|
|
||||||
architecture.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/auth.svg"
|
|
||||||
alt="Authentication Architecture Diagram"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Agile Scrum Process
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate agile
|
|
||||||
scrum workflow diagram for software
|
|
||||||
development team.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/agile_scrum.svg"
|
|
||||||
alt="Agile Scrum Diagram"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Open Innovation
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Create
|
|
||||||
visualization of Henry Chesbrough's
|
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/inno.svg"
|
|
||||||
alt="Open Innovation Diagram"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
How It Works
|
{t.howHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">{t.howIntro}</p>
|
||||||
The application uses the following technologies:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<strong>Next.js</strong>: For the frontend framework
|
<strong>Next.js</strong>
|
||||||
and routing
|
{t.colon}
|
||||||
|
{t.tech.nextjs}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
|
<strong>Vercel AI SDK</strong>
|
||||||
<code>@ai-sdk/*</code>): For streaming AI responses
|
{t.parenOpen}
|
||||||
and multi-provider support
|
<code>ai</code> + <code>@ai-sdk/*</code>
|
||||||
|
{t.parenClose}
|
||||||
|
{t.colon}
|
||||||
|
{t.tech.aiSdk}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>draw.io</strong>: The editor, bundled with
|
<strong>draw.io</strong>
|
||||||
the app so it can be driven directly
|
{t.colon}
|
||||||
|
{t.tech.drawio}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">{t.howOutro}</p>
|
||||||
Diagrams are represented as XML that can be rendered in
|
|
||||||
draw.io. The AI processes your commands and generates or
|
|
||||||
modifies this XML accordingly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
{/* Multi-Provider Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Multi-Provider Support
|
{t.providersHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>ByteDance Doubao</li>
|
<li>{t.doubao}</li>
|
||||||
<li>AWS Bedrock (default)</li>
|
<li>{t.bedrock}</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
{t.openai.before}
|
||||||
<code>OPENAI_BASE_URL</code>)
|
<code>OPENAI_BASE_URL</code>
|
||||||
|
{t.openai.after}
|
||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
@@ -303,48 +602,47 @@ export default function About() {
|
|||||||
<li>ModelScope</li>
|
<li>ModelScope</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
{t.modelNote.before}
|
||||||
draw.io diagrams with AWS logos, so if you want to
|
<code>claude-sonnet-4-5</code>
|
||||||
create AWS architecture diagrams, this is the best
|
{t.modelNote.after}
|
||||||
choice.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Support & Contact
|
{t.supportHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
If you find this project useful, please consider{" "}
|
{t.sponsor.before}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
sponsoring
|
{t.sponsor.link}
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to help host the live demo site!
|
{t.sponsor.after}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mt-2">
|
<p className="text-gray-700 mt-2">
|
||||||
For support or inquiries, please open an issue on the{" "}
|
{t.contact.before}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
GitHub repository
|
{t.contact.link}
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
or contact: me[at]jiang.jp
|
{t.contact.after}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-center">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${lang}`}
|
||||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Open Editor
|
{t.openEditor}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -354,8 +652,7 @@ export default function About() {
|
|||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<p className="text-center text-gray-600 text-sm">
|
<p className="text-center text-gray-600 text-sm">
|
||||||
Next AI Draw.io - Open Source AI-Powered Diagram
|
{t.footer}
|
||||||
Generator
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { MaskedSecret as SecretValue } from "@/lib/admin/auth"
|
||||||
|
import type { AdminProviderInput as AdminProvider } from "@/lib/admin/providers"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
import type { ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
@@ -5,7 +7,10 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
|||||||
|
|
||||||
// ── Shared types ─────────────────────────────────────────────────────
|
// ── Shared types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type SecretValue = { isSet: true; hint: string }
|
// The same shapes the admin API validates and returns. Secret fields of a
|
||||||
|
// provider hold either a masked marker (unchanged) or a plaintext string
|
||||||
|
// (new value).
|
||||||
|
export type { AdminProvider, SecretValue }
|
||||||
|
|
||||||
export function isSecretValue(v: unknown): v is SecretValue {
|
export function isSecretValue(v: unknown): v is SecretValue {
|
||||||
return typeof v === "object" && v !== null && "isSet" in v
|
return typeof v === "object" && v !== null && "isSet" in v
|
||||||
@@ -24,22 +29,6 @@ export function savedTextOf(state: SettingState | undefined): string {
|
|||||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin provider in client state. Secret fields hold either a masked
|
|
||||||
// marker (unchanged) or a plaintext string (new value).
|
|
||||||
export interface AdminProvider {
|
|
||||||
id: string
|
|
||||||
provider: ProviderName
|
|
||||||
name?: string
|
|
||||||
apiKey?: string | SecretValue
|
|
||||||
baseUrl?: string
|
|
||||||
awsAccessKeyId?: string | SecretValue
|
|
||||||
awsSecretAccessKey?: string | SecretValue
|
|
||||||
awsRegion?: string
|
|
||||||
vertexApiKey?: string | SecretValue
|
|
||||||
models: string[]
|
|
||||||
isDefault?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||||
export interface EnvProvider {
|
export interface EnvProvider {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import { useState } from "react"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||||
@@ -206,26 +199,6 @@ export function SettingField({
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "enum":
|
|
||||||
control = (
|
|
||||||
<Select
|
|
||||||
value={currentValue || undefined}
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={onChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
|
||||||
<SelectValue placeholder={dict.admin.notSet} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{def.options?.map((opt) => (
|
|
||||||
<SelectItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case "secret":
|
case "secret":
|
||||||
control = (
|
control = (
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
|||||||
+13
-16
@@ -41,6 +41,13 @@ export async function generateStaticParams() {
|
|||||||
return i18n.locales.map((locale) => ({ lang: locale }))
|
return i18n.locales.map((locale) => ({ lang: locale }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OG_LOCALE: Record<Locale, string> = {
|
||||||
|
en: "en_US",
|
||||||
|
zh: "zh_CN",
|
||||||
|
ja: "ja_JP",
|
||||||
|
"zh-Hant": "zh_HK",
|
||||||
|
}
|
||||||
|
|
||||||
// Generate metadata per locale
|
// Generate metadata per locale
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -48,22 +55,19 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ lang: string }>
|
params: Promise<{ lang: string }>
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { lang: rawLang } = await params
|
const { lang: rawLang } = await params
|
||||||
const lang = (
|
const lang: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
|
||||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
|
||||||
) as Locale
|
|
||||||
|
|
||||||
// Default to English metadata
|
|
||||||
const titles: Record<Locale, string> = {
|
const titles: Record<Locale, string> = {
|
||||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
zh: "Next AI Draw.io - AI 驱动的图表生成器",
|
||||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
|
||||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptions: Record<Locale, string> = {
|
const descriptions: Record<Locale, string> = {
|
||||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
zh: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
|
||||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
ja: "AI で AWS アーキテクチャ図、フローチャート、技術図を作成。draw.io と AI アシスタントを統合した無料オンラインツールで、プロ品質の図を簡単に作成できます。",
|
||||||
"zh-Hant":
|
"zh-Hant":
|
||||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||||
}
|
}
|
||||||
@@ -92,14 +96,7 @@ export async function generateMetadata({
|
|||||||
type: "website",
|
type: "website",
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
url: "https://next-ai-drawio.jiang.jp",
|
||||||
siteName: "Next AI Draw.io",
|
siteName: "Next AI Draw.io",
|
||||||
locale:
|
locale: OG_LOCALE[lang],
|
||||||
lang === "zh"
|
|
||||||
? "zh_CN"
|
|
||||||
: lang === "zh-Hant"
|
|
||||||
? "zh_HK"
|
|
||||||
: lang === "ja"
|
|
||||||
? "ja_JP"
|
|
||||||
: "en_US",
|
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/architecture.png",
|
url: "/architecture.png",
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ function validateValue(def: SettingDef, value: string): string | null {
|
|||||||
return value === "true" || value === "false"
|
return value === "true" || value === "false"
|
||||||
? null
|
? null
|
||||||
: 'Must be "true" or "false"'
|
: 'Must be "true" or "false"'
|
||||||
case "enum":
|
|
||||||
return def.options?.includes(value)
|
|
||||||
? null
|
|
||||||
: `Must be one of: ${def.options?.join(", ")}`
|
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
import {
|
import {
|
||||||
AdminProviderSchema,
|
AdminProviderSchema,
|
||||||
@@ -6,6 +5,7 @@ import {
|
|||||||
mergeSecrets,
|
mergeSecrets,
|
||||||
} from "@/lib/admin/providers"
|
} from "@/lib/admin/providers"
|
||||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||||
|
import { testModelConnection } from "@/lib/test-model-connection"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
|
|||||||
sameEndpoint && stored ? [stored] : [],
|
sameEndpoint && stored ? [stored] : [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Without a URL of its own, chat sends the entry's key to the server's
|
||||||
|
// <P>_BASE_URL: test that endpoint, not another one. It is the server's
|
||||||
|
// own, which chat uses without the checks for a URL a user typed.
|
||||||
|
// The admin's own request supplies the EdgeOne endpoint, cookies and
|
||||||
|
// access code, as a chat request would.
|
||||||
const serverUrl = globalBaseUrl(resolved.provider)
|
const serverUrl = globalBaseUrl(resolved.provider)
|
||||||
return validateModel(
|
return testModelConnection(
|
||||||
new Request(new URL("/api/validate-model", req.url), {
|
req,
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
provider: resolved.provider,
|
||||||
"Content-Type": "application/json",
|
apiKey: resolved.apiKey,
|
||||||
// Checked again there, in place of an access code
|
baseUrl: resolved.baseUrl || serverUrl,
|
||||||
"x-admin-password": req.headers.get("x-admin-password") || "",
|
modelId: body.modelId,
|
||||||
// The EdgeOne function checks the access code and Pages
|
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||||
// cookies, and its URL is built from the page's origin
|
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||||
"x-access-code": req.headers.get("x-access-code") || "",
|
awsRegion: resolved.awsRegion,
|
||||||
cookie: req.headers.get("cookie") || "",
|
vertexApiKey: resolved.vertexApiKey,
|
||||||
...(req.headers.get("origin") && {
|
},
|
||||||
origin: req.headers.get("origin") as string,
|
!resolved.baseUrl && !!serverUrl,
|
||||||
}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
provider: resolved.provider,
|
|
||||||
apiKey: resolved.apiKey,
|
|
||||||
// Without a URL of its own, chat sends the entry's key to
|
|
||||||
// the server's <P>_BASE_URL: test that endpoint, not
|
|
||||||
// another one. It is the server's own, which chat uses
|
|
||||||
// without the checks for a URL a user typed.
|
|
||||||
baseUrl: resolved.baseUrl || serverUrl,
|
|
||||||
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
|
||||||
modelId: body.modelId,
|
|
||||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
|
||||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
|
||||||
awsRegion: resolved.awsRegion,
|
|
||||||
vertexApiKey: resolved.vertexApiKey,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-318
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
APICallError,
|
APICallError,
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
|
||||||
createUIMessageStreamResponse,
|
|
||||||
InvalidToolInputError,
|
InvalidToolInputError,
|
||||||
|
isFileUIPart,
|
||||||
|
isTextUIPart,
|
||||||
|
pruneMessages,
|
||||||
|
safeValidateUIMessages,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { jsonrepair } from "jsonrepair"
|
import { jsonrepair } from "jsonrepair"
|
||||||
import path from "path"
|
|
||||||
import { z } from "zod"
|
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import {
|
import {
|
||||||
CACHE_POINT,
|
CACHE_POINT,
|
||||||
@@ -21,14 +21,14 @@ import {
|
|||||||
usesServerCredentials,
|
usesServerCredentials,
|
||||||
usesServerEndpoint,
|
usesServerEndpoint,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
|
||||||
import {
|
import {
|
||||||
dropInvalidToolCalls,
|
dropInvalidToolCalls,
|
||||||
fixToolInputJson,
|
fixToolInputJson,
|
||||||
formatSelectionContext,
|
formatSelectionContext,
|
||||||
replaceHistoricalToolInputs,
|
|
||||||
validateFileParts,
|
validateFileParts,
|
||||||
} from "@/lib/chat-helpers"
|
} from "@/lib/chat-helpers"
|
||||||
|
import type { ChatUIMessage } from "@/lib/chat-message"
|
||||||
|
import { chatTools } from "@/lib/chat-tools"
|
||||||
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
@@ -56,50 +56,11 @@ import { getSystemPrompt } from "@/lib/system-prompts"
|
|||||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
|
||||||
import {
|
|
||||||
getShapeLibrary,
|
|
||||||
SHAPE_LIBRARY_LIST,
|
|
||||||
} from "@/packages/mcp-server/src/shape-library.ts"
|
|
||||||
import {
|
|
||||||
STYLE_CLASS_EXAMPLE,
|
|
||||||
SWIMLANE_EXAMPLE,
|
|
||||||
} from "@/packages/mcp-server/src/xml-examples.ts"
|
|
||||||
|
|
||||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||||
// which is also where Node's response-body timeout on the upstream stream lands.
|
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||||
|
|
||||||
// Helper function to create cached stream response
|
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
|
||||||
const toolCallId = `cached-${Date.now()}`
|
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
|
||||||
execute: async ({ writer }) => {
|
|
||||||
writer.write({ type: "start" })
|
|
||||||
writer.write({
|
|
||||||
type: "tool-input-start",
|
|
||||||
toolCallId,
|
|
||||||
toolName: "display_diagram",
|
|
||||||
})
|
|
||||||
writer.write({
|
|
||||||
type: "tool-input-delta",
|
|
||||||
toolCallId,
|
|
||||||
inputTextDelta: xml,
|
|
||||||
})
|
|
||||||
writer.write({
|
|
||||||
type: "tool-input-available",
|
|
||||||
toolCallId,
|
|
||||||
toolName: "display_diagram",
|
|
||||||
input: { xml },
|
|
||||||
})
|
|
||||||
writer.write({ type: "finish" })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return createUIMessageStreamResponse({ stream })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responses streamed from the model, whose trace streamText's callbacks end
|
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||||
const modelStreamResponses = new WeakSet<Response>()
|
const modelStreamResponses = new WeakSet<Response>()
|
||||||
// A Request's signal follows the client's disconnect only while the Request
|
// A Request's signal follows the client's disconnect only while the Request
|
||||||
@@ -111,6 +72,21 @@ const requestOfResponse = new WeakMap<Response, Request>()
|
|||||||
// Inner handler function
|
// Inner handler function
|
||||||
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
||||||
|
|
||||||
|
// Message structure for DEBUG_LLM_PAYLOAD logs: roles, part types, tool names
|
||||||
|
// and input keys, without the diagram XML or image data
|
||||||
|
const describeMessages = (msgs: any[]) =>
|
||||||
|
msgs.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
parts: (m.parts ?? m.content ?? []).map(
|
||||||
|
(p: any) =>
|
||||||
|
p.type +
|
||||||
|
(p.toolName ? `:${p.toolName}` : "") +
|
||||||
|
(p.input && typeof p.input === "object"
|
||||||
|
? `(${Object.keys(p.input).join(",")})`
|
||||||
|
: ""),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
async function handleChatRequest(req: Request): Promise<Response> {
|
async function handleChatRequest(req: Request): Promise<Response> {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
@@ -119,7 +95,19 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
if (accessDenied) return accessDenied
|
if (accessDenied) return accessDenied
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { messages, xml, previousXml, sessionId } = body
|
// The messages in the SDK's UI shape. The route writes its own system
|
||||||
|
// messages, so none may come from the client.
|
||||||
|
const validated = await safeValidateUIMessages<ChatUIMessage>({
|
||||||
|
messages: body.messages,
|
||||||
|
})
|
||||||
|
if (!validated.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: validated.error.message },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const messages = validated.data.filter((m) => m.role !== "system")
|
||||||
|
const { xml, previousXml, sessionId } = body
|
||||||
const customSystemMessage =
|
const customSystemMessage =
|
||||||
typeof body.customSystemMessage === "string"
|
typeof body.customSystemMessage === "string"
|
||||||
? body.customSystemMessage.slice(0, 5000)
|
? body.customSystemMessage.slice(0, 5000)
|
||||||
@@ -138,9 +126,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
||||||
const lastUserMessage = [...messages]
|
const lastUserMessage = [...messages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((m: any) => m.role === "user")
|
.find((m) => m.role === "user")
|
||||||
const userInputText =
|
const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || ""
|
||||||
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
|
||||||
|
|
||||||
// Update Langfuse trace with input, session, and user
|
// Update Langfuse trace with input, session, and user
|
||||||
setTraceInput({
|
setTraceInput({
|
||||||
@@ -156,23 +143,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
// === FILE VALIDATION END ===
|
// === FILE VALIDATION END ===
|
||||||
|
|
||||||
// === CACHE CHECK START ===
|
|
||||||
const isFirstMessage = messages.length === 1
|
|
||||||
const isEmptyDiagram = !xml || !hasCells(xml)
|
|
||||||
|
|
||||||
if (isFirstMessage && isEmptyDiagram) {
|
|
||||||
const lastMessage = messages[0]
|
|
||||||
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
|
|
||||||
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
|
|
||||||
|
|
||||||
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return createCachedStreamResponse(cached.xml)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// === CACHE CHECK END ===
|
|
||||||
|
|
||||||
// Read client AI provider overrides from headers
|
// Read client AI provider overrides from headers
|
||||||
const provider = req.headers.get("x-ai-provider")
|
const provider = req.headers.get("x-ai-provider")
|
||||||
let baseUrl = req.headers.get("x-ai-base-url")
|
let baseUrl = req.headers.get("x-ai-base-url")
|
||||||
@@ -348,16 +318,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
|
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
// System prompt, with the model name filled in and the style section chosen
|
||||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
const finalSystemMessage = customSystemMessage
|
const finalSystemMessage = customSystemMessage
|
||||||
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
||||||
: systemMessage
|
: systemMessage
|
||||||
|
|
||||||
// Extract file parts (images) from the last user message
|
// Extract file parts (images) from the last user message
|
||||||
const fileParts =
|
const fileParts = lastUserMessage?.parts.filter(isFileUIPart) ?? []
|
||||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
|
||||||
[]
|
|
||||||
|
|
||||||
// Note: we used to pre-emptively reject images for models we guessed were
|
// Note: we used to pre-emptively reject images for models we guessed were
|
||||||
// text-only (by name matching). That heuristic misfired on newer models
|
// text-only (by name matching). That heuristic misfired on newer models
|
||||||
@@ -370,98 +338,37 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
${userInputText}
|
${userInputText}
|
||||||
"""`
|
"""`
|
||||||
|
|
||||||
// Convert UIMessages to ModelMessages and add system message. A tool
|
// Convert UIMessages to ModelMessages. A tool call that never got its
|
||||||
// call that never got its result (the user stopped while it ran) is
|
// result (the user stopped while it ran) is left out: the SDK would
|
||||||
// left out: the SDK would refuse this and every later request of the
|
// refuse this and every later request of the chat
|
||||||
// chat (MissingToolResultsError)
|
// (MissingToolResultsError). Messages left without content (a notice
|
||||||
const modelMessages = await convertToModelMessages(messages, {
|
// the chat shows is a data part, which the model never sees) are
|
||||||
ignoreIncompleteToolCalls: true,
|
// dropped: Bedrock rejects empty content.
|
||||||
|
const modelMessages = pruneMessages({
|
||||||
|
messages: await convertToModelMessages(messages, {
|
||||||
|
ignoreIncompleteToolCalls: true,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
||||||
if (DEBUG_LLM_PAYLOAD) {
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
console.log(
|
||||||
messages.forEach((msg: any, idx: number) => {
|
"[route.ts] incoming",
|
||||||
console.log(
|
JSON.stringify(describeMessages(messages)),
|
||||||
`[route.ts] Message ${idx} role:`,
|
)
|
||||||
msg.role,
|
|
||||||
"parts count:",
|
|
||||||
msg.parts?.length,
|
|
||||||
)
|
|
||||||
if (msg.parts) {
|
|
||||||
msg.parts.forEach((part: any, partIdx: number) => {
|
|
||||||
if (
|
|
||||||
part.type === "tool-invocation" ||
|
|
||||||
part.type === "tool-result"
|
|
||||||
) {
|
|
||||||
console.log(`[route.ts] Part ${partIdx}:`, {
|
|
||||||
type: part.type,
|
|
||||||
toolName: part.toolName,
|
|
||||||
hasInput: !!part.input,
|
|
||||||
inputType: typeof part.input,
|
|
||||||
inputKeys:
|
|
||||||
part.input && typeof part.input === "object"
|
|
||||||
? Object.keys(part.input)
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace historical tool call XML with placeholders to reduce tokens
|
|
||||||
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
|
|
||||||
const enableHistoryReplace =
|
|
||||||
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
|
|
||||||
const placeholderMessages = enableHistoryReplace
|
|
||||||
? replaceHistoricalToolInputs(modelMessages)
|
|
||||||
: modelMessages
|
|
||||||
|
|
||||||
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
|
||||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
|
||||||
let enhancedMessages = placeholderMessages.filter(
|
|
||||||
(msg: any) =>
|
|
||||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
||||||
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
||||||
// JSON object, and every provider rejects a tool result whose call is gone.
|
// JSON object, and every provider rejects a tool result whose call is gone.
|
||||||
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
let enhancedMessages = dropInvalidToolCalls(modelMessages)
|
||||||
|
|
||||||
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
||||||
if (DEBUG_LLM_PAYLOAD) {
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
console.log(
|
||||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
"[route.ts] to model",
|
||||||
console.log(
|
JSON.stringify(describeMessages(enhancedMessages)),
|
||||||
`[route.ts] ModelMsg ${idx} role:`,
|
)
|
||||||
msg.role,
|
|
||||||
"content count:",
|
|
||||||
msg.content?.length,
|
|
||||||
)
|
|
||||||
if (msg.content) {
|
|
||||||
msg.content.forEach((part: any, partIdx: number) => {
|
|
||||||
if (
|
|
||||||
part.type === "tool-call" ||
|
|
||||||
part.type === "tool-result"
|
|
||||||
) {
|
|
||||||
console.log(`[route.ts] Content ${partIdx}:`, {
|
|
||||||
type: part.type,
|
|
||||||
toolName: part.toolName,
|
|
||||||
hasInput: !!part.input,
|
|
||||||
inputType: typeof part.input,
|
|
||||||
inputValue:
|
|
||||||
part.input === undefined
|
|
||||||
? "undefined"
|
|
||||||
: part.input === null
|
|
||||||
? "null"
|
|
||||||
: "object",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the last message with user input only (XML moved to separate cached system message)
|
// Update the last message with user input only (XML moved to separate cached system message)
|
||||||
@@ -566,13 +473,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
|
|
||||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||||
|
|
||||||
// Set by onAbort, which records the finished steps' tokens itself
|
|
||||||
let stopped = false
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
// The system messages carry cache points, so they go in messages.
|
// The system messages carry cache points, so they go in messages
|
||||||
// A client's own system messages have string content and were
|
// (the client's own system messages were dropped above)
|
||||||
// dropped by the empty-content filter above.
|
|
||||||
allowSystemInMessages: true,
|
allowSystemInMessages: true,
|
||||||
abortSignal: req.signal,
|
abortSignal: req.signal,
|
||||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||||
@@ -619,166 +523,31 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
},
|
},
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
||||||
// Langfuse telemetry config (returns undefined if not configured)
|
// Langfuse telemetry (undefined when not configured)
|
||||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
experimental_telemetry: getTelemetryConfig({
|
||||||
experimental_telemetry: getTelemetryConfig({
|
sessionId: validSessionId,
|
||||||
sessionId: validSessionId,
|
userId,
|
||||||
userId,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
onFinish: ({ text, totalUsage }) => {
|
// The quota counts each step as it ends: a later step that fails or
|
||||||
|
// is stopped has still used these tokens (onFinish runs only for a
|
||||||
|
// completed answer). Awaited, so the write is done before the host
|
||||||
|
// may freeze the function after the answer ends.
|
||||||
|
onStepFinish: async ({ usage }) => {
|
||||||
|
if (countsQuota) {
|
||||||
|
await recordTokenUsage(userId, usage.totalTokens ?? 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinish: ({ text }) => {
|
||||||
// AI SDK 6 telemetry auto-reports token usage on its spans
|
// AI SDK 6 telemetry auto-reports token usage on its spans
|
||||||
setTraceOutput(text)
|
setTraceOutput(text)
|
||||||
|
|
||||||
// Record token usage for server-side quota tracking (if enabled)
|
|
||||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
|
||||||
// inputTokens already includes cache reads and writes in AI SDK 6
|
|
||||||
if (countsQuota && totalUsage && !stopped) {
|
|
||||||
const totalTokens =
|
|
||||||
(totalUsage.inputTokens || 0) +
|
|
||||||
(totalUsage.outputTokens || 0)
|
|
||||||
recordTokenUsage(userId, totalTokens)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
||||||
onError: ({ error }) => {
|
onError: ({ error }) => {
|
||||||
console.error(error) // what AI SDK does without an onError
|
console.error(error) // what AI SDK does without an onError
|
||||||
endTrace()
|
endTrace()
|
||||||
},
|
},
|
||||||
onAbort: ({ steps }) => {
|
onAbort: () => endTrace(),
|
||||||
stopped = true
|
tools: chatTools,
|
||||||
endTrace()
|
|
||||||
// Stopped (or disconnected) after some steps finished: their
|
|
||||||
// tokens were used, or stopping every request after a costly
|
|
||||||
// first step would get around the token limits
|
|
||||||
if (countsQuota) {
|
|
||||||
const tokens = steps.reduce(
|
|
||||||
(sum, step) =>
|
|
||||||
sum +
|
|
||||||
(step.usage.inputTokens || 0) +
|
|
||||||
(step.usage.outputTokens || 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if (tokens > 0) recordTokenUsage(userId, tokens)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
// Client-side tool that will be executed on the client
|
|
||||||
display_diagram: {
|
|
||||||
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
|
|
||||||
|
|
||||||
VALIDATION RULES (XML will be rejected if violated):
|
|
||||||
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
|
||||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
|
||||||
3. All mxCell elements must be siblings - never nested
|
|
||||||
4. Every mxCell needs a unique id (start from "2")
|
|
||||||
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
|
|
||||||
6. Escape special chars in values: < > & "
|
|
||||||
|
|
||||||
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),
|
||||||
}),
|
}),
|
||||||
@@ -790,17 +559,6 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||||
// user's own, and EdgeOne's text is our function's explanation.
|
// user's own, and EdgeOne's text is our function's explanation.
|
||||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||||
messageMetadata: ({ part }) => {
|
|
||||||
if (part.type === "finish") {
|
|
||||||
const usage = (part as any).totalUsage
|
|
||||||
// AI SDK 6 provides totalTokens directly
|
|
||||||
return {
|
|
||||||
totalTokens: usage?.totalTokens ?? 0,
|
|
||||||
finishReason: (part as any).finishReason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
modelStreamResponses.add(response)
|
modelStreamResponses.add(response)
|
||||||
requestOfResponse.set(response, req)
|
requestOfResponse.set(response, req)
|
||||||
@@ -840,7 +598,7 @@ async function safeHandler(req: Request): Promise<Response> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
response = handleError(error)
|
response = handleError(error)
|
||||||
}
|
}
|
||||||
// Early returns, cache hits and errors never reach streamText's callbacks,
|
// Early returns and errors never reach streamText's callbacks,
|
||||||
// so their Langfuse trace has to be ended here
|
// so their Langfuse trace has to be ended here
|
||||||
if (!modelStreamResponses.has(response)) endTrace()
|
if (!modelStreamResponses.has(response)) endTrace()
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
# Draw.io XML Schema Guide
|
|
||||||
|
|
||||||
This guide explains the structure of draw.io (diagrams.net) XML files to help you understand and create diagrams programmatically.
|
|
||||||
|
|
||||||
## Basic Structure
|
|
||||||
|
|
||||||
A draw.io XML file has the following hierarchy:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxfile>
|
|
||||||
<diagram>
|
|
||||||
<mxGraphModel>
|
|
||||||
<root>
|
|
||||||
<mxCell /> <!-- Cells that make up the diagram -->
|
|
||||||
</root>
|
|
||||||
</mxGraphModel>
|
|
||||||
</diagram>
|
|
||||||
</mxfile>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Root Element: `<mxfile>`
|
|
||||||
|
|
||||||
The root element of a draw.io file.
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
|
|
||||||
- `host`: The application that created the file (e.g., "app.diagrams.net")
|
|
||||||
- `modified`: Last modification timestamp
|
|
||||||
- `agent`: Browser / user agent information
|
|
||||||
- `version`: Version of the application
|
|
||||||
- `type`: File type (usually "device" or "google")
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxfile host="app.diagrams.net" modified="2023-07-14T10:20:30.123Z" agent="Mozilla/5.0" version="21.5.2" type="device">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Diagram Element: `<diagram>`
|
|
||||||
|
|
||||||
Each page in your draw.io document is represented by a `<diagram>` element.
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
|
|
||||||
- `id`: Unique identifier for the diagram
|
|
||||||
- `name`: The name of the diagram / page
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<diagram id="pWHN0msd4Ud1ZK5cD-Hr" name="Page-1">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graph Model: `<mxGraphModel>`
|
|
||||||
|
|
||||||
Contains the actual diagram data.
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
|
|
||||||
- `dx`: Grid size in x-direction (usually 1)
|
|
||||||
- `dy`: Grid size in y-direction (usually 1)
|
|
||||||
- `grid`: Whether grid is enabled (0 or 1)
|
|
||||||
- `gridSize`: Grid cell size (usually 10)
|
|
||||||
- `guides`: Whether guides are enabled (0 or 1)
|
|
||||||
- `tooltips`: Whether tooltips are enabled (0 or 1)
|
|
||||||
- `connect`: Whether connections are enabled (0 or 1)
|
|
||||||
- `arrows`: Whether arrows are enabled (0 or 1)
|
|
||||||
- `fold`: Whether folding is enabled (0 or 1)
|
|
||||||
- `page`: Whether page view is enabled (0 or 1)
|
|
||||||
- `pageScale`: Scale of the page (usually 1)
|
|
||||||
- `pageWidth`: Width of the page (e.g., 850)
|
|
||||||
- `pageHeight`: Height of the page (e.g., 1100)
|
|
||||||
- `math`: Whether math typesetting is enabled (0 or 1)
|
|
||||||
- `shadow`: Whether shadows are enabled (0 or 1)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxGraphModel dx="1" dy="1" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Root Cell Container: `<root>`
|
|
||||||
|
|
||||||
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
|
|
||||||
|
|
||||||
**Internal structure (auto-generated):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<root>
|
|
||||||
<mxCell id="0"/> <!-- Auto-added -->
|
|
||||||
<mxCell id="1" parent="0"/> <!-- Auto-added -->
|
|
||||||
<!-- Your mxCell elements go here (start from id="2") -->
|
|
||||||
</root>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cell Elements: `<mxCell>`
|
|
||||||
|
|
||||||
The basic building block of diagrams. Cells represent shapes, connectors, text, etc.
|
|
||||||
|
|
||||||
**Attributes for all cells:**
|
|
||||||
|
|
||||||
- `id`: Unique identifier for the cell
|
|
||||||
- `parent`: ID of the parent cell (typically "1" for most cells)
|
|
||||||
- `value`: Text content of the cell
|
|
||||||
- `style`: Styling information (see Style section below)
|
|
||||||
|
|
||||||
**Attributes for shapes (vertices):**
|
|
||||||
|
|
||||||
- `vertex`: Set to "1" for shapes
|
|
||||||
- `connectable`: Whether the shape can be connected (0 or 1)
|
|
||||||
|
|
||||||
**Attributes for connectors (edges):**
|
|
||||||
|
|
||||||
- `edge`: Set to "1" for connectors
|
|
||||||
- `source`: ID of the source cell
|
|
||||||
- `target`: ID of the target cell
|
|
||||||
|
|
||||||
**Example (Rectangle shape):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="2" value="Hello World" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example (Connector):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="3" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="2" target="4">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="400" y="430" as="sourcePoint"/>
|
|
||||||
<mxPoint x="450" y="380" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Geometry: `<mxGeometry>`
|
|
||||||
|
|
||||||
Defines the position and dimensions of cells.
|
|
||||||
|
|
||||||
**Attributes for shapes:**
|
|
||||||
|
|
||||||
- `x`: The x-coordinate of the **top-left** point of the shape.
|
|
||||||
- `y`: The y-coordinate of the **top-left** point of the shape.
|
|
||||||
- `width`: The width of the shape.
|
|
||||||
- `height`: The height of the shape.
|
|
||||||
- `as`: Specifies the role of this geometry within its parent cell. Typically set to `"geometry"` for the main shape definition.
|
|
||||||
|
|
||||||
**Attributes for connectors:**
|
|
||||||
|
|
||||||
- `relative`: Set to "1" for relative geometry
|
|
||||||
- `as`: Set to "geometry"
|
|
||||||
|
|
||||||
**Example for shapes:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example for connectors:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="400" y="430" as="sourcePoint"/>
|
|
||||||
<mxPoint x="450" y="380" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cell Style Reference
|
|
||||||
|
|
||||||
Styles are specified as semicolon-separated `key=value` pairs in the `style` attribute of `<mxCell>` elements.
|
|
||||||
|
|
||||||
### Shape-specific Styles
|
|
||||||
|
|
||||||
- Rectangle: `shape=rectangle`
|
|
||||||
- Ellipse: `shape=ellipse`
|
|
||||||
- Triangle: `shape=triangle`
|
|
||||||
- Rhombus: `shape=rhombus`
|
|
||||||
- Hexagon: `shape=hexagon`
|
|
||||||
- Cloud: `shape=cloud`
|
|
||||||
- Actor: `shape=actor`
|
|
||||||
- Cylinder: `shape=cylinder`
|
|
||||||
- Document: `shape=document`
|
|
||||||
- Note: `shape=note`
|
|
||||||
- Card: `shape=card`
|
|
||||||
- Parallelogram: `shape=parallelogram`
|
|
||||||
|
|
||||||
### Connector Styles
|
|
||||||
|
|
||||||
- `endArrow=classic`: Arrow type at the end (classic, open, oval, diamond, block)
|
|
||||||
- `startArrow=none`: Arrow type at the start (none, classic, open, oval, diamond)
|
|
||||||
- `curved=1`: Curved connector (0 or 1)
|
|
||||||
- `edgeStyle=orthogonalEdgeStyle`: Connector routing style
|
|
||||||
- `elbow=vertical`: Elbow direction (vertical, horizontal)
|
|
||||||
- `jumpStyle=arc`: Jump style for line crossing (arc, gap)
|
|
||||||
- `jumpSize=10`: Size of the jump
|
|
||||||
|
|
||||||
## Special Cells
|
|
||||||
|
|
||||||
Draw.io files contain two special cells that are always present:
|
|
||||||
|
|
||||||
1. **Root Cell** (id = "0"): The parent of all cells
|
|
||||||
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
|
|
||||||
|
|
||||||
## Tips for Creating Draw.io XML
|
|
||||||
|
|
||||||
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
|
|
||||||
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
|
|
||||||
3. Assign unique and sequential IDs to all cells
|
|
||||||
4. Define parent relationships correctly (use parent="1" for top-level shapes)
|
|
||||||
5. Use `mxGeometry` elements to position shapes
|
|
||||||
6. For connectors, specify `source` and `target` attributes
|
|
||||||
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Grouping Elements
|
|
||||||
|
|
||||||
To group elements, create a parent cell and set other cells' `parent` attribute to its ID:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Group container -->
|
|
||||||
<mxCell id="10" value="Group" style="group" vertex="1" connectable="0" parent="1">
|
|
||||||
<mxGeometry x="200" y="200" width="200" height="100" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<!-- Elements inside the group -->
|
|
||||||
<mxCell id="11" value="Element 1" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
|
|
||||||
<mxGeometry width="90" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="12" value="Element 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
|
|
||||||
<mxGeometry x="110" width="90" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Swimlanes
|
|
||||||
|
|
||||||
Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<root>
|
|
||||||
<mxCell id="0"/>
|
|
||||||
<mxCell id="1" parent="0"/>
|
|
||||||
<!-- Swimlane 1 -->
|
|
||||||
<mxCell id="lane1" value="Frontend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="40" y="40" width="200" height="300" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Swimlane 2 -->
|
|
||||||
<mxCell id="lane2" value="Backend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="280" y="40" width="200" height="300" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Step inside lane1 (parent="lane1") -->
|
|
||||||
<mxCell id="step1" value="Send Request" style="rounded=1;" vertex="1" parent="lane1">
|
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Step inside lane2 (parent="lane2") -->
|
|
||||||
<mxCell id="step2" value="Process" style="rounded=1;" vertex="1" parent="lane2">
|
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->
|
|
||||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
</root>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tables
|
|
||||||
|
|
||||||
Tables use multiple cells with parent-child relationships:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="30" value="Table" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="200" y="200" width="180" height="120" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="31" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[0,0.5,1,0.5];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="30">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### Custom Attributes
|
|
||||||
|
|
||||||
Draw.io allows adding custom attributes to cells:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="40" value="Custom Element" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="200" y="200" width="120" height="60" as="geometry"/>
|
|
||||||
<Object label="Custom Label" customAttr="value" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
These custom attributes can store additional metadata or be used by plugins and custom behaviors.
|
|
||||||
|
|
||||||
### User-defined Styles
|
|
||||||
|
|
||||||
You can define custom styles for cells by combining various style attributes:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="50" value="Custom Styled Cell"
|
|
||||||
style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#f8cecc;strokeColor=#b85450;strokeWidth=2;fontSize=14;fontStyle=1"
|
|
||||||
vertex="1" parent="1">
|
|
||||||
<mxGeometry x="300" y="200" width="120" height="80" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Layers
|
|
||||||
|
|
||||||
You can create multiple layers in a diagram to organize complex diagrams:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Default layer (always present) -->
|
|
||||||
<mxCell id="1" parent="0"/>
|
|
||||||
|
|
||||||
<!-- Additional custom layer -->
|
|
||||||
<mxCell id="60" value="Layer 2" style="locked=0;group=" parent="0"/>
|
|
||||||
|
|
||||||
<!-- Elements in Layer 2 -->
|
|
||||||
<mxCell id="61" value="Element in Layer 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="60">
|
|
||||||
<mxGeometry x="200" y="300" width="120" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
@@ -3,8 +3,5 @@ import { NextResponse } from "next/server"
|
|||||||
export async function GET() {
|
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,6 +1,5 @@
|
|||||||
import { randomUUID } from "crypto"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
const feedbackSchema = z.object({
|
const feedbackSchema = z.object({
|
||||||
@@ -37,75 +36,24 @@ export async function POST(req: Request) {
|
|||||||
const userId = getUserIdFromRequest(req)
|
const userId = getUserIdFromRequest(req)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the most recent chat trace for this session to attach the score to
|
// Score the session's latest chat trace; without one, a standalone
|
||||||
const tracesResponse = await langfuse.api.trace.list({
|
// feedback trace carries the score
|
||||||
|
await scoreLatestTrace(langfuse, {
|
||||||
sessionId,
|
sessionId,
|
||||||
limit: 1,
|
name: "user-feedback",
|
||||||
|
value: feedback === "good" ? 1 : 0,
|
||||||
|
comment: `User gave ${feedback} feedback`,
|
||||||
|
fallbackTrace: {
|
||||||
|
name: "user-feedback",
|
||||||
|
userId,
|
||||||
|
input: { messageId, feedback },
|
||||||
|
metadata: {
|
||||||
|
source: "feedback-button",
|
||||||
|
note: "standalone - no chat trace found",
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const traces = tracesResponse.data || []
|
|
||||||
const latestTrace = traces[0]
|
|
||||||
|
|
||||||
if (!latestTrace) {
|
|
||||||
// No trace found for this session - create a standalone feedback trace
|
|
||||||
const traceId = randomUUID()
|
|
||||||
const timestamp = new Date().toISOString()
|
|
||||||
|
|
||||||
await langfuse.api.ingestion.batch({
|
|
||||||
batch: [
|
|
||||||
{
|
|
||||||
type: "trace-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: traceId,
|
|
||||||
name: "user-feedback",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
input: { messageId, feedback },
|
|
||||||
metadata: {
|
|
||||||
source: "feedback-button",
|
|
||||||
note: "standalone - no chat trace found",
|
|
||||||
},
|
|
||||||
timestamp,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "score-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: randomUUID(),
|
|
||||||
traceId,
|
|
||||||
name: "user-feedback",
|
|
||||||
value: feedback === "good" ? 1 : 0,
|
|
||||||
comment: `User gave ${feedback} feedback`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Attach score to the existing chat trace
|
|
||||||
const timestamp = new Date().toISOString()
|
|
||||||
|
|
||||||
await langfuse.api.ingestion.batch({
|
|
||||||
batch: [
|
|
||||||
{
|
|
||||||
type: "score-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: randomUUID(),
|
|
||||||
traceId: latestTrace.id,
|
|
||||||
name: "user-feedback",
|
|
||||||
value: feedback === "good" ? 1 : 0,
|
|
||||||
comment: `User gave ${feedback} feedback`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ success: true, logged: true })
|
return Response.json({ success: true, logged: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Langfuse feedback error:", error)
|
console.error("Langfuse feedback error:", error)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "crypto"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||||
|
|
||||||
const saveSchema = z.object({
|
const saveSchema = z.object({
|
||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString()
|
// Flag the session's latest chat trace as saved; a session without a
|
||||||
|
// trace (no chat yet) is skipped
|
||||||
// Find the most recent chat trace for this session to attach the save flag
|
const logged = await scoreLatestTrace(langfuse, {
|
||||||
const tracesResponse = await langfuse.api.trace.list({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
limit: 1,
|
name: "diagram-saved",
|
||||||
|
value: 1,
|
||||||
|
comment: `User saved diagram as ${filename}.${format}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const traces = tracesResponse.data || []
|
return Response.json({ success: true, logged })
|
||||||
const latestTrace = traces[0]
|
|
||||||
|
|
||||||
if (latestTrace) {
|
|
||||||
// Add a score to the existing trace to flag that user saved
|
|
||||||
await langfuse.api.ingestion.batch({
|
|
||||||
batch: [
|
|
||||||
{
|
|
||||||
type: "score-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: randomUUID(),
|
|
||||||
traceId: latestTrace.id,
|
|
||||||
name: "diagram-saved",
|
|
||||||
value: 1,
|
|
||||||
comment: `User saved diagram as ${filename}.${format}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// If no trace found, skip logging (user hasn't chatted yet)
|
|
||||||
|
|
||||||
return Response.json({ success: true, logged: !!latestTrace })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Langfuse save error:", error)
|
console.error("Langfuse save error:", error)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const models = await loadFlattenedServerModels()
|
const models = await loadFlattenedServerModels()
|
||||||
return NextResponse.json({
|
return NextResponse.json({ models })
|
||||||
models,
|
|
||||||
hasConfig: models.length > 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* API endpoint for VLM-based diagram validation.
|
* API endpoint for VLM-based diagram validation.
|
||||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
* Accepts a PNG image and answers with the validation result as JSON.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Output, streamText } from "ai"
|
import { generateText, Output } from "ai"
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import { getValidationModel } from "@/lib/ai-providers"
|
import { getValidationModel } from "@/lib/ai-providers"
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
recordTokenUsage,
|
recordTokenUsage,
|
||||||
} from "@/lib/dynamo-quota-manager"
|
} from "@/lib/dynamo-quota-manager"
|
||||||
|
import { getTelemetryConfig } from "@/lib/langfuse"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||||
import {
|
import {
|
||||||
@@ -35,13 +36,6 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
|||||||
suggestions: [],
|
suggestions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A fixed result in the text format useObject reads */
|
|
||||||
function createStreamingResponse(result: ValidationResult): Response {
|
|
||||||
return new Response(JSON.stringify(result), {
|
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
export async function POST(req: Request): Promise<Response> {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
@@ -53,7 +47,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
// Check if VLM validation is enabled (default: true)
|
// Check if VLM validation is enabled (default: true)
|
||||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||||
if (!enableValidation) {
|
if (!enableValidation) {
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
return Response.json(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: ValidateDiagramRequest = await req.json()
|
const body: ValidateDiagramRequest = await req.json()
|
||||||
@@ -123,7 +117,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
// Return valid if no vision model is configured
|
// Return valid if no vision model is configured
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
return Response.json(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||||
@@ -133,8 +127,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||||
) || 10000
|
) || 10000
|
||||||
|
|
||||||
// Stream the VLM response for useObject consumption
|
const { output, totalUsage } = await generateText({
|
||||||
const result = streamText({
|
|
||||||
model,
|
model,
|
||||||
output: Output.object({ schema: ValidationResultSchema }),
|
output: Output.object({ schema: ValidationResultSchema }),
|
||||||
system: VALIDATION_SYSTEM_PROMPT,
|
system: VALIDATION_SYSTEM_PROMPT,
|
||||||
@@ -154,24 +147,25 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
maxOutputTokens: 1024,
|
maxOutputTokens: 1024,
|
||||||
abortSignal: AbortSignal.timeout(timeout),
|
// Ends with the request: the user pressed Stop, or left
|
||||||
onFinish: ({ output, totalUsage }) => {
|
abortSignal: req.signal,
|
||||||
if (countsQuota && totalUsage) {
|
timeout,
|
||||||
recordTokenUsage(
|
// Its usage and cost show in Langfuse next to the chat's
|
||||||
userId,
|
experimental_telemetry: getTelemetryConfig({
|
||||||
(totalUsage.inputTokens || 0) +
|
functionId: "validate-diagram",
|
||||||
(totalUsage.outputTokens || 0),
|
sessionId,
|
||||||
)
|
userId,
|
||||||
}
|
}),
|
||||||
if (sessionId && output) {
|
|
||||||
console.log(
|
|
||||||
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
if (countsQuota) {
|
||||||
return result.toTextStreamResponse()
|
await recordTokenUsage(userId, totalUsage.totalTokens ?? 0)
|
||||||
|
}
|
||||||
|
if (sessionId) {
|
||||||
|
console.log(
|
||||||
|
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Response.json(output)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log with session context if available
|
// Log with session context if available
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -179,6 +173,6 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
console.error("[validate-diagram] Error:", errorMessage)
|
console.error("[validate-diagram] Error:", errorMessage)
|
||||||
|
|
||||||
// On error, return valid to not block the user
|
// On error, return valid to not block the user
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
return Response.json(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-233
@@ -1,249 +1,29 @@
|
|||||||
import { streamText, tool } from "ai"
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { z } from "zod"
|
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
|
||||||
import {
|
import {
|
||||||
edgeOneEndpoint,
|
type TestModelRequest,
|
||||||
getAIModel,
|
testModelConnection,
|
||||||
globalBaseUrl,
|
} from "@/lib/test-model-connection"
|
||||||
usesServerCredentials,
|
|
||||||
usesServerEndpoint,
|
|
||||||
} from "@/lib/ai-providers"
|
|
||||||
import {
|
|
||||||
checkAndIncrementRequest,
|
|
||||||
isQuotaEnabled,
|
|
||||||
} from "@/lib/dynamo-quota-manager"
|
|
||||||
import { classifyLLMError } from "@/lib/llm-errors"
|
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|
||||||
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
interface ValidateRequest {
|
// The Settings dialog's Test button: a model on the user's own credentials
|
||||||
provider: ProviderName
|
|
||||||
apiKey: string
|
|
||||||
baseUrl?: string
|
|
||||||
modelId: string
|
|
||||||
// AWS Bedrock specific
|
|
||||||
awsAccessKeyId?: string
|
|
||||||
awsSecretAccessKey?: string
|
|
||||||
awsRegion?: string
|
|
||||||
awsSessionToken?: string
|
|
||||||
// Vertex AI specific
|
|
||||||
vertexApiKey?: string // Express Mode API key
|
|
||||||
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
|
||||||
serverBaseUrl?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEST_TIMEOUT_MS = 15_000
|
|
||||||
|
|
||||||
// Drawing works through tool calls, so the test asks for one
|
|
||||||
const PING_TOOL = tool({
|
|
||||||
description: "Report that the connection works.",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const NO_TOOL_CALL_WARNING =
|
|
||||||
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
// Lets the server send requests to arbitrary URLs, so require the access
|
// Lets the server send requests to arbitrary URLs, so require the access code
|
||||||
// code, or the admin password (the admin panel's Test button)
|
|
||||||
const accessError = checkAccessCode(req)
|
const accessError = checkAccessCode(req)
|
||||||
if (accessError && checkAdminAuth(req)) return accessError
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
let body: TestModelRequest
|
||||||
try {
|
try {
|
||||||
const body: ValidateRequest = await req.json()
|
body = await req.json()
|
||||||
const {
|
} catch {
|
||||||
provider,
|
|
||||||
apiKey,
|
|
||||||
modelId,
|
|
||||||
awsAccessKeyId,
|
|
||||||
awsSecretAccessKey,
|
|
||||||
awsRegion,
|
|
||||||
awsSessionToken,
|
|
||||||
// Note: Express Mode only needs vertexApiKey
|
|
||||||
vertexApiKey,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "Provider and model ID are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// EdgeOne is this site's own function, as in the chat; the admin
|
|
||||||
// panel's Test sends no URL, and a relative one cannot be fetched
|
|
||||||
const baseUrl =
|
|
||||||
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
|
||||||
// The admin panel's Test of an entry without a URL sends the
|
|
||||||
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
|
||||||
// user chose, so no private-address or redirect rules
|
|
||||||
const serverUrl =
|
|
||||||
body.serverBaseUrl === true &&
|
|
||||||
!!baseUrl &&
|
|
||||||
baseUrl === globalBaseUrl(provider) &&
|
|
||||||
!checkAdminAuth(req)
|
|
||||||
|
|
||||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
|
||||||
if (
|
|
||||||
baseUrl &&
|
|
||||||
!serverUrl &&
|
|
||||||
!allowPrivateUrls() &&
|
|
||||||
(await isPrivateUrl(baseUrl))
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "Invalid base URL" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate credentials based on provider
|
|
||||||
if (provider === "bedrock") {
|
|
||||||
if (
|
|
||||||
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
|
|
||||||
!awsRegion
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (provider === "vertexai") {
|
|
||||||
if (!vertexApiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: "Vertex AI API key is required for Express Mode",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "API key is required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// The Test button checks the user's own provider. On the server's
|
|
||||||
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
|
||||||
if (
|
|
||||||
usesServerCredentials(provider, {
|
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
awsAccessKeyId,
|
|
||||||
awsSecretAccessKey,
|
|
||||||
vertexApiKey,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "API key is required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// On the deployment's own endpoints a Test runs a model as a chat
|
|
||||||
// does, so with the quota on it counts as a chat request (an
|
|
||||||
// admin's Test of the server's URL does not)
|
|
||||||
const userId = getUserIdFromRequest(req)
|
|
||||||
if (
|
|
||||||
isQuotaEnabled() &&
|
|
||||||
!serverUrl &&
|
|
||||||
userId !== "anonymous" &&
|
|
||||||
(await usesServerEndpoint(
|
|
||||||
provider,
|
|
||||||
normalizeBaseUrl(body.baseUrl ?? ""),
|
|
||||||
apiKey,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
|
||||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
|
||||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
|
||||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
|
||||||
})
|
|
||||||
if (!quotaCheck.allowed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: quotaCheck.error },
|
|
||||||
{ status: 429 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same model the chat would use. A client base URL makes it
|
|
||||||
// refuse redirects to internal hosts.
|
|
||||||
const { model } = getAIModel({
|
|
||||||
provider,
|
|
||||||
modelId,
|
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
trustedBaseUrl: serverUrl,
|
|
||||||
awsAccessKeyId,
|
|
||||||
awsSecretAccessKey,
|
|
||||||
awsRegion,
|
|
||||||
// Temporary AWS credentials need it, as in the chat
|
|
||||||
awsSessionToken,
|
|
||||||
vertexApiKey,
|
|
||||||
// EdgeOne checks the Pages cookies and the access code
|
|
||||||
...(provider === "edgeone" && {
|
|
||||||
headers: {
|
|
||||||
cookie: req.headers.get("cookie") || "",
|
|
||||||
"x-access-code": req.headers.get("x-access-code") || "",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Streaming, like the chat (some models only stream). Stop at the
|
|
||||||
// first tool call; a reasoning model that runs out of tokens first
|
|
||||||
// proves the connection but not tool support.
|
|
||||||
const startTime = Date.now()
|
|
||||||
const result = streamText({
|
|
||||||
model,
|
|
||||||
prompt: "Call the ping tool.",
|
|
||||||
tools: { ping: PING_TOOL },
|
|
||||||
maxOutputTokens: 1024,
|
|
||||||
maxRetries: 0,
|
|
||||||
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
|
||||||
})
|
|
||||||
let calledTool = false
|
|
||||||
let finishReason: string | undefined
|
|
||||||
for await (const part of result.fullStream) {
|
|
||||||
if (part.type === "error") throw part.error
|
|
||||||
// The timeout ends the stream with an abort part, not an error
|
|
||||||
if (part.type === "abort") {
|
|
||||||
const timeout = new Error(
|
|
||||||
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
|
||||||
)
|
|
||||||
timeout.name = "TimeoutError"
|
|
||||||
throw timeout
|
|
||||||
}
|
|
||||||
if (part.type === "tool-call") {
|
|
||||||
calledTool = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (part.type === "finish") finishReason = part.finishReason
|
|
||||||
}
|
|
||||||
const responseTime = Date.now() - startTime
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
valid: true,
|
|
||||||
responseTime,
|
|
||||||
...(!calledTool &&
|
|
||||||
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[validate-model] Error:", error)
|
|
||||||
|
|
||||||
const { code, message } = classifyLLMError(error)
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, code, error: message },
|
{ valid: false, error: "Invalid JSON body" },
|
||||||
{ status: 200 }, // Return 200 so client can read error message
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// A URL in the request is one the user typed, never the server's own
|
||||||
|
return testModelConnection(req, body, false)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-22
@@ -98,9 +98,7 @@
|
|||||||
--shadow-pop:
|
--shadow-pop:
|
||||||
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
|
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
|
||||||
|
|
||||||
--duration-normal: 200ms;
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -241,13 +239,6 @@
|
|||||||
.shadow-pop {
|
.shadow-pop {
|
||||||
box-shadow: var(--shadow-pop);
|
box-shadow: var(--shadow-pop);
|
||||||
}
|
}
|
||||||
/* Older names kept for components that still use them */
|
|
||||||
.shadow-soft {
|
|
||||||
box-shadow: var(--shadow-float);
|
|
||||||
}
|
|
||||||
.shadow-dialog {
|
|
||||||
box-shadow: var(--shadow-pop);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@@ -284,17 +275,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes check-pop {
|
/* A highlight sweeps across "Thinking..." text (Shimmer) */
|
||||||
0% {
|
@keyframes shimmer {
|
||||||
transform: scale(0.8);
|
from {
|
||||||
opacity: 0;
|
background-position: 100% center;
|
||||||
}
|
}
|
||||||
50% {
|
to {
|
||||||
transform: scale(1.1);
|
background-position: 0% center;
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,15 +294,15 @@
|
|||||||
.animate-panel-in {
|
.animate-panel-in {
|
||||||
animation: panelIn 0.32s var(--ease-out) both;
|
animation: panelIn 0.32s var(--ease-out) both;
|
||||||
}
|
}
|
||||||
.animate-check-pop {
|
.animate-shimmer {
|
||||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
animation: shimmer var(--shimmer-duration, 2s) linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-fade-in,
|
.animate-fade-in,
|
||||||
.animate-message-in,
|
.animate-message-in,
|
||||||
.animate-panel-in,
|
.animate-panel-in,
|
||||||
.animate-check-pop {
|
.animate-shimmer {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { Cloud } from "lucide-react"
|
|
||||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
CommandSeparator,
|
|
||||||
CommandShortcut,
|
|
||||||
} from "@/components/ui/command"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
|
||||||
|
|
||||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
|
||||||
<Dialog {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
|
||||||
|
|
||||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
|
||||||
<DialogTrigger {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
|
||||||
title?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorContent = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
title = "Model Selector",
|
|
||||||
...props
|
|
||||||
}: ModelSelectorContentProps) => (
|
|
||||||
<DialogContent className={cn("p-0", className)} {...props}>
|
|
||||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
||||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
|
||||||
|
|
||||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
|
||||||
<CommandDialog {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
|
||||||
|
|
||||||
export const ModelSelectorInput = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorInputProps) => (
|
|
||||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
|
||||||
|
|
||||||
export const ModelSelectorList = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorListProps) => {
|
|
||||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
|
||||||
const [showShadow, setShowShadow] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listElement = listRef.current
|
|
||||||
if (!listElement) return
|
|
||||||
|
|
||||||
const checkScroll = () => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
|
||||||
// Show shadow if there is more content below
|
|
||||||
// Using a small threshold to handle fractional pixel rendering
|
|
||||||
setShowShadow(
|
|
||||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkScroll()
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
listElement.addEventListener("scroll", checkScroll)
|
|
||||||
window.addEventListener("resize", checkScroll)
|
|
||||||
|
|
||||||
// Observe content changes (e.g. async loading of items)
|
|
||||||
const observer = new MutationObserver(checkScroll)
|
|
||||||
observer.observe(listElement, { childList: true, subtree: true })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
listElement.removeEventListener("scroll", checkScroll)
|
|
||||||
window.removeEventListener("resize", checkScroll)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<CommandList
|
|
||||||
ref={listRef}
|
|
||||||
className={cn(
|
|
||||||
// Hide scrollbar on all platforms
|
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{/* Bottom shadow indicator for scrollable content */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
|
||||||
showShadow ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
|
||||||
|
|
||||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
|
||||||
<CommandEmpty {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
|
||||||
|
|
||||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
|
||||||
<CommandGroup {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
|
||||||
|
|
||||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
|
||||||
<CommandItem {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
|
||||||
|
|
||||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
|
||||||
<CommandShortcut {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
|
||||||
typeof CommandSeparator
|
|
||||||
>
|
|
||||||
|
|
||||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
|
||||||
<CommandSeparator {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorLogoProps = Omit<
|
|
||||||
ComponentProps<"img">,
|
|
||||||
"src" | "alt"
|
|
||||||
> & {
|
|
||||||
provider: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorLogo = ({
|
|
||||||
provider,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorLogoProps) => {
|
|
||||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
|
||||||
if (provider === "amazon-bedrock") {
|
|
||||||
return <Cloud className={cn("size-4", className)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
|
||||||
<img
|
|
||||||
{...props}
|
|
||||||
alt={`${provider} logo`}
|
|
||||||
className={cn("size-4 dark:invert", className)}
|
|
||||||
height={16}
|
|
||||||
src={`https://models.dev/logos/${provider}.svg`}
|
|
||||||
width={16}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
|
||||||
|
|
||||||
export const ModelSelectorLogoGroup = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorLogoGroupProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
|
||||||
|
|
||||||
export const ModelSelectorName = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorNameProps) => (
|
|
||||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorSectionHeaderProps = {
|
|
||||||
icon: ReactNode
|
|
||||||
label: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorSectionHeader = ({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
}: ModelSelectorSectionHeaderProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
@@ -1,186 +1,83 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
|
||||||
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
||||||
import type { ComponentProps, ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { createContext, memo, useContext, useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible"
|
} from "@/components/ui/collapsible"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Shimmer } from "./shimmer"
|
|
||||||
|
|
||||||
type ReasoningContextValue = {
|
export type ReasoningProps = {
|
||||||
isStreaming: boolean
|
className?: string
|
||||||
isOpen: boolean
|
|
||||||
setIsOpen: (open: boolean) => void
|
|
||||||
duration: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
|
||||||
|
|
||||||
export const useReasoning = () => {
|
|
||||||
const context = useContext(ReasoningContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("Reasoning components must be used within Reasoning")
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
open?: boolean
|
/** Open while streaming, then folds once about a second after it ends */
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
|
||||||
duration?: number
|
children: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_CLOSE_DELAY = 1000
|
const AUTO_CLOSE_DELAY = 1000
|
||||||
const MS_IN_S = 1000
|
const MS_IN_S = 1000
|
||||||
|
|
||||||
export const Reasoning = memo(
|
export function Reasoning({
|
||||||
({
|
className,
|
||||||
className,
|
isStreaming = false,
|
||||||
isStreaming = false,
|
defaultOpen = true,
|
||||||
open,
|
getThinkingMessage,
|
||||||
defaultOpen = true,
|
children,
|
||||||
onOpenChange,
|
}: ReasoningProps) {
|
||||||
duration: durationProp,
|
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||||
children,
|
const [duration, setDuration] = useState<number>()
|
||||||
...props
|
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||||
}: ReasoningProps) => {
|
const [startTime, setStartTime] = useState<number | null>(null)
|
||||||
const [isOpen, setIsOpen] = useControllableState({
|
|
||||||
prop: open,
|
|
||||||
defaultProp: defaultOpen,
|
|
||||||
onChange: onOpenChange,
|
|
||||||
})
|
|
||||||
const [duration, setDuration] = useControllableState({
|
|
||||||
prop: durationProp,
|
|
||||||
defaultProp: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
// Track duration when streaming starts and ends
|
||||||
const [startTime, setStartTime] = useState<number | null>(null)
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
// Track duration when streaming starts and ends
|
if (startTime === null) {
|
||||||
useEffect(() => {
|
setStartTime(Date.now())
|
||||||
if (isStreaming) {
|
|
||||||
if (startTime === null) {
|
|
||||||
setStartTime(Date.now())
|
|
||||||
}
|
|
||||||
} else if (startTime !== null) {
|
|
||||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
|
||||||
setStartTime(null)
|
|
||||||
}
|
}
|
||||||
}, [isStreaming, startTime, setDuration])
|
} else if (startTime !== null) {
|
||||||
|
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
setStartTime(null)
|
||||||
useEffect(() => {
|
|
||||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
|
||||||
// Add a small delay before closing to allow user to see the content
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsOpen(false)
|
|
||||||
setHasAutoClosed(true)
|
|
||||||
}, AUTO_CLOSE_DELAY)
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
|
||||||
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
|
||||||
setIsOpen(newOpen)
|
|
||||||
}
|
}
|
||||||
|
}, [isStreaming, startTime])
|
||||||
|
|
||||||
return (
|
// Auto-close once when streaming ends, after a short delay so the user
|
||||||
<ReasoningContext.Provider
|
// sees the content
|
||||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
useEffect(() => {
|
||||||
>
|
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||||
<Collapsible
|
const timer = setTimeout(() => {
|
||||||
className={cn("not-prose mb-4", className)}
|
setIsOpen(false)
|
||||||
onOpenChange={handleOpenChange}
|
setHasAutoClosed(true)
|
||||||
open={isOpen}
|
}, AUTO_CLOSE_DELAY)
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Collapsible>
|
|
||||||
</ReasoningContext.Provider>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ReasoningTriggerProps = ComponentProps<
|
return () => clearTimeout(timer)
|
||||||
typeof CollapsibleTrigger
|
}
|
||||||
> & {
|
}, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
|
||||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
return (
|
||||||
if (isStreaming || duration === 0) {
|
<Collapsible
|
||||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
className={cn("not-prose mb-4", className)}
|
||||||
}
|
onOpenChange={setIsOpen}
|
||||||
if (duration === undefined) {
|
open={isOpen}
|
||||||
return <p>Thought for a few seconds</p>
|
|
||||||
}
|
|
||||||
return <p>Thought for {duration} seconds</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReasoningTrigger = memo(
|
|
||||||
({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
getThinkingMessage = defaultGetThinkingMessage,
|
|
||||||
...props
|
|
||||||
}: ReasoningTriggerProps) => {
|
|
||||||
const { isStreaming, isOpen, duration } = useReasoning()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CollapsibleTrigger
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<BrainIcon className="size-4" />
|
|
||||||
{getThinkingMessage(isStreaming, duration)}
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-transform",
|
|
||||||
isOpen ? "rotate-180" : "rotate-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ReasoningContentProps = ComponentProps<
|
|
||||||
typeof CollapsibleContent
|
|
||||||
> & {
|
|
||||||
children: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReasoningContent = memo(
|
|
||||||
({ className, children, ...props }: ReasoningContentProps) => (
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
"mt-4 text-sm",
|
|
||||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<div className="whitespace-pre-wrap">{children}</div>
|
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||||
</CollapsibleContent>
|
<BrainIcon className="size-4" />
|
||||||
),
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
)
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
Reasoning.displayName = "Reasoning"
|
"size-4 transition-transform",
|
||||||
ReasoningTrigger.displayName = "ReasoningTrigger"
|
isOpen ? "rotate-180" : "rotate-0",
|
||||||
ReasoningContent.displayName = "ReasoningContent"
|
)}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="mt-4 text-sm data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
|
||||||
|
<div className="whitespace-pre-wrap">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { motion } from "motion/react"
|
import { type CSSProperties, type ElementType, memo, useMemo } from "react"
|
||||||
import {
|
|
||||||
type CSSProperties,
|
|
||||||
type ElementType,
|
|
||||||
type JSX,
|
|
||||||
memo,
|
|
||||||
useMemo,
|
|
||||||
} from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export type TextShimmerProps = {
|
export type TextShimmerProps = {
|
||||||
@@ -25,39 +18,29 @@ const ShimmerComponent = ({
|
|||||||
duration = 2,
|
duration = 2,
|
||||||
spread = 2,
|
spread = 2,
|
||||||
}: TextShimmerProps) => {
|
}: TextShimmerProps) => {
|
||||||
const MotionComponent = motion.create(
|
|
||||||
Component as keyof JSX.IntrinsicElements,
|
|
||||||
)
|
|
||||||
|
|
||||||
const dynamicSpread = useMemo(
|
const dynamicSpread = useMemo(
|
||||||
() => (children?.length ?? 0) * spread,
|
() => (children?.length ?? 0) * spread,
|
||||||
[children, spread],
|
[children, spread],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MotionComponent
|
<Component
|
||||||
animate={{ backgroundPosition: "0% center" }}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
"animate-shimmer relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
initial={{ backgroundPosition: "100% center" }}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--spread": `${dynamicSpread}px`,
|
"--spread": `${dynamicSpread}px`,
|
||||||
|
"--shimmer-duration": `${duration}s`,
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
transition={{
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
duration,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MotionComponent>
|
</Component>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
|
||||||
import type React from "react"
|
|
||||||
import { Button, type buttonVariants } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
interface ButtonWithTooltipProps
|
|
||||||
extends React.ComponentProps<"button">,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
tooltipContent: string
|
|
||||||
children: React.ReactNode
|
|
||||||
asChild?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ButtonWithTooltip({
|
|
||||||
tooltipContent,
|
|
||||||
children,
|
|
||||||
...buttonProps
|
|
||||||
}: ButtonWithTooltipProps) {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button {...buttonProps}>{children}</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs text-wrap">
|
|
||||||
{tooltipContent}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -155,8 +155,7 @@ export function CanvasStage({
|
|||||||
key={src}
|
key={src}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
src={src}
|
src={src}
|
||||||
configCss={DRAWIO_CSS}
|
css={DRAWIO_CSS}
|
||||||
editorCss={DRAWIO_CSS}
|
|
||||||
dark={isDark}
|
dark={isDark}
|
||||||
onLoad={onDrawioLoad}
|
onLoad={onDrawioLoad}
|
||||||
onAutoSave={handleDiagramAutoSave}
|
onAutoSave={handleDiagramAutoSave}
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ export interface DrawioFrameHandle {
|
|||||||
|
|
||||||
interface DrawioFrameProps {
|
interface DrawioFrameProps {
|
||||||
src: string
|
src: string
|
||||||
/** CSS sent with the configure message (works cross-origin too) */
|
/**
|
||||||
configCss: string
|
* Theme CSS: sent with the configure message (works cross-origin too)
|
||||||
/** CSS injected once the editor is reachable directly (same origin) */
|
* and, same origin, injected as a <style> so it updates without a reload
|
||||||
editorCss: string
|
*/
|
||||||
|
css: string
|
||||||
/** draw.io's dark mode, switched in place when the editor is reachable */
|
/** draw.io's dark mode, switched in place when the editor is reachable */
|
||||||
dark: boolean
|
dark: boolean
|
||||||
onLoad?: () => void
|
onLoad?: () => void
|
||||||
@@ -52,25 +53,14 @@ const STYLE_ID = "next-ai-drawio-theme"
|
|||||||
*/
|
*/
|
||||||
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||||
function DrawioFrame(
|
function DrawioFrame(
|
||||||
{
|
{ src, css, dark, onLoad, onAutoSave, onExport, onEditorAccess },
|
||||||
src,
|
|
||||||
configCss,
|
|
||||||
editorCss,
|
|
||||||
dark,
|
|
||||||
onLoad,
|
|
||||||
onAutoSave,
|
|
||||||
onExport,
|
|
||||||
onEditorAccess,
|
|
||||||
},
|
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
const editorUiRef = useRef<any>(null)
|
const editorUiRef = useRef<any>(null)
|
||||||
const attachedRef = useRef(false)
|
const attachedRef = useRef(false)
|
||||||
const configCssRef = useRef(configCss)
|
const cssRef = useRef(css)
|
||||||
configCssRef.current = configCss
|
cssRef.current = css
|
||||||
const editorCssRef = useRef(editorCss)
|
|
||||||
editorCssRef.current = editorCss
|
|
||||||
const darkRef = useRef(dark)
|
const darkRef = useRef(dark)
|
||||||
darkRef.current = dark
|
darkRef.current = dark
|
||||||
// Handlers change on every render; the message listener reads refs
|
// Handlers change on every render; the message listener reads refs
|
||||||
@@ -111,7 +101,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
style.id = STYLE_ID
|
style.id = STYLE_ID
|
||||||
doc.head.appendChild(style)
|
doc.head.appendChild(style)
|
||||||
}
|
}
|
||||||
style.textContent = editorCssRef.current
|
style.textContent = cssRef.current
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin: the configure message already applied it
|
// Cross-origin: the configure message already applied it
|
||||||
}
|
}
|
||||||
@@ -119,7 +109,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyThemeCss()
|
applyThemeCss()
|
||||||
}, [editorCss])
|
}, [css])
|
||||||
|
|
||||||
// Same-origin only: draw.io's own dark mode, without a reload
|
// Same-origin only: draw.io's own dark mode, without a reload
|
||||||
const applyDarkMode = () => {
|
const applyDarkMode = () => {
|
||||||
@@ -209,7 +199,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
send("configure", {
|
send("configure", {
|
||||||
config: {
|
config: {
|
||||||
...DRAWIO_CONFIG,
|
...DRAWIO_CONFIG,
|
||||||
css: configCssRef.current,
|
css: cssRef.current,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Bookmark, Plus } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import {
|
|
||||||
createTemplate,
|
|
||||||
type TemplateCreateInput,
|
|
||||||
} from "@/lib/template-storage"
|
|
||||||
|
|
||||||
interface TemplateCreateDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess: () => void
|
|
||||||
initialPrompt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateCreateDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
initialPrompt = "",
|
|
||||||
}: TemplateCreateDialogProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [title, setTitle] = useState("")
|
|
||||||
const [description, setDescription] = useState("")
|
|
||||||
const [prompt, setPrompt] = useState("")
|
|
||||||
const [pinned, setPinned] = useState(false)
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Reset form when dialog opens with the latest initialPrompt
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setTitle("")
|
|
||||||
setDescription("")
|
|
||||||
setPrompt(initialPrompt)
|
|
||||||
setPinned(false)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
}, [open, initialPrompt])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
// React submit events bubble through the portal; keep them away from
|
|
||||||
// the chat form this dialog may be rendered in
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const trimmedPrompt = prompt.trim()
|
|
||||||
if (!trimmedPrompt) {
|
|
||||||
setError(dict.templates.promptRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input: TemplateCreateInput = {
|
|
||||||
prompt: trimmedPrompt,
|
|
||||||
title: title.trim() || undefined,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
pinned,
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = await createTemplate(input)
|
|
||||||
if (template) {
|
|
||||||
onSuccess()
|
|
||||||
onOpenChange(false)
|
|
||||||
} else {
|
|
||||||
setError(dict.templates.createFailed)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create template:", err)
|
|
||||||
setError(dict.templates.createFailed)
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{dict.templates.createTitle}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{dict.templates.createDescription}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
{/* Prompt field - required */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="prompt" className="text-foreground">
|
|
||||||
{dict.templates.promptLabel}
|
|
||||||
<span className="text-destructive ml-1">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="prompt"
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
placeholder={dict.templates.promptPlaceholder}
|
|
||||||
className="min-h-[100px] resize-none break-words"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title field - optional */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title" className="text-foreground">
|
|
||||||
{dict.templates.titleLabel}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder={dict.templates.titlePlaceholder}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.templates.titleHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description field - optional */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="description"
|
|
||||||
className="text-foreground"
|
|
||||||
>
|
|
||||||
{dict.templates.descriptionLabel}
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
dict.templates.descriptionPlaceholder
|
|
||||||
}
|
|
||||||
className="min-h-[60px] resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pinned switch */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="pinned"
|
|
||||||
className="flex items-center gap-2 text-foreground"
|
|
||||||
>
|
|
||||||
<Bookmark className="w-4 h-4" />
|
|
||||||
{dict.templates.pinnedLabel}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.templates.pinnedHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="pinned"
|
|
||||||
checked={pinned}
|
|
||||||
onCheckedChange={setPinned}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{dict.common.cancel}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting
|
|
||||||
? dict.common.loading
|
|
||||||
: dict.templates.createButton}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Bookmark, Edit2 } from "lucide-react"
|
import { Bookmark, Edit2, Plus } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useId, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,22 +16,32 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { type Template, updateTemplate } from "@/lib/template-storage"
|
import {
|
||||||
|
createTemplate,
|
||||||
|
type Template,
|
||||||
|
updateTemplate,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
|
||||||
interface TemplateEditDialogProps {
|
interface TemplateFormDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
template: Template | null
|
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
|
/** The template to edit; without one the dialog creates a new template */
|
||||||
|
template?: Template | null
|
||||||
|
initialPrompt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateEditDialog({
|
// One form for creating and editing a template
|
||||||
|
export function TemplateFormDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
template,
|
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: TemplateEditDialogProps) {
|
template,
|
||||||
|
initialPrompt = "",
|
||||||
|
}: TemplateFormDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
const id = useId()
|
||||||
|
const isEdit = !!template
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [prompt, setPrompt] = useState("")
|
const [prompt, setPrompt] = useState("")
|
||||||
@@ -39,32 +49,22 @@ export function TemplateEditDialog({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
// Fill the form each time the dialog opens, dropping cancelled edits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && template) {
|
if (!open) return
|
||||||
setTitle(template.title || "")
|
setTitle(template?.title || "")
|
||||||
setDescription(template.description || "")
|
setDescription(template?.description || "")
|
||||||
setPrompt(template.prompt || "")
|
setPrompt(template ? template.prompt || "" : initialPrompt)
|
||||||
setPinned(template.pinned || false)
|
setPinned(template?.pinned || false)
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}, [open, template, initialPrompt])
|
||||||
}, [open, template])
|
|
||||||
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
|
||||||
if (!newOpen) {
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
onOpenChange(newOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// React submit events bubble through the portal; keep them away from
|
// React submit events bubble through the portal; keep them away from
|
||||||
// any form this dialog may be rendered in
|
// the chat form this dialog may be rendered in
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
if (!template) return
|
|
||||||
|
|
||||||
const trimmedPrompt = prompt.trim()
|
const trimmedPrompt = prompt.trim()
|
||||||
if (!trimmedPrompt) {
|
if (!trimmedPrompt) {
|
||||||
setError(dict.templates.promptRequired)
|
setError(dict.templates.promptRequired)
|
||||||
@@ -74,40 +74,59 @@ export function TemplateEditDialog({
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
|
const failed = isEdit
|
||||||
|
? dict.templates.updateFailed
|
||||||
|
: dict.templates.createFailed
|
||||||
try {
|
try {
|
||||||
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
const fields = {
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
title: title.trim() || template.title,
|
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
pinned,
|
pinned,
|
||||||
}
|
}
|
||||||
|
// An emptied title keeps the old one when editing; a new template
|
||||||
const updated = await updateTemplate(template.id, updates)
|
// gets a default title from its prompt
|
||||||
if (updated) {
|
const result = template
|
||||||
|
? await updateTemplate(template.id, {
|
||||||
|
...fields,
|
||||||
|
title: title.trim() || template.title,
|
||||||
|
})
|
||||||
|
: await createTemplate({
|
||||||
|
...fields,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
})
|
||||||
|
if (result) {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} else {
|
} else {
|
||||||
setError(dict.templates.updateFailed)
|
setError(failed)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update template:", err)
|
console.error("Failed to save template:", err)
|
||||||
setError(dict.templates.updateFailed)
|
setError(failed)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Edit2 className="w-5 h-5" />
|
{isEdit ? (
|
||||||
{dict.templates.editTitle}
|
<Edit2 className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
{isEdit
|
||||||
|
? dict.templates.editTitle
|
||||||
|
: dict.templates.createTitle}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{dict.templates.editDescription}
|
{isEdit
|
||||||
|
? dict.templates.editDescription
|
||||||
|
: dict.templates.createDescription}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -115,14 +134,14 @@ export function TemplateEditDialog({
|
|||||||
{/* Prompt field - required */}
|
{/* Prompt field - required */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-prompt"
|
htmlFor={`${id}-prompt`}
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.promptLabel}
|
{dict.templates.promptLabel}
|
||||||
<span className="text-destructive ml-1">*</span>
|
<span className="text-destructive ml-1">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="edit-prompt"
|
id={`${id}-prompt`}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
placeholder={dict.templates.promptPlaceholder}
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
@@ -134,13 +153,13 @@ export function TemplateEditDialog({
|
|||||||
{/* Title field - optional */}
|
{/* Title field - optional */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-title"
|
htmlFor={`${id}-title`}
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.titleLabel}
|
{dict.templates.titleLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-title"
|
id={`${id}-title`}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder={dict.templates.titlePlaceholder}
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
@@ -153,13 +172,13 @@ export function TemplateEditDialog({
|
|||||||
{/* Description field - optional */}
|
{/* Description field - optional */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-description"
|
htmlFor={`${id}-description`}
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.descriptionLabel}
|
{dict.templates.descriptionLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="edit-description"
|
id={`${id}-description`}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -173,7 +192,7 @@ export function TemplateEditDialog({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-pinned"
|
htmlFor={`${id}-pinned`}
|
||||||
className="flex items-center gap-2 text-foreground"
|
className="flex items-center gap-2 text-foreground"
|
||||||
>
|
>
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
@@ -184,7 +203,7 @@ export function TemplateEditDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="edit-pinned"
|
id={`${id}-pinned`}
|
||||||
checked={pinned}
|
checked={pinned}
|
||||||
onCheckedChange={setPinned}
|
onCheckedChange={setPinned}
|
||||||
/>
|
/>
|
||||||
@@ -208,7 +227,9 @@ export function TemplateEditDialog({
|
|||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? dict.common.loading
|
? dict.common.loading
|
||||||
: dict.common.save}
|
: isEdit
|
||||||
|
? dict.common.save
|
||||||
|
: dict.templates.createButton}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -23,63 +24,33 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatRelativeTime } from "@/lib/relative-time"
|
||||||
import {
|
import {
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
duplicateTemplate,
|
duplicateTemplate,
|
||||||
exportTemplates,
|
exportTemplates,
|
||||||
getAllTemplates,
|
getAllTemplates,
|
||||||
importTemplates,
|
importTemplates,
|
||||||
incrementClickCount,
|
|
||||||
incrementRunCount,
|
incrementRunCount,
|
||||||
searchTemplates,
|
searchTemplates,
|
||||||
type Template,
|
type Template,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
validateImportData,
|
validateImportData,
|
||||||
} from "@/lib/template-storage"
|
} from "@/lib/template-storage"
|
||||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
import { TemplateFormDialog } from "./TemplateFormDialog"
|
||||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
|
||||||
|
|
||||||
interface TemplatePanelProps {
|
interface TemplatePanelProps {
|
||||||
setInput: (input: string) => void
|
|
||||||
/** Whether it was sent */
|
/** Whether it was sent */
|
||||||
onSendTemplate?: (template: Template) => Promise<boolean>
|
onSendTemplate: (template: Template) => Promise<boolean>
|
||||||
currentInput?: string
|
currentInput?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
|
||||||
if (!timestamp) return neverUsedText
|
|
||||||
const now = Date.now()
|
|
||||||
const diffMs = now - timestamp
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
|
||||||
if (diffMins < 1) return rtf.format(0, "minute")
|
|
||||||
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
|
||||||
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
|
||||||
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
|
||||||
} catch {
|
|
||||||
// Fallback if Intl.RelativeTimeFormat is not available
|
|
||||||
if (diffMins < 1) return "<1m ago"
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplatePanel({
|
export function TemplatePanel({
|
||||||
setInput,
|
|
||||||
onSendTemplate,
|
onSendTemplate,
|
||||||
currentInput = "",
|
currentInput = "",
|
||||||
}: TemplatePanelProps) {
|
}: TemplatePanelProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
const params = useParams<{ lang: string }>()
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
@@ -111,10 +82,6 @@ export function TemplatePanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
// Reload when a template is saved elsewhere, e.g. from the chat input
|
|
||||||
window.addEventListener("templatesChanged", loadTemplates)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("templatesChanged", loadTemplates)
|
|
||||||
}, [loadTemplates])
|
}, [loadTemplates])
|
||||||
|
|
||||||
const handleCreateSuccess = () => {
|
const handleCreateSuccess = () => {
|
||||||
@@ -133,7 +100,7 @@ export function TemplatePanel({
|
|||||||
const handleDuplicate = async (template: Template) => {
|
const handleDuplicate = async (template: Template) => {
|
||||||
const duplicated = await duplicateTemplate(
|
const duplicated = await duplicateTemplate(
|
||||||
template.id,
|
template.id,
|
||||||
dict.templates.copySuffix || "(copy)",
|
dict.templates.copySuffix,
|
||||||
)
|
)
|
||||||
if (duplicated) {
|
if (duplicated) {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@@ -179,23 +146,13 @@ export function TemplatePanel({
|
|||||||
|
|
||||||
// Actually send the template
|
// Actually send the template
|
||||||
const sendTemplate = async (template: Template) => {
|
const sendTemplate = async (template: Template) => {
|
||||||
if (onSendTemplate) {
|
// A run only when it was sent (not while a message is still being
|
||||||
// Sent first, while the chat is the one it was clicked in (the
|
// sent, or attachments read)
|
||||||
// counts wait for storage). A run only when it was sent (not
|
if (await onSendTemplate(template)) {
|
||||||
// while a message is still being sent, or attachments read).
|
// Increment run count and update lastUsedAt
|
||||||
const sending = onSendTemplate(template)
|
await incrementRunCount(template.id)
|
||||||
// Increment click count only when actually sending
|
// Reload to show updated stats
|
||||||
await incrementClickCount(template.id)
|
loadTemplates()
|
||||||
if (await sending) {
|
|
||||||
// Increment run count and update lastUsedAt
|
|
||||||
await incrementRunCount(template.id)
|
|
||||||
// Reload to show updated stats
|
|
||||||
loadTemplates()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await incrementClickCount(template.id)
|
|
||||||
// Fallback: just fill the input if no send callback provided
|
|
||||||
setInput(template.prompt)
|
|
||||||
}
|
}
|
||||||
setConfirmSendDialogOpen(false)
|
setConfirmSendDialogOpen(false)
|
||||||
setTemplateToSend(null)
|
setTemplateToSend(null)
|
||||||
@@ -218,7 +175,7 @@ export function TemplatePanel({
|
|||||||
if (templates.length === 0) {
|
if (templates.length === 0) {
|
||||||
setImportMessage({
|
setImportMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: dict.templates.exportEmpty || "No templates to export",
|
text: dict.templates.exportEmpty,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -260,8 +217,7 @@ export function TemplatePanel({
|
|||||||
if (!file) {
|
if (!file) {
|
||||||
setImportMessage({
|
setImportMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text:
|
text: dict.templates.importNoFile,
|
||||||
dict.templates.importNoFile || "Please select a JSON file",
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -370,7 +326,7 @@ export function TemplatePanel({
|
|||||||
<div className="mt-3">{importMessageBox}</div>
|
<div className="mt-3">{importMessageBox}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
@@ -500,7 +456,7 @@ export function TemplatePanel({
|
|||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||||
{template.runCount > 0
|
{template.runCount > 0
|
||||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${template.lastUsedAt ? formatRelativeTime(template.lastUsedAt, params.lang, dict.sessionHistory.justNow) : dict.templates.neverUsed}`
|
||||||
: dict.templates.neverUsed}
|
: dict.templates.neverUsed}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
@@ -517,10 +473,8 @@ export function TemplatePanel({
|
|||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
template.pinned
|
template.pinned
|
||||||
? dict.templates
|
? dict.templates.unpin
|
||||||
.unpin || "Unpin"
|
: dict.templates.pin
|
||||||
: dict.templates.pin ||
|
|
||||||
"Pin"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Bookmark
|
<Bookmark
|
||||||
@@ -545,10 +499,7 @@ export function TemplatePanel({
|
|||||||
handleDuplicate(template)
|
handleDuplicate(template)
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
title={
|
title={dict.templates.duplicate}
|
||||||
dict.templates.duplicate ||
|
|
||||||
"Duplicate"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -570,13 +521,13 @@ export function TemplatePanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateEditDialog
|
<TemplateFormDialog
|
||||||
open={editDialogOpen}
|
open={editDialogOpen}
|
||||||
onOpenChange={setEditDialogOpen}
|
onOpenChange={setEditDialogOpen}
|
||||||
template={templateToEdit}
|
template={templateToEdit}
|
||||||
@@ -591,12 +542,10 @@ export function TemplatePanel({
|
|||||||
<AlertDialogContent className="max-w-sm">
|
<AlertDialogContent className="max-w-sm">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{dict.templates.deleteTitle ||
|
{dict.templates.deleteTitle}
|
||||||
"Delete this template?"}
|
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{dict.templates.deleteDescription ||
|
{dict.templates.deleteDescription}
|
||||||
"This will permanently delete this template. This action cannot be undone."}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -621,12 +570,10 @@ export function TemplatePanel({
|
|||||||
<AlertDialogContent className="max-w-sm">
|
<AlertDialogContent className="max-w-sm">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{dict.templates.confirmSendTitle ||
|
{dict.templates.confirmSendTitle}
|
||||||
"Replace current input?"}
|
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{dict.templates.confirmSendDescription ||
|
{dict.templates.confirmSendDescription}
|
||||||
"You have unsent content in the input. Sending this template will replace it."}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -634,8 +581,7 @@ export function TemplatePanel({
|
|||||||
{dict.common.cancel}
|
{dict.common.cancel}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleConfirmSend}>
|
<AlertDialogAction onClick={handleConfirmSend}>
|
||||||
{dict.templates.confirmSendButton ||
|
{dict.templates.confirmSendButton}
|
||||||
"Send Template"}
|
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
|||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
|
|
||||||
export type ValidationStatus =
|
export type ValidationStatus =
|
||||||
| "idle"
|
|
||||||
| "capturing"
|
| "capturing"
|
||||||
| "validating"
|
| "validating"
|
||||||
| "success"
|
| "success"
|
||||||
@@ -159,13 +158,10 @@ export function ValidationCard({
|
|||||||
color: "text-gray-600 bg-gray-50",
|
color: "text-gray-600 bg-gray-50",
|
||||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusDisplay = getStatusDisplay()
|
const statusDisplay = getStatusDisplay()
|
||||||
if (!statusDisplay || state.status === "idle") return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||||
|
|||||||
+122
-200
@@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react"
|
|||||||
import {
|
import {
|
||||||
type ChatStatus,
|
type ChatStatus,
|
||||||
DefaultChatTransport,
|
DefaultChatTransport,
|
||||||
|
getToolName,
|
||||||
isToolUIPart,
|
isToolUIPart,
|
||||||
type UIMessage,
|
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
@@ -19,14 +19,10 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { flushSync } from "react-dom"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import {
|
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||||
keepFileVars,
|
|
||||||
useDiagramToolHandlers,
|
|
||||||
} from "@/hooks/use-diagram-tool-handlers"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import {
|
import {
|
||||||
getSelectedAIConfig,
|
getSelectedAIConfig,
|
||||||
@@ -34,13 +30,18 @@ import {
|
|||||||
useModelConfig,
|
useModelConfig,
|
||||||
} from "@/hooks/use-model-config"
|
} from "@/hooks/use-model-config"
|
||||||
import {
|
import {
|
||||||
getSessionVersions,
|
|
||||||
type SessionData,
|
type SessionData,
|
||||||
useSessionManager,
|
useSessionManager,
|
||||||
} from "@/hooks/use-session-manager"
|
} from "@/hooks/use-session-manager"
|
||||||
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
|
import {
|
||||||
|
type ChatToolName,
|
||||||
|
type ChatUIMessage,
|
||||||
|
type MessageSelection,
|
||||||
|
noticeMessage,
|
||||||
|
} from "@/lib/chat-message"
|
||||||
import { buildChatHeaders } from "@/lib/chat-request"
|
import { buildChatHeaders } from "@/lib/chat-request"
|
||||||
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
|
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
|
||||||
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
|
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
|
||||||
@@ -80,7 +81,7 @@ const SAVE_DEBOUNCE_MS = 1000
|
|||||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||||
* A call the user stopped is not one to retry.
|
* A call the user stopped is not one to retry.
|
||||||
*/
|
*/
|
||||||
function hasToolErrors(messages: UIMessage[]): boolean {
|
function hasToolErrors(messages: ChatUIMessage[]): boolean {
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
if (lastMessage?.role !== "assistant") return false
|
if (lastMessage?.role !== "assistant") return false
|
||||||
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
|
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
|
||||||
@@ -107,45 +108,23 @@ function viewedPageId(): string | null {
|
|||||||
return useCanvasStore.getState().currentPageId
|
return useCanvasStore.getState().currentPageId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shapes sent with a user message (also kept in its metadata)
|
function selectionOf(message: ChatUIMessage): MessageSelection {
|
||||||
type MessageSelection = Pick<SelectedCell, "id" | "label">[]
|
const selected = message.metadata?.selectedCells
|
||||||
|
return Array.isArray(selected) ? selected : []
|
||||||
function selectionOf(message: UIMessage): MessageSelection {
|
|
||||||
const meta = message.metadata as
|
|
||||||
| { selectedCells?: MessageSelection }
|
|
||||||
| undefined
|
|
||||||
return Array.isArray(meta?.selectedCells) ? meta.selectedCells : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newLangfuseSessionId() {
|
function newLangfuseSessionId() {
|
||||||
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const XML_PROLOG =
|
|
||||||
/^(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>)*/i
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the diagram document out of a .drawio, .xml or .drawio.svg file, with
|
* Read the diagram document out of a .drawio, .xml or .drawio.svg file, with
|
||||||
* its pages inflated: previews and the model read it before draw.io saves
|
* its pages inflated: previews and the model read it before draw.io saves
|
||||||
* it again. A broken or cut off file gives null.
|
* it again. A broken or cut off file gives null.
|
||||||
*/
|
*/
|
||||||
async function readDiagramFile(file: File): Promise<string | null> {
|
async function readDiagramFile(file: File): Promise<string | null> {
|
||||||
const raw = await file.text()
|
const loaded = parseDrawioFileContent(await file.text())
|
||||||
// What XML allows before the root element: the declaration, comments,
|
return loaded.ok ? withUniquePageIds(loaded.xml) : null
|
||||||
// a DOCTYPE (the parser below expects the root first)
|
|
||||||
let text = raw.replace(XML_PROLOG, "")
|
|
||||||
// Editable SVG: the diagram is stored in the root's `content` attribute.
|
|
||||||
// Parsed whole: its DOCTYPE may define entities the content uses.
|
|
||||||
if (/^<(?:[\w.-]+:)?svg[\s>]/i.test(text)) {
|
|
||||||
const doc = new DOMParser().parseFromString(raw, "image/svg+xml")
|
|
||||||
text = (doc.documentElement?.getAttribute("content") ?? "").replace(
|
|
||||||
XML_PROLOG,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const loaded = parseDrawioFileContent(text)
|
|
||||||
if (!loaded.ok) return null
|
|
||||||
return withUniquePageIds(loaded.xml)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,15 +169,13 @@ function withUniquePageIds(xml: string): string {
|
|||||||
class FileReadError extends Error {}
|
class FileReadError extends Error {}
|
||||||
|
|
||||||
export interface ChatEngine {
|
export interface ChatEngine {
|
||||||
messages: UIMessage[]
|
messages: ChatUIMessage[]
|
||||||
status: ChatStatus
|
status: ChatStatus
|
||||||
error: Error | undefined
|
error: Error | undefined
|
||||||
isBusy: boolean
|
isBusy: boolean
|
||||||
/** The chat on screen is being left (saved, then another one shown) */
|
/** The chat on screen is being left (saved, then another one shown) */
|
||||||
isLeaving: boolean
|
isLeaving: boolean
|
||||||
/** A message is being sent again; the chat is not empty meanwhile */
|
setMessages: (messages: ChatUIMessage[]) => void
|
||||||
isResending: boolean
|
|
||||||
setMessages: (messages: UIMessage[]) => void
|
|
||||||
stop: () => void
|
stop: () => void
|
||||||
|
|
||||||
input: string
|
input: string
|
||||||
@@ -267,11 +244,10 @@ export function ChatEngineProvider({
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
handleExport: onExport,
|
fetchDiagramXml,
|
||||||
exportResolversRef,
|
|
||||||
chartXML,
|
chartXML,
|
||||||
chartXMLRef: liveChartXMLRef,
|
chartXMLRef,
|
||||||
latestSvg,
|
latestSvgRef,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
@@ -284,26 +260,10 @@ export function ChatEngineProvider({
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const urlSessionId = searchParams.get("session")
|
const urlSessionId = searchParams.get("session")
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const vlmValidationEnabled = useSettingsStore((s) => s.vlmValidationEnabled)
|
||||||
const openSettings = useUiStore((s) => s.openSettings)
|
const openSettings = useUiStore((s) => s.openSettings)
|
||||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||||
|
|
||||||
const onFetchChart = () => {
|
|
||||||
// Waits for the reply to its own export, by its tag
|
|
||||||
const tag = onExport()
|
|
||||||
return Promise.race([
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
if (tag) exportResolversRef.current[tag] = resolve
|
|
||||||
}),
|
|
||||||
new Promise<string>((_, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
delete exportResolversRef.current[tag]
|
|
||||||
reject(new Error("Chart export timed out after 10 seconds"))
|
|
||||||
}, 10000)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({
|
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({
|
||||||
tooLong: dict.errors.fileTooLong,
|
tooLong: dict.errors.fileTooLong,
|
||||||
readFailed: dict.errors.fileReadFailed,
|
readFailed: dict.errors.fileReadFailed,
|
||||||
@@ -318,9 +278,6 @@ export function ChatEngineProvider({
|
|||||||
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
|
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
|
||||||
|
|
||||||
const [input, setInputState] = useState("")
|
const [input, setInputState] = useState("")
|
||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
|
||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
|
||||||
const [tpmLimit, setTpmLimit] = useState(0)
|
|
||||||
|
|
||||||
// Restore the unsent input from sessionStorage
|
// Restore the unsent input from sessionStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -340,21 +297,7 @@ export function ChatEngineProvider({
|
|||||||
else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(getApiEndpoint("/api/config"))
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
|
||||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
|
||||||
setTpmLimit(data.tpmLimit || 0)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const quotaManager = useQuotaManager({
|
const quotaManager = useQuotaManager({
|
||||||
dailyRequestLimit,
|
|
||||||
dailyTokenLimit,
|
|
||||||
tpmLimit,
|
|
||||||
onConfigModel: () => openSettings("models"),
|
onConfigModel: () => openSettings("models"),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -392,20 +335,12 @@ export function ChatEngineProvider({
|
|||||||
const hasRestoredRef = useRef(false)
|
const hasRestoredRef = useRef(false)
|
||||||
const [isRestored, setIsRestored] = useState(false)
|
const [isRestored, setIsRestored] = useState(false)
|
||||||
|
|
||||||
// Latest chartXML for callbacks (avoids stale closures)
|
|
||||||
const chartXMLRef = useRef(chartXML)
|
|
||||||
// Session loaded without a diagram (prevents thumbnail contamination)
|
// Session loaded without a diagram (prevents thumbnail contamination)
|
||||||
const justLoadedSessionIdRef = useRef<string | null>(null)
|
const justLoadedSessionIdRef = useRef<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chartXMLRef.current = chartXML
|
|
||||||
if (chartXML) justLoadedSessionIdRef.current = null
|
if (chartXML) justLoadedSessionIdRef.current = null
|
||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
const latestSvgRef = useRef(latestSvg)
|
|
||||||
useEffect(() => {
|
|
||||||
latestSvgRef.current = latestSvg
|
|
||||||
}, [latestSvg])
|
|
||||||
|
|
||||||
const autoRetryCountRef = useRef(0)
|
const autoRetryCountRef = useRef(0)
|
||||||
const continuationRetryCountRef = useRef(0)
|
const continuationRetryCountRef = useRef(0)
|
||||||
// Partial XML when output was truncated; non-empty means continuation mode
|
// Partial XML when output was truncated; non-empty means continuation mode
|
||||||
@@ -424,9 +359,6 @@ export function ChatEngineProvider({
|
|||||||
const [isLeaving, setIsLeaving] = useState(false)
|
const [isLeaving, setIsLeaving] = useState(false)
|
||||||
// The user's provider the current turn was sent to, for its errors
|
// The user's provider the current turn was sent to, for its errors
|
||||||
const requestProviderIdRef = useRef<string | undefined>(undefined)
|
const requestProviderIdRef = useRef<string | undefined>(undefined)
|
||||||
// A message is being sent again (retry, regenerate, edit), until the
|
|
||||||
// SDK has put it back
|
|
||||||
const [isResending, setIsResending] = useState(false)
|
|
||||||
// Presses of Stop: a check that began before one still knows of it after
|
// Presses of Stop: a check that began before one still knows of it after
|
||||||
// the next message clears stoppedRef
|
// the next message clears stoppedRef
|
||||||
const stopCountRef = useRef(0)
|
const stopCountRef = useRef(0)
|
||||||
@@ -458,13 +390,13 @@ export function ChatEngineProvider({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
validationRetryCountRef,
|
validationRetryCountRef,
|
||||||
// A preview undone just before the tool call is in this one already
|
// A preview undone just before the tool call is in this one already
|
||||||
chartXMLRef: liveChartXMLRef,
|
chartXMLRef,
|
||||||
turnPageIdRef,
|
turnPageIdRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart: fetchDiagramXml,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
validateDiagram: validateWithFallback,
|
validateDiagram: validateWithFallback,
|
||||||
enableVlmValidation: settings.vlmValidationEnabled,
|
enableVlmValidation: vlmValidationEnabled,
|
||||||
sessionId: langfuseSessionId,
|
sessionId: langfuseSessionId,
|
||||||
watchStop: () => {
|
watchStop: () => {
|
||||||
const stopsBefore = stopCountRef.current
|
const stopsBefore = stopCountRef.current
|
||||||
@@ -492,9 +424,40 @@ export function ChatEngineProvider({
|
|||||||
onDisplayChart(original, true, "revert")
|
onDisplayChart(original, true, "revert")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Requests made in the turn so far. The first carries the diagram the
|
||||||
|
// message was sent with (exported then, or the snapshot it is sent
|
||||||
|
// again from). Each later one, an automatic retry after a tool error,
|
||||||
|
// carries the canvas as it is then: a drawing is committed before its
|
||||||
|
// screenshot check can fail, and the model must go on from that
|
||||||
|
// drawing, not from the one before the turn.
|
||||||
|
const turnRequestsRef = useRef(0)
|
||||||
const transport = useMemo(
|
const transport = useMemo(
|
||||||
() => new DefaultChatTransport({ api: getApiEndpoint("/api/chat") }),
|
() =>
|
||||||
[],
|
new DefaultChatTransport<ChatUIMessage>({
|
||||||
|
api: getApiEndpoint("/api/chat"),
|
||||||
|
prepareSendMessagesRequest: ({
|
||||||
|
id,
|
||||||
|
messages,
|
||||||
|
body,
|
||||||
|
trigger,
|
||||||
|
messageId,
|
||||||
|
}) => ({
|
||||||
|
body: {
|
||||||
|
...body,
|
||||||
|
...(turnRequestsRef.current++ > 0 && {
|
||||||
|
xml: getPageXml(
|
||||||
|
chartXMLRef.current,
|
||||||
|
turnPageIdRef.current,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
id,
|
||||||
|
messages,
|
||||||
|
trigger,
|
||||||
|
messageId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[chartXMLRef],
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -505,19 +468,19 @@ export function ChatEngineProvider({
|
|||||||
error,
|
error,
|
||||||
setMessages,
|
setMessages,
|
||||||
stop,
|
stop,
|
||||||
} = useChat({
|
} = useChat<ChatUIMessage>({
|
||||||
transport,
|
transport,
|
||||||
onToolCall: async ({ toolCall }) => {
|
onToolCall: async ({ toolCall }) => {
|
||||||
await handleToolCall({ toolCall }, addToolOutputSafely)
|
await handleToolCall({ toolCall }, addToolOutputSafely)
|
||||||
},
|
},
|
||||||
onFinish: ({ message, isAbort, isError }) => {
|
onFinish: ({ message, isAbort, isError, finishReason }) => {
|
||||||
// Stopped, failed, or the reply just ended (a closed connection):
|
// Stopped, failed, or the reply just ended (a closed connection):
|
||||||
// tool calls still streaming never reach the tool handler. Mark
|
// tool calls still streaming never reach the tool handler. Mark
|
||||||
// them handled so a later render of the stream does not draw
|
// them handled so a later render of the stream does not draw
|
||||||
// their preview again.
|
// their preview again.
|
||||||
const cut = (message.parts as any[]).filter(
|
const cut = message.parts
|
||||||
(part) => part.state === "input-streaming" && part.toolCallId,
|
.filter(isToolUIPart)
|
||||||
)
|
.filter((part) => part.state === "input-streaming")
|
||||||
for (const part of cut) {
|
for (const part of cut) {
|
||||||
processedToolCallsRef.current.add(part.toolCallId)
|
processedToolCallsRef.current.add(part.toolCallId)
|
||||||
}
|
}
|
||||||
@@ -526,18 +489,16 @@ export function ChatEngineProvider({
|
|||||||
// or the page going, aborts a reply). Not once another chat is
|
// or the page going, aborts a reply). Not once another chat is
|
||||||
// on screen: the call is not among its messages.
|
// on screen: the call is not among its messages.
|
||||||
if (isAbort && getChatGeneration() === turnChatRef.current) {
|
if (isAbort && getChatGeneration() === turnChatRef.current) {
|
||||||
for (const part of cut) {
|
for (const part of cut) stopToolCall(part)
|
||||||
addToolOutputSafely({
|
|
||||||
tool: part.type.replace("tool-", ""),
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: "Stopped by user",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (isAbort || isError || cut.length === 0) return
|
if (isAbort || isError) return
|
||||||
// Ended with no error: undo the preview, and say so, as an
|
// The output budget ran out before the model called a tool (its
|
||||||
// error does (with Retry)
|
// thinking took it all): nothing was drawn
|
||||||
|
const outOfTokens =
|
||||||
|
finishReason === "length" && !message.parts.some(isToolUIPart)
|
||||||
|
if (cut.length === 0 && !outOfTokens) return
|
||||||
|
// Ended with no error and no diagram: undo the preview, and say
|
||||||
|
// so, as an error does (with Retry)
|
||||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||||
editDiagramOriginalXmlRef.current.clear()
|
editDiagramOriginalXmlRef.current.clear()
|
||||||
@@ -546,16 +507,11 @@ export function ChatEngineProvider({
|
|||||||
if (getChatGeneration() !== turnChatRef.current) return
|
if (getChatGeneration() !== turnChatRef.current) return
|
||||||
setMessages((currentMessages) => [
|
setMessages((currentMessages) => [
|
||||||
...currentMessages,
|
...currentMessages,
|
||||||
{
|
noticeMessage({
|
||||||
id: `error-${Date.now()}`,
|
text: outOfTokens
|
||||||
role: "system" as const,
|
? dict.errors.llm.output_truncated
|
||||||
parts: [
|
: dict.errors.answerEndedEarly,
|
||||||
{
|
}),
|
||||||
type: "text" as const,
|
|
||||||
text: dict.errors.answerEndedEarly,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -624,19 +580,15 @@ export function ChatEngineProvider({
|
|||||||
// the model selected may have changed since, e.g. in another tab
|
// the model selected may have changed since, e.g. in another tab
|
||||||
const providerId = requestProviderIdRef.current
|
const providerId = requestProviderIdRef.current
|
||||||
|
|
||||||
// A system message, so it can be cleared with the conversation
|
// In the chat, so it is cleared with the conversation; with a
|
||||||
|
// button that opens model settings, on the page of the provider
|
||||||
|
// that refused
|
||||||
setMessages((currentMessages) => [
|
setMessages((currentMessages) => [
|
||||||
...currentMessages,
|
...currentMessages,
|
||||||
{
|
noticeMessage({
|
||||||
id: `error-${Date.now()}`,
|
text,
|
||||||
role: "system" as const,
|
...(openModelConfig && { openModelConfig, providerId }),
|
||||||
parts: [{ type: "text" as const, text }],
|
}),
|
||||||
// The message shows a button that opens model settings,
|
|
||||||
// on the page of the provider that refused
|
|
||||||
...(openModelConfig && {
|
|
||||||
metadata: { openModelConfig: true, providerId },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
|
|
||||||
if (isAccessCodeError) openSettings("general")
|
if (isAccessCodeError) openSettings("general")
|
||||||
@@ -666,9 +618,19 @@ export function ChatEngineProvider({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A call the user stopped before it reached the tool handler. The SDK
|
||||||
|
// finds the call by its id; the tool name is for the types only.
|
||||||
|
const stopToolCall = (part: Parameters<typeof getToolName>[0]) =>
|
||||||
|
addToolOutputSafely({
|
||||||
|
tool: getToolName(part) as ChatToolName,
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "Stopped by user",
|
||||||
|
})
|
||||||
|
|
||||||
// After each request: send the next one (retry, continue a cut off
|
// After each request: send the next one (retry, continue a cut off
|
||||||
// drawing) or end the turn
|
// drawing) or end the turn
|
||||||
const shouldSendAgain = (messages: UIMessage[]) => {
|
const shouldSendAgain = (messages: ChatUIMessage[]) => {
|
||||||
// The user stopped: a tool result that arrives later (a VLM
|
// The user stopped: a tool result that arrives later (a VLM
|
||||||
// check still running) must not start a new request
|
// check still running) must not start a new request
|
||||||
if (stoppedRef.current) return false
|
if (stoppedRef.current) return false
|
||||||
@@ -720,9 +682,6 @@ export function ChatEngineProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isBusy = status === "streaming" || status === "submitted"
|
const isBusy = status === "streaming" || status === "submitted"
|
||||||
useEffect(() => {
|
|
||||||
if (messages.length > 0) setIsResending(false)
|
|
||||||
}, [messages.length])
|
|
||||||
// isBusy of the latest render, for code that runs after an await
|
// isBusy of the latest render, for code that runs after an await
|
||||||
const busyRef = useRef(isBusy)
|
const busyRef = useRef(isBusy)
|
||||||
busyRef.current = isBusy
|
busyRef.current = isBusy
|
||||||
@@ -834,16 +793,10 @@ export function ChatEngineProvider({
|
|||||||
const [shownChats, setShownChats] = useState(0)
|
const [shownChats, setShownChats] = useState(0)
|
||||||
// The messages shown when another chat was put on screen: useChat can
|
// The messages shown when another chat was put on screen: useChat can
|
||||||
// show that chat's own up to 150 ms later than the rest of its state
|
// show that chat's own up to 150 ms later than the rest of its state
|
||||||
const messagesBeforeShownRef = useRef<UIMessage[] | null>(null)
|
const messagesBeforeShownRef = useRef<ChatUIMessage[] | null>(null)
|
||||||
|
|
||||||
const syncUIWithSession = useCallback(
|
const syncUIWithSession = useCallback(
|
||||||
(
|
(data: SessionData | null) => {
|
||||||
data:
|
|
||||||
| (SessionData & {
|
|
||||||
diagramHistory?: { svg: string; xml: string }[]
|
|
||||||
})
|
|
||||||
| null,
|
|
||||||
) => {
|
|
||||||
// An answer still coming in belongs to the chat that was on
|
// An answer still coming in belongs to the chat that was on
|
||||||
// screen (browser back or forward): it stops, before this chat's
|
// screen (browser back or forward): it stops, before this chat's
|
||||||
// messages are put in its place, so none of it lands here
|
// messages are put in its place, so none of it lands here
|
||||||
@@ -880,26 +833,17 @@ export function ChatEngineProvider({
|
|||||||
loadedMessageIdsRef.current = new Set(
|
loadedMessageIdsRef.current = new Set(
|
||||||
(data.messages as { id: string }[]).map((m) => m.id),
|
(data.messages as { id: string }[]).map((m) => m.id),
|
||||||
)
|
)
|
||||||
setMessages(data.messages as unknown as UIMessage[])
|
setMessages(data.messages as unknown as ChatUIMessage[])
|
||||||
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
||||||
if (data.diagramXml) {
|
if (data.diagramXml) onDisplayChart(data.diagramXml, true)
|
||||||
onDisplayChart(data.diagramXml, true)
|
else clearDiagram()
|
||||||
chartXMLRef.current = data.diagramXml
|
|
||||||
} else {
|
|
||||||
clearDiagram()
|
|
||||||
chartXMLRef.current = ""
|
|
||||||
}
|
|
||||||
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
|
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
|
||||||
useVersionsStore
|
useVersionsStore.getState().setVersions(data.versions ?? [])
|
||||||
.getState()
|
|
||||||
.setVersions(getSessionVersions(data))
|
|
||||||
} else {
|
} else {
|
||||||
loadedMessageIdsRef.current = new Set()
|
loadedMessageIdsRef.current = new Set()
|
||||||
setMessages([])
|
setMessages([])
|
||||||
xmlSnapshotsRef.current.clear()
|
xmlSnapshotsRef.current.clear()
|
||||||
clearDiagram()
|
clearDiagram()
|
||||||
chartXMLRef.current = ""
|
|
||||||
latestSvgRef.current = ""
|
|
||||||
useVersionsStore.getState().clear()
|
useVersionsStore.getState().clear()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -932,7 +876,6 @@ export function ChatEngineProvider({
|
|||||||
if (isRealDiagram(currentDiagramXml) && options.withThumbnail) {
|
if (isRealDiagram(currentDiagramXml) && options.withThumbnail) {
|
||||||
const freshThumb = await getThumbnailSvg()
|
const freshThumb = await getThumbnailSvg()
|
||||||
if (freshThumb) {
|
if (freshThumb) {
|
||||||
latestSvgRef.current = freshThumb
|
|
||||||
thumbnailDataUrl = freshThumb
|
thumbnailDataUrl = freshThumb
|
||||||
} else if (latestSvgRef.current) {
|
} else if (latestSvgRef.current) {
|
||||||
thumbnailDataUrl = latestSvgRef.current
|
thumbnailDataUrl = latestSvgRef.current
|
||||||
@@ -1197,12 +1140,15 @@ export function ChatEngineProvider({
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messageId: the user message sent again (regenerate, edit, retry); the
|
||||||
|
// SDK puts the new one in its place and drops the answers after it
|
||||||
const sendChatMessage = (
|
const sendChatMessage = (
|
||||||
parts: any,
|
parts: any,
|
||||||
xml: string,
|
xml: string,
|
||||||
previousXml: string,
|
previousXml: string,
|
||||||
turnIndex: number,
|
turnIndex: number,
|
||||||
selectedCells: MessageSelection,
|
selectedCells: MessageSelection,
|
||||||
|
messageId?: string,
|
||||||
) => {
|
) => {
|
||||||
autoRetryCountRef.current = 0
|
autoRetryCountRef.current = 0
|
||||||
continuationRetryCountRef.current = 0
|
continuationRetryCountRef.current = 0
|
||||||
@@ -1240,9 +1186,11 @@ export function ChatEngineProvider({
|
|||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
turnRequestsRef.current = 0
|
||||||
// The selection stays with the message, for regenerate and retry
|
// The selection stays with the message, for regenerate and retry
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{
|
{
|
||||||
|
...(messageId !== undefined && { messageId }),
|
||||||
parts,
|
parts,
|
||||||
...(selected.length > 0 && {
|
...(selected.length > 0 && {
|
||||||
metadata: { selectedCells: selected },
|
metadata: { selectedCells: selected },
|
||||||
@@ -1261,7 +1209,7 @@ export function ChatEngineProvider({
|
|||||||
selectedCells: MessageSelection = [],
|
selectedCells: MessageSelection = [],
|
||||||
generation = getChatGeneration(),
|
generation = getChatGeneration(),
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const chartXml = formatXML(await onFetchChart())
|
const chartXml = formatXML(await fetchDiagramXml())
|
||||||
if (getChatGeneration() !== generation || leavingRef.current) {
|
if (getChatGeneration() !== generation || leavingRef.current) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1344,12 +1292,8 @@ export function ChatEngineProvider({
|
|||||||
})
|
})
|
||||||
if (prepared.ok) {
|
if (prepared.ok) {
|
||||||
const pageId = viewedPageId()
|
const pageId = viewedPageId()
|
||||||
const canvasXml = chartXMLRef.current || ""
|
|
||||||
onDisplayChart(
|
onDisplayChart(
|
||||||
keepFileVars(
|
placeOnPage(prepared.xml, chartXMLRef.current, pageId),
|
||||||
placeOnPage(prepared.xml, canvasXml, pageId),
|
|
||||||
canvasXml,
|
|
||||||
),
|
|
||||||
true,
|
true,
|
||||||
"commit",
|
"commit",
|
||||||
{ toolCallId, pageId },
|
{ toolCallId, pageId },
|
||||||
@@ -1437,8 +1381,8 @@ export function ChatEngineProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Templates fill the input and send it right away (attachments included)
|
// Templates fill the input and send it right away (attachments included)
|
||||||
// Whether it was sent (it can run after a wait: the template panel
|
// Whether it was sent (it can run after a wait, so busy is read from
|
||||||
// stores its click count first, so busy is read from the ref)
|
// the ref)
|
||||||
const sendTemplate = async (prompt: string): Promise<boolean> => {
|
const sendTemplate = async (prompt: string): Promise<boolean> => {
|
||||||
// Like the send button, it waits for an answer or a message being
|
// Like the send button, it waits for an answer or a message being
|
||||||
// prepared (that message may still come back to the input), and for
|
// prepared (that message may still come back to the input), and for
|
||||||
@@ -1478,11 +1422,6 @@ export function ChatEngineProvider({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreDiagramFromSnapshot = (savedXml: string) => {
|
|
||||||
onDisplayChart(savedXml, true)
|
|
||||||
chartXMLRef.current = savedXml
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshots of the messages after this one go with them
|
// Snapshots of the messages after this one go with them
|
||||||
const dropLaterSnapshots = (messageIndex: number) => {
|
const dropLaterSnapshots = (messageIndex: number) => {
|
||||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||||
@@ -1500,22 +1439,17 @@ export function ChatEngineProvider({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const previousXml = getPreviousXml(index)
|
const previousXml = getPreviousXml(index)
|
||||||
restoreDiagramFromSnapshot(savedXml)
|
onDisplayChart(savedXml, true)
|
||||||
dropLaterSnapshots(index)
|
dropLaterSnapshots(index)
|
||||||
useVersionsStore.getState().removeFromTurn(index)
|
useVersionsStore.getState().removeFromTurn(index)
|
||||||
|
|
||||||
// Remove the user message onwards (sendMessage re-adds it). For a
|
|
||||||
// moment there may be no message: that is no empty chat.
|
|
||||||
flushSync(() => {
|
|
||||||
setIsResending(true)
|
|
||||||
setMessages(messages.slice(0, index))
|
|
||||||
})
|
|
||||||
sendChatMessage(
|
sendChatMessage(
|
||||||
parts,
|
parts,
|
||||||
getPageXml(savedXml, viewedPageId()),
|
getPageXml(savedXml, viewedPageId()),
|
||||||
previousXml,
|
previousXml,
|
||||||
index,
|
index,
|
||||||
selectionOf(messages[index]),
|
selectionOf(messages[index]),
|
||||||
|
messages[index].id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -1568,7 +1502,7 @@ export function ChatEngineProvider({
|
|||||||
// Like submit: the diagram export comes first
|
// Like submit: the diagram export comes first
|
||||||
preparingSendRef.current = true
|
preparingSendRef.current = true
|
||||||
try {
|
try {
|
||||||
const chartXml = formatXML(await onFetchChart())
|
const chartXml = formatXML(await fetchDiagramXml())
|
||||||
// Another chat came on screen meanwhile (browser back/forward),
|
// Another chat came on screen meanwhile (browser back/forward),
|
||||||
// or this one is being left
|
// or this one is being left
|
||||||
if (getChatGeneration() !== generation || leavingRef.current) {
|
if (getChatGeneration() !== generation || leavingRef.current) {
|
||||||
@@ -1581,16 +1515,13 @@ export function ChatEngineProvider({
|
|||||||
chartXMLRef.current || chartXml,
|
chartXMLRef.current || chartXml,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
flushSync(() => {
|
|
||||||
setIsResending(true)
|
|
||||||
setMessages(messagesRef.current.slice(0, index))
|
|
||||||
})
|
|
||||||
sendChatMessage(
|
sendChatMessage(
|
||||||
message.parts,
|
message.parts,
|
||||||
chartXml,
|
chartXml,
|
||||||
getPreviousXml(index),
|
getPreviousXml(index),
|
||||||
index,
|
index,
|
||||||
selectionOf(message),
|
selectionOf(message),
|
||||||
|
message.id,
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching chart data:", error)
|
console.error("Error fetching chart data:", error)
|
||||||
@@ -1609,25 +1540,17 @@ export function ChatEngineProvider({
|
|||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
// Calls the tool handler already took can still show as streaming:
|
// Calls the tool handler already took can still show as streaming:
|
||||||
// the messages update at most every 150 ms (useChat throttle)
|
// the messages update at most every 150 ms (useChat throttle)
|
||||||
const toolParts = lastMessage?.parts?.filter(
|
const streaming = (lastMessage?.parts ?? [])
|
||||||
(part: any) =>
|
.filter(isToolUIPart)
|
||||||
part.type?.startsWith("tool-") &&
|
.filter(
|
||||||
part.state === "input-streaming" &&
|
(part) =>
|
||||||
!processedToolCallsRef.current.has(part.toolCallId),
|
part.state === "input-streaming" &&
|
||||||
)
|
!processedToolCallsRef.current.has(part.toolCallId),
|
||||||
for (const part of (toolParts ?? []) as any[]) {
|
)
|
||||||
if (part.toolCallId) {
|
for (const part of streaming) stopToolCall(part)
|
||||||
addToolOutputSafely({
|
|
||||||
tool: part.type.replace("tool-", ""),
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: "Stopped by user",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop()
|
stop()
|
||||||
abandonContinuation()
|
abandonContinuation()
|
||||||
}, [messages, addToolOutputSafely, stop, cancelValidation])
|
}, [messages, stopToolCall, stop, cancelValidation])
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Session actions
|
// Session actions
|
||||||
@@ -1937,8 +1860,7 @@ export function ChatEngineProvider({
|
|||||||
error,
|
error,
|
||||||
isBusy,
|
isBusy,
|
||||||
isLeaving,
|
isLeaving,
|
||||||
isResending,
|
setMessages: setMessages as (messages: ChatUIMessage[]) => void,
|
||||||
setMessages: setMessages as (messages: UIMessage[]) => void,
|
|
||||||
stop: handleStop,
|
stop: handleStop,
|
||||||
input,
|
input,
|
||||||
setInput,
|
setInput,
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
|
||||||
import { FilePreviewList } from "@/components/file-preview-list"
|
import { FilePreviewList } from "@/components/file-preview-list"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
import {
|
import {
|
||||||
@@ -46,24 +45,16 @@ import { useUiStore } from "@/stores/ui-store"
|
|||||||
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
|
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
|
||||||
import { useChatEngine } from "./chat-engine"
|
import { useChatEngine } from "./chat-engine"
|
||||||
|
|
||||||
function showErrorToast(message: React.ReactNode) {
|
|
||||||
toast.custom(
|
|
||||||
(t) => (
|
|
||||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
|
||||||
),
|
|
||||||
{ duration: 5000 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showValidationErrors(errors: string[], dict: any) {
|
function showValidationErrors(errors: string[], dict: any) {
|
||||||
if (errors.length === 0) return
|
if (errors.length === 0) return
|
||||||
if (errors.length === 1) {
|
if (errors.length === 1) {
|
||||||
showErrorToast(
|
toast.error(
|
||||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||||
|
{ duration: 5000 },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showErrorToast(
|
toast.error(
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatMessage(dict.errors.filesRejected, {
|
{formatMessage(dict.errors.filesRejected, {
|
||||||
@@ -83,6 +74,7 @@ function showValidationErrors(errors: string[], dict: any) {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>,
|
</div>,
|
||||||
|
{ duration: 5000 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,10 +269,11 @@ export function Composer({
|
|||||||
next.delete(url)
|
next.delete(url)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
showErrorToast(
|
toast.error(
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{err instanceof Error ? err.message : dict.url.failed}
|
{err instanceof Error ? err.message : dict.url.failed}
|
||||||
</span>,
|
</span>,
|
||||||
|
{ duration: 5000 },
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setIsExtractingUrl(false)
|
setIsExtractingUrl(false)
|
||||||
@@ -519,7 +512,7 @@ export function Composer({
|
|||||||
onSubmit={handleUrlExtract}
|
onSubmit={handleUrlExtract}
|
||||||
isExtracting={isExtractingUrl}
|
isExtracting={isExtractingUrl}
|
||||||
/>
|
/>
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open={saveTemplateOpen}
|
open={saveTemplateOpen}
|
||||||
onOpenChange={setSaveTemplateOpen}
|
onOpenChange={setSaveTemplateOpen}
|
||||||
onSuccess={() => setSaveTemplateOpen(false)}
|
onSuccess={() => setSaveTemplateOpen(false)}
|
||||||
@@ -532,10 +525,6 @@ export function Composer({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
|
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
|
||||||
<TemplatePanel
|
<TemplatePanel
|
||||||
setInput={(value) => {
|
|
||||||
setInput(value)
|
|
||||||
setTemplatesOpen(false)
|
|
||||||
}}
|
|
||||||
currentInput={input}
|
currentInput={input}
|
||||||
onSendTemplate={(template) => {
|
onSendTemplate={(template) => {
|
||||||
setTemplatesOpen(false)
|
setTemplatesOpen(false)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileText, FileUp, History, Pencil } from "lucide-react"
|
import { FileUp, History, Pencil } from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getAssetUrl } from "@/lib/base-path"
|
import { getAssetUrl } from "@/lib/base-path"
|
||||||
@@ -12,7 +12,7 @@ interface Example {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
/** Picture of the result (or of the input, for "replicate" examples) */
|
/** Picture of the result (or of the input, for "replicate" examples) */
|
||||||
image?: string
|
image: string
|
||||||
prompt: string
|
prompt: string
|
||||||
/** Bundled file sent with the prompt; matches the cached example */
|
/** Bundled file sent with the prompt; matches the cached example */
|
||||||
file?: { path: string; name: string; type: string }
|
file?: { path: string; name: string; type: string }
|
||||||
@@ -34,7 +34,6 @@ function ExampleTile({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
picture,
|
picture,
|
||||||
plain,
|
|
||||||
testId,
|
testId,
|
||||||
}: {
|
}: {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
@@ -42,8 +41,6 @@ function ExampleTile({
|
|||||||
/** Shown as the tooltip: the title alone names the example */
|
/** Shown as the tooltip: the title alone names the example */
|
||||||
description: string
|
description: string
|
||||||
picture: React.ReactNode
|
picture: React.ReactNode
|
||||||
/** An icon on a tinted tile instead of a diagram on paper */
|
|
||||||
plain?: boolean
|
|
||||||
testId?: string
|
testId?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -54,12 +51,7 @@ function ExampleTile({
|
|||||||
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
|
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
<span
|
<span className="flex h-16 w-full items-center justify-center overflow-hidden rounded-lg sheet-light bg-white">
|
||||||
className={cn(
|
|
||||||
"flex h-16 w-full items-center justify-center overflow-hidden rounded-lg",
|
|
||||||
plain ? "bg-muted" : "sheet-light bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{picture}
|
{picture}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
|
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
|
||||||
@@ -220,18 +212,13 @@ export function LobbyHero({
|
|||||||
description={example.description}
|
description={example.description}
|
||||||
testId={`example-${example.key}`}
|
testId={`example-${example.key}`}
|
||||||
picture={
|
picture={
|
||||||
example.image ? (
|
// biome-ignore lint/performance/noImgElement: static preview
|
||||||
// biome-ignore lint/performance/noImgElement: static preview
|
<img
|
||||||
<img
|
src={getAssetUrl(example.image)}
|
||||||
src={getAssetUrl(example.image)}
|
alt=""
|
||||||
alt=""
|
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
||||||
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FileText className="size-5 text-muted-foreground" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
plain={!example.image}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UIMessage } from "ai"
|
import { isToolUIPart, type UIMessage } from "ai"
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
BookmarkPlus,
|
BookmarkPlus,
|
||||||
@@ -16,18 +16,15 @@ import {
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import { Reasoning } from "@/components/ai-elements/reasoning"
|
||||||
Reasoning,
|
|
||||||
ReasoningContent,
|
|
||||||
ReasoningTrigger,
|
|
||||||
} from "@/components/ai-elements/reasoning"
|
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||||
import type { ToolPartLike } from "@/components/chat/types"
|
import type { ToolPartLike } from "@/components/chat/types"
|
||||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||||
import Image from "@/components/image-with-basepath"
|
import Image from "@/components/image-with-basepath"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import { noticeOf } from "@/lib/chat-message"
|
||||||
import { useCopy } from "@/lib/clipboard"
|
import { useCopy } from "@/lib/clipboard"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
@@ -291,7 +288,7 @@ function UserMessage({
|
|||||||
{text && <MessageText text={text} />}
|
{text && <MessageText text={text} />}
|
||||||
</div>
|
</div>
|
||||||
{saveTemplate && (
|
{saveTemplate && (
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open
|
open
|
||||||
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
||||||
onSuccess={() => setSaveTemplate(false)}
|
onSuccess={() => setSaveTemplate(false)}
|
||||||
@@ -373,7 +370,7 @@ function AssistantMessage({
|
|||||||
parts.forEach((part, partIndex) => {
|
parts.forEach((part, partIndex) => {
|
||||||
if (part.type === "reasoning")
|
if (part.type === "reasoning")
|
||||||
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
|
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
|
||||||
else if (part.type?.startsWith("tool-"))
|
else if (isToolUIPart(part))
|
||||||
blocks.push({ kind: "tool", index: partIndex, texts: [] })
|
blocks.push({ kind: "tool", index: partIndex, texts: [] })
|
||||||
else if (part.type === "text") {
|
else if (part.type === "text") {
|
||||||
const text = (part as { text: string }).text
|
const text = (part as { text: string }).text
|
||||||
@@ -398,13 +395,9 @@ function AssistantMessage({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
isStreaming={isStreamingReasoning}
|
isStreaming={isStreamingReasoning}
|
||||||
defaultOpen={!isRestored}
|
defaultOpen={!isRestored}
|
||||||
|
getThinkingMessage={thinkingMessage}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger
|
{(part as { text: string }).text}
|
||||||
getThinkingMessage={thinkingMessage}
|
|
||||||
/>
|
|
||||||
<ReasoningContent>
|
|
||||||
{(part as { text: string }).text}
|
|
||||||
</ReasoningContent>
|
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -576,9 +569,10 @@ export function MessageList() {
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const lastAssistantIndex = messages
|
// A notice (a failed request) is an assistant message without an answer
|
||||||
.map((m) => m.role)
|
const lastAssistantIndex = messages.findLastIndex(
|
||||||
.lastIndexOf("assistant")
|
(m) => m.role === "assistant" && !noticeOf(m),
|
||||||
|
)
|
||||||
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -587,6 +581,7 @@ export function MessageList() {
|
|||||||
const isRestored = engine.loadedMessageIdsRef.current.has(
|
const isRestored = engine.loadedMessageIdsRef.current.has(
|
||||||
message.id,
|
message.id,
|
||||||
)
|
)
|
||||||
|
const notice = noticeOf(message)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -598,33 +593,18 @@ export function MessageList() {
|
|||||||
index={index}
|
index={index}
|
||||||
isLast={index === lastUserIndex}
|
isLast={index === lastUserIndex}
|
||||||
/>
|
/>
|
||||||
) : message.role === "assistant" ? (
|
) : notice ? (
|
||||||
<AssistantMessage
|
|
||||||
message={message}
|
|
||||||
index={index}
|
|
||||||
isLastMessage={index === messages.length - 1}
|
|
||||||
isLastAssistant={index === lastAssistantIndex}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ErrorNotice
|
<ErrorNotice
|
||||||
text={getMessageTextContent(message)}
|
text={notice.text}
|
||||||
openModelConfig={
|
openModelConfig={!!notice.openModelConfig}
|
||||||
!!(
|
onOpenModelConfig={() =>
|
||||||
message.metadata as
|
|
||||||
| { openModelConfig?: boolean }
|
|
||||||
| undefined
|
|
||||||
)?.openModelConfig
|
|
||||||
}
|
|
||||||
onOpenModelConfig={() => {
|
|
||||||
const { providerId } =
|
|
||||||
(message.metadata as
|
|
||||||
| { providerId?: string }
|
|
||||||
| undefined) ?? {}
|
|
||||||
openSettings(
|
openSettings(
|
||||||
"models",
|
"models",
|
||||||
providerId ? { providerId } : "list",
|
notice.providerId
|
||||||
|
? { providerId: notice.providerId }
|
||||||
|
: "list",
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
onRetry={
|
onRetry={
|
||||||
index === messages.length - 1 &&
|
index === messages.length - 1 &&
|
||||||
!engine.isBusy &&
|
!engine.isBusy &&
|
||||||
@@ -633,6 +613,13 @@ export function MessageList() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<AssistantMessage
|
||||||
|
message={message}
|
||||||
|
index={index}
|
||||||
|
isLastMessage={index === messages.length - 1}
|
||||||
|
isLastAssistant={index === lastAssistantIndex}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getToolName } from "ai"
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
@@ -164,7 +165,7 @@ function ToolRow({
|
|||||||
tone === "error" && "border-destructive/25",
|
tone === "error" && "border-destructive/25",
|
||||||
)}
|
)}
|
||||||
data-testid="tool-row"
|
data-testid="tool-row"
|
||||||
data-tool-name={part.type?.replace("tool-", "")}
|
data-tool-name={getToolName(part)}
|
||||||
data-tool-state={part.state}
|
data-tool-state={part.state}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -246,7 +247,7 @@ export function ToolActivity({
|
|||||||
const t = dict.versions
|
const t = dict.versions
|
||||||
const engine = useChatEngine()
|
const engine = useChatEngine()
|
||||||
const versions = useVersionsStore((s) => s.versions)
|
const versions = useVersionsStore((s) => s.versions)
|
||||||
const toolName = part.type?.replace("tool-", "")
|
const toolName = getToolName(part)
|
||||||
const { state, errorText, output } = part
|
const { state, errorText, output } = part
|
||||||
|
|
||||||
const versionIndex = versions.findIndex(
|
const versionIndex = versions.findIndex(
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
|
import type { DynamicToolUIPart, ToolUIPart, UITools } from "ai"
|
||||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||||
|
|
||||||
export type { DiagramOperation }
|
export type { DiagramOperation }
|
||||||
|
|
||||||
export interface ToolPartLike {
|
/**
|
||||||
type: string
|
* A tool part as the UI reads it, in any state: input, output and errorText
|
||||||
toolCallId: string
|
* are each there or not, instead of one shape per state.
|
||||||
state?: string
|
*/
|
||||||
input?: {
|
export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & {
|
||||||
xml?: string
|
input?:
|
||||||
operations?: DiagramOperation[]
|
| ({
|
||||||
} & Record<string, unknown>
|
xml?: string
|
||||||
|
operations?: DiagramOperation[]
|
||||||
|
} & Record<string, unknown>)
|
||||||
|
| undefined
|
||||||
output?: string
|
output?: string
|
||||||
errorText?: string
|
errorText?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,25 +5,25 @@ import type { RefObject } from "react"
|
|||||||
import { useCallback, useEffect, useRef } from "react"
|
import { useCallback, useEffect, useRef } from "react"
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { pageSelectorFor } from "@/lib/diagram-pages"
|
import { pageSelectorFor, replacePageModel } from "@/lib/diagram-pages"
|
||||||
import {
|
import { extractCompleteMxCells } from "@/lib/utils"
|
||||||
convertToLegalXml,
|
|
||||||
extractCompleteMxCells,
|
|
||||||
replaceNodes,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import {
|
import {
|
||||||
defaultLayerOf,
|
defaultLayerOf,
|
||||||
expandCompactCells,
|
expandCompactCells,
|
||||||
} from "@/packages/mcp-server/src/compact-cells.ts"
|
} from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||||
import { targetPageXml } from "@/packages/mcp-server/src/edit-diagram.ts"
|
import { targetPageXml } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
import {
|
||||||
|
BLANK_MXFILE,
|
||||||
|
wrapCellsInModel,
|
||||||
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
import {
|
import {
|
||||||
addDefaultStyles,
|
addDefaultStyles,
|
||||||
applyStyleClasses,
|
applyStyleClasses,
|
||||||
edgeIdsOf,
|
edgeIdsOf,
|
||||||
readStyleClasses,
|
readStyleClasses,
|
||||||
} from "@/packages/mcp-server/src/style-classes.ts"
|
} from "@/packages/mcp-server/src/style-classes.ts"
|
||||||
|
import { autoFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -91,32 +91,31 @@ export function useDiagramStreaming({
|
|||||||
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
|
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
|
||||||
const completeCells = extractCompleteMxCells(cellsXml)
|
const completeCells = extractCompleteMxCells(cellsXml)
|
||||||
if (!completeCells) return
|
if (!completeCells) return
|
||||||
const convertedXml = addDefaultStyles(
|
// The same wrapping, fixes and expansion as the final diagram
|
||||||
|
// (prepareNewDiagram), so the preview matches what is committed
|
||||||
|
const model = addDefaultStyles(
|
||||||
applyStyleClasses(
|
applyStyleClasses(
|
||||||
expandCompactCells(convertToLegalXml(completeCells)),
|
expandCompactCells(
|
||||||
|
autoFixXml(wrapCellsInModel(completeCells)).fixed,
|
||||||
|
),
|
||||||
classes,
|
classes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (convertedXml === previousXML.current) return
|
if (model === previousXML.current) return
|
||||||
|
|
||||||
// Skip this update while the cells written so far don't parse
|
// Skip this update while the cells written so far don't parse
|
||||||
const testDoc = new DOMParser().parseFromString(
|
const testDoc = new DOMParser().parseFromString(model, "text/xml")
|
||||||
`<root>${convertedXml}</root>`,
|
|
||||||
"text/xml",
|
|
||||||
)
|
|
||||||
if (testDoc.querySelector("parsererror")) return
|
if (testDoc.querySelector("parsererror")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Replace the model's page's cells so other pages stay
|
// Replace the model's page so other pages stay intact; an
|
||||||
// intact. An empty canvas gets a default mxfile to put the
|
// empty canvas gets the blank mxfile to put the page in
|
||||||
// cells in.
|
const replacedXML = replacePageModel(
|
||||||
const baseXML = chartXML || BLANK_MXFILE
|
chartXML,
|
||||||
const replacedXML = replaceNodes(
|
|
||||||
baseXML,
|
|
||||||
convertedXml,
|
|
||||||
turnPageIdRef?.current,
|
turnPageIdRef?.current,
|
||||||
|
model,
|
||||||
)
|
)
|
||||||
previousXML.current = convertedXml
|
previousXML.current = model
|
||||||
loadDiagram(replacedXML, true, "preview")
|
loadDiagram(replacedXML, true, "preview")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing XML:", error)
|
console.error("Error processing XML:", error)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
|
||||||
// Dev XML presets for streaming simulator
|
// Dev XML presets for streaming simulator
|
||||||
@@ -135,15 +134,12 @@ const DEV_XML_PRESETS: Record<string, string> = {
|
|||||||
interface DevXmlSimulatorProps {
|
interface DevXmlSimulatorProps {
|
||||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||||
onDisplayChart: (xml: string) => void
|
onDisplayChart: (xml: string) => void
|
||||||
onShowQuotaToast?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevXmlSimulator({
|
export function DevXmlSimulator({
|
||||||
setMessages,
|
setMessages,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onShowQuotaToast,
|
|
||||||
}: DevXmlSimulatorProps) {
|
}: DevXmlSimulatorProps) {
|
||||||
const dict = useDictionary()
|
|
||||||
const [devXml, setDevXml] = useState("")
|
const [devXml, setDevXml] = useState("")
|
||||||
const [isSimulating, setIsSimulating] = useState(false)
|
const [isSimulating, setIsSimulating] = useState(false)
|
||||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||||
@@ -180,7 +176,7 @@ export function DevXmlSimulator({
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: dict.dev.simulatingMessage,
|
text: "[Dev] Simulating XML streaming",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -230,7 +226,7 @@ export function DevXmlSimulator({
|
|||||||
const lastMsg = updated[updated.length - 1] as any
|
const lastMsg = updated[updated.length - 1] as any
|
||||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||||
lastMsg.parts[0].state = "output-available"
|
lastMsg.parts[0].state = "output-available"
|
||||||
lastMsg.parts[0].output = dict.dev.successMessage
|
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||||
lastMsg.parts[0].input = { xml }
|
lastMsg.parts[0].input = { xml }
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
@@ -247,12 +243,12 @@ export function DevXmlSimulator({
|
|||||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||||
<details>
|
<details>
|
||||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||||
{dict.dev.title}
|
Dev: XML Streaming Simulator
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.preset}
|
Preset:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -264,7 +260,7 @@ export function DevXmlSimulator({
|
|||||||
defaultValue=""
|
defaultValue=""
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{dict.dev.selectPreset}
|
Select a preset...
|
||||||
</option>
|
</option>
|
||||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||||
<option key={name} value={name}>
|
<option key={name} value={name}>
|
||||||
@@ -277,19 +273,19 @@ export function DevXmlSimulator({
|
|||||||
onClick={() => setDevXml("")}
|
onClick={() => setDevXml("")}
|
||||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||||
>
|
>
|
||||||
{dict.dev.clear}
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={devXml}
|
value={devXml}
|
||||||
onChange={(e) => setDevXml(e.target.value)}
|
onChange={(e) => setDevXml(e.target.value)}
|
||||||
placeholder={dict.dev.placeholder}
|
placeholder="Paste mxCell XML here or select a preset..."
|
||||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.interval}
|
Interval:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -308,7 +304,7 @@ export function DevXmlSimulator({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.chars}
|
Chars:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -332,8 +328,8 @@ export function DevXmlSimulator({
|
|||||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isSimulating
|
{isSimulating
|
||||||
? dict.dev.streaming
|
? "Streaming..."
|
||||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||||
</button>
|
</button>
|
||||||
{isSimulating && (
|
{isSimulating && (
|
||||||
<button
|
<button
|
||||||
@@ -343,16 +339,7 @@ export function DevXmlSimulator({
|
|||||||
}}
|
}}
|
||||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
>
|
>
|
||||||
{dict.dev.stop}
|
Stop
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onShowQuotaToast && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onShowQuotaToast}
|
|
||||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
|
||||||
>
|
|
||||||
{dict.dev.testQuotaToast}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
|
|
||||||
interface ErrorToastProps {
|
|
||||||
message: React.ReactNode
|
|
||||||
onDismiss: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onDismiss}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-destructive"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// GitHub mark as an inline SVG, so no icon package is needed for one icon
|
||||||
|
export function GitHubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command"
|
} from "@/components/ui/command"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -164,7 +163,6 @@ export function ModelConfigDialog({
|
|||||||
)
|
)
|
||||||
const [customModelInput, setCustomModelInput] = useState("")
|
const [customModelInput, setCustomModelInput] = useState("")
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("")
|
|
||||||
// Models whose test is running (they are all tested at once)
|
// Models whose test is running (they are all tested at once)
|
||||||
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
|
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
@@ -441,7 +439,6 @@ export function ModelConfigDialog({
|
|||||||
setFetchModelsError("")
|
setFetchModelsError("")
|
||||||
updates = {
|
updates = {
|
||||||
...updates,
|
...updates,
|
||||||
validated: false,
|
|
||||||
models: selectedProvider.models.map((m) => ({
|
models: selectedProvider.models.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
validated: undefined,
|
validated: undefined,
|
||||||
@@ -659,10 +656,6 @@ export function ModelConfigDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A model whose id changed was not tested
|
|
||||||
if (allValid && !idChanged) {
|
|
||||||
updateProvider(selectedProviderId, { validated: true })
|
|
||||||
}
|
|
||||||
markRejected(selectedProviderId, askedWith, rejected)
|
markRejected(selectedProviderId, askedWith, rejected)
|
||||||
// The status line is about the provider shown now
|
// The status line is about the provider shown now
|
||||||
if (selectedProviderIdRef.current !== selectedProviderId) return
|
if (selectedProviderIdRef.current !== selectedProviderId) return
|
||||||
@@ -1004,7 +997,6 @@ export function ModelConfigDialog({
|
|||||||
name={provider.name}
|
name={provider.name}
|
||||||
baseUrl={provider.baseUrl}
|
baseUrl={provider.baseUrl}
|
||||||
awsRegion={provider.awsRegion}
|
awsRegion={provider.awsRegion}
|
||||||
bedrockApiKey
|
|
||||||
settingsLayout
|
settingsLayout
|
||||||
bedrockFilled={{
|
bedrockFilled={{
|
||||||
apiKey: !!provider.apiKey,
|
apiKey: !!provider.apiKey,
|
||||||
@@ -1389,10 +1381,7 @@ export function ModelConfigDialog({
|
|||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteConfirmOpen}
|
open={deleteConfirmOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={setDeleteConfirmOpen}
|
||||||
setDeleteConfirmOpen(open)
|
|
||||||
if (!open) setDeleteConfirmText("")
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="border-destructive/30">
|
<AlertDialogContent className="border-destructive/30">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -1410,39 +1399,11 @@ export function ModelConfigDialog({
|
|||||||
})}
|
})}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
{selectedProvider &&
|
|
||||||
selectedProvider.models.length >= 3 && (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="delete-confirm"
|
|
||||||
className="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{formatMessage(t.typeToConfirm, {
|
|
||||||
name: providerName(selectedProvider),
|
|
||||||
})}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="delete-confirm"
|
|
||||||
value={deleteConfirmText}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDeleteConfirmText(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={t.typeProviderName}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDeleteProvider}
|
onClick={handleDeleteProvider}
|
||||||
disabled={
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
selectedProvider &&
|
|
||||||
selectedProvider.models.length >= 3 &&
|
|
||||||
deleteConfirmText !==
|
|
||||||
providerName(selectedProvider)
|
|
||||||
}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{t.delete}
|
{t.delete}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
+368
-334
@@ -10,28 +10,38 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
ModelSelectorContent,
|
type ComponentProps,
|
||||||
ModelSelectorEmpty,
|
type ReactNode,
|
||||||
ModelSelectorGroup,
|
useEffect,
|
||||||
ModelSelectorInput,
|
useMemo,
|
||||||
ModelSelectorItem,
|
useRef,
|
||||||
ModelSelectorList,
|
useState,
|
||||||
ModelSelectorLogo,
|
} from "react"
|
||||||
ModelSelectorName,
|
|
||||||
ModelSelector as ModelSelectorRoot,
|
|
||||||
ModelSelectorSectionHeader,
|
|
||||||
ModelSelectorSeparator,
|
|
||||||
ModelSelectorTrigger,
|
|
||||||
} from "@/components/ai-elements/model-selector"
|
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
|
||||||
import { ProviderLogo } from "@/components/provider-logo"
|
import { ProviderLogo } from "@/components/provider-logo"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
type FlattenedModel,
|
Command,
|
||||||
PROVIDER_LOGO_MAP,
|
CommandEmpty,
|
||||||
} from "@/lib/types/model-config"
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import type { FlattenedModel, ProviderName } from "@/lib/types/model-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
@@ -44,25 +54,22 @@ interface ModelSelectorProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] }
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
function groupModelsByProvider(
|
function groupModelsByProvider(
|
||||||
models: FlattenedModel[],
|
models: FlattenedModel[],
|
||||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
): Map<string, ProviderGroup> {
|
||||||
const groups = new Map<
|
const groups = new Map<string, ProviderGroup>()
|
||||||
string,
|
|
||||||
{ provider: string; models: FlattenedModel[] }
|
|
||||||
>()
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
// For server models, strip "Server · " prefix for cleaner grouping
|
const existing = groups.get(model.providerLabel)
|
||||||
const key =
|
|
||||||
model.source === "server"
|
|
||||||
? model.providerLabel.replace(/^Server · /, "")
|
|
||||||
: model.providerLabel
|
|
||||||
const existing = groups.get(key)
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.models.push(model)
|
existing.models.push(model)
|
||||||
} else {
|
} else {
|
||||||
groups.set(key, { provider: model.provider, models: [model] })
|
groups.set(model.providerLabel, {
|
||||||
|
provider: model.provider,
|
||||||
|
models: [model],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
@@ -88,6 +95,72 @@ export function shortModelName(id: string): string {
|
|||||||
return short
|
return short
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The command list with a shadow at the bottom while more items are below
|
||||||
|
function ModelList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<typeof CommandList>) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [showShadow, setShowShadow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listElement = listRef.current
|
||||||
|
if (!listElement) return
|
||||||
|
|
||||||
|
const checkScroll = () => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||||
|
// A small threshold handles fractional pixel rendering
|
||||||
|
setShowShadow(
|
||||||
|
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkScroll()
|
||||||
|
listElement.addEventListener("scroll", checkScroll)
|
||||||
|
window.addEventListener("resize", checkScroll)
|
||||||
|
// Content changes, e.g. the search filtering items
|
||||||
|
const observer = new MutationObserver(checkScroll)
|
||||||
|
observer.observe(listElement, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listElement.removeEventListener("scroll", checkScroll)
|
||||||
|
window.removeEventListener("resize", checkScroll)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<CommandList
|
||||||
|
ref={listRef}
|
||||||
|
className={cn(
|
||||||
|
// Hide scrollbar on all platforms
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||||
|
showShadow ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ icon, label }: { icon: ReactNode; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1">
|
||||||
|
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
models,
|
models,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
@@ -146,6 +219,52 @@ export function ModelSelector({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One group per provider; `trailing` is the small label at the end of a
|
||||||
|
// row ("Default" for server models, the test state for user models)
|
||||||
|
const renderGroups = (
|
||||||
|
groups: Map<string, ProviderGroup>,
|
||||||
|
keyPrefix: "server" | "user",
|
||||||
|
trailing: (model: FlattenedModel) => ReactNode,
|
||||||
|
) =>
|
||||||
|
Array.from(groups.entries()).map(
|
||||||
|
([providerLabel, { provider, models: providerModels }]) => (
|
||||||
|
<CommandGroup
|
||||||
|
key={`${keyPrefix}-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map((model) => (
|
||||||
|
<CommandItem
|
||||||
|
key={model.id}
|
||||||
|
// Unique value so same-named models highlight
|
||||||
|
// separately; keywords keep search by name
|
||||||
|
value={model.id}
|
||||||
|
keywords={[model.modelId, providerLabel]}
|
||||||
|
onSelect={() => handleSelect(model.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId === model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ProviderLogo
|
||||||
|
provider={provider}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{model.modelId}
|
||||||
|
</span>
|
||||||
|
{trailing(model)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const tooltipContent = selectedModel
|
const tooltipContent = selectedModel
|
||||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||||
@@ -186,329 +305,244 @@ export function ModelSelector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||||
<ModelSelectorRoot
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(next) => {
|
onOpenChange={(next) => {
|
||||||
if (next) toSettingsRef.current = false
|
if (next) toSettingsRef.current = false
|
||||||
setOpen(next)
|
setOpen(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModelSelectorTrigger asChild>
|
{/* The dialog trigger must wrap the button itself: Radix
|
||||||
<ButtonWithTooltip
|
Tooltip's root renders no element to pass its props to */}
|
||||||
tooltipContent={tooltipContent}
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<DialogTrigger asChild>
|
||||||
disabled={disabled}
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
size="sm"
|
||||||
!showLabel && "px-1.5 justify-center",
|
disabled={disabled}
|
||||||
)}
|
className={cn(
|
||||||
// accessibility: expose label to screen readers
|
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
||||||
aria-label={tooltipContent}
|
!showLabel && "px-1.5 justify-center",
|
||||||
data-testid="model-selector"
|
)}
|
||||||
>
|
// accessibility: expose label to screen readers
|
||||||
{selectedModel ? (
|
aria-label={tooltipContent}
|
||||||
<ProviderLogo
|
data-testid="model-selector"
|
||||||
provider={selectedModel.provider}
|
>
|
||||||
className="size-3.5 flex-shrink-0 opacity-80"
|
{selectedModel ? (
|
||||||
/>
|
<ProviderLogo
|
||||||
) : (
|
provider={selectedModel.provider}
|
||||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
className="size-3.5 flex-shrink-0 opacity-80"
|
||||||
)}
|
/>
|
||||||
{/* show/hide visible label based on measured width */}
|
) : (
|
||||||
{showLabel ? (
|
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="min-w-0 truncate text-xs">
|
)}
|
||||||
{selectedModel
|
{/* show/hide visible label based on measured width */}
|
||||||
? shortModelName(selectedModel.modelId)
|
{showLabel ? (
|
||||||
: dict.modelConfig.serverDefault}
|
<span className="min-w-0 truncate text-xs">
|
||||||
</span>
|
{selectedModel
|
||||||
) : (
|
? shortModelName(
|
||||||
// Keep an sr-only label for screen readers when hidden
|
selectedModel.modelId,
|
||||||
<span className="sr-only">
|
)
|
||||||
{selectedModel
|
: dict.modelConfig.serverDefault}
|
||||||
? selectedModel.modelId
|
</span>
|
||||||
: dict.modelConfig.serverDefault}
|
) : (
|
||||||
</span>
|
// Keep an sr-only label for screen readers when hidden
|
||||||
)}
|
<span className="sr-only">
|
||||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
{selectedModel
|
||||||
</ButtonWithTooltip>
|
? selectedModel.modelId
|
||||||
</ModelSelectorTrigger>
|
: dict.modelConfig.serverDefault}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs text-wrap">
|
||||||
|
{tooltipContent}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<ModelSelectorContent
|
<DialogContent
|
||||||
title={dict.modelConfig.selectModel}
|
className="p-0"
|
||||||
onCloseAutoFocus={(e) => {
|
onCloseAutoFocus={(e) => {
|
||||||
if (!toSettingsRef.current) return
|
if (!toSettingsRef.current) return
|
||||||
toSettingsRef.current = false
|
toSettingsRef.current = false
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModelSelectorInput
|
<DialogTitle className="sr-only">
|
||||||
placeholder={dict.modelConfig.searchModels}
|
{dict.modelConfig.selectModel}
|
||||||
/>
|
</DialogTitle>
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<CommandInput
|
||||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
className="h-auto py-3.5"
|
||||||
<ModelSelectorEmpty>
|
placeholder={dict.modelConfig.searchModels}
|
||||||
{dict.modelConfig.noModelsFound}
|
/>
|
||||||
</ModelSelectorEmpty>
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<ModelList className="overflow-y-auto scrollbar-thin">
|
||||||
|
<CommandEmpty>
|
||||||
|
{dict.modelConfig.noModelsFound}
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
{/* Server Default Option - only show when no server models are configured */}
|
{/* Server Default Option - only show when no server models are configured */}
|
||||||
{serverModels.length === 0 && (
|
{serverModels.length === 0 && (
|
||||||
<ModelSelectorGroup
|
<CommandGroup
|
||||||
heading={dict.modelConfig.default}
|
heading={dict.modelConfig.default}
|
||||||
>
|
|
||||||
<ModelSelectorItem
|
|
||||||
value="__server_default__"
|
|
||||||
onSelect={handleSelect}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer",
|
|
||||||
!selectedModelId && "bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Check
|
<CommandItem
|
||||||
|
value="__server_default__"
|
||||||
|
onSelect={handleSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"cursor-pointer",
|
||||||
!selectedModelId
|
!selectedModelId &&
|
||||||
? "opacity-100"
|
"bg-accent",
|
||||||
: "opacity-0",
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
!selectedModelId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.serverDefault
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Models Section */}
|
||||||
|
{serverModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
icon={<Monitor />}
|
||||||
|
label={
|
||||||
|
dict.modelConfig
|
||||||
|
.serverModels
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
{renderGroups(
|
||||||
<ModelSelectorName>
|
groupedServerModels,
|
||||||
{dict.modelConfig.serverDefault}
|
"server",
|
||||||
</ModelSelectorName>
|
(model) =>
|
||||||
</ModelSelectorItem>
|
model.isDefault && (
|
||||||
</ModelSelectorGroup>
|
<span
|
||||||
)}
|
title={
|
||||||
|
dict.modelConfig
|
||||||
|
.serverDefaultModel
|
||||||
|
}
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.default
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Server Models Section */}
|
{/* User Models Section */}
|
||||||
{serverModels.length > 0 && (
|
{userModels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ModelSelectorSectionHeader
|
{serverModels.length > 0 && (
|
||||||
icon={<Monitor />}
|
<CommandSeparator />
|
||||||
label={
|
)}
|
||||||
dict.modelConfig.serverModels
|
<SectionHeader
|
||||||
}
|
icon={<User />}
|
||||||
/>
|
label={
|
||||||
{Array.from(
|
dict.modelConfig.userModels
|
||||||
groupedServerModels.entries(),
|
}
|
||||||
).map(
|
/>
|
||||||
([
|
{renderGroups(
|
||||||
providerLabel,
|
groupedUserModels,
|
||||||
{
|
"user",
|
||||||
provider,
|
(model) =>
|
||||||
models: providerModels,
|
model.validated !==
|
||||||
},
|
true && (
|
||||||
]) => (
|
<span
|
||||||
<ModelSelectorGroup
|
className={cn(
|
||||||
key={`server-${providerLabel}`}
|
"ml-auto shrink-0 pl-2 text-xs",
|
||||||
heading={providerLabel}
|
model.validated ===
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
false
|
||||||
>
|
? "text-destructive"
|
||||||
{providerModels.map(
|
: "text-muted-foreground",
|
||||||
(model) => (
|
)}
|
||||||
<ModelSelectorItem
|
>
|
||||||
key={model.id}
|
{model.validated ===
|
||||||
// Unique value so same-named models highlight
|
false
|
||||||
// separately; keywords keep search by name
|
? dict
|
||||||
value={model.id}
|
.modelConfig
|
||||||
keywords={[
|
.modelFailed
|
||||||
model.modelId,
|
: dict
|
||||||
providerLabel,
|
.modelConfig
|
||||||
]}
|
.modelUntested}
|
||||||
onSelect={() =>
|
</span>
|
||||||
handleSelect(
|
),
|
||||||
model.id,
|
)}
|
||||||
)
|
</>
|
||||||
}
|
)}
|
||||||
className="cursor-pointer"
|
</ModelList>
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.isDefault && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.serverDefaultModel
|
|
||||||
}
|
|
||||||
className="ml-auto text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.default
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Models Section */}
|
|
||||||
{userModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
{serverModels.length > 0 && (
|
|
||||||
<ModelSelectorSeparator />
|
|
||||||
)}
|
|
||||||
<ModelSelectorSectionHeader
|
|
||||||
icon={<User />}
|
|
||||||
label={dict.modelConfig.userModels}
|
|
||||||
/>
|
|
||||||
{Array.from(
|
|
||||||
groupedUserModels.entries(),
|
|
||||||
).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
models: providerModels,
|
|
||||||
},
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={`user-${providerLabel}`}
|
|
||||||
heading={providerLabel}
|
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
|
||||||
>
|
|
||||||
{providerModels.map(
|
|
||||||
(model) => (
|
|
||||||
<ModelSelectorItem
|
|
||||||
key={model.id}
|
|
||||||
value={model.id}
|
|
||||||
keywords={[
|
|
||||||
model.modelId,
|
|
||||||
providerLabel,
|
|
||||||
]}
|
|
||||||
onSelect={() =>
|
|
||||||
handleSelect(
|
|
||||||
model.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.validated !==
|
|
||||||
true && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto shrink-0 pl-2 text-xs",
|
|
||||||
model.validated ===
|
|
||||||
false
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{model.validated ===
|
|
||||||
false
|
|
||||||
? dict
|
|
||||||
.modelConfig
|
|
||||||
.modelFailed
|
|
||||||
: dict
|
|
||||||
.modelConfig
|
|
||||||
.modelUntested}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ModelSelectorList>
|
|
||||||
</div>
|
|
||||||
{/* Pinned footer: add a provider, configure models
|
|
||||||
(z-10 above list shadow). Buttons, outside the
|
|
||||||
search: reachable with Tab, never filtered out */}
|
|
||||||
{(onAddProvider || onConfigure) && (
|
|
||||||
<div
|
|
||||||
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
|
||||||
// The search's Enter would pick the highlighted
|
|
||||||
// model instead of pressing the button
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{onAddProvider && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
goToSettings(onAddProvider)
|
|
||||||
}
|
|
||||||
className={footerButton}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">
|
|
||||||
{dict.modelConfig.addProviderEntry}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onConfigure && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
goToSettings(onConfigure)
|
|
||||||
}
|
|
||||||
className={footerButton}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">
|
|
||||||
{dict.modelConfig.configureModels}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Pinned footer: add a provider, configure models
|
||||||
</div>
|
(z-10 above list shadow). Buttons, outside the
|
||||||
</ModelSelectorContent>
|
search: reachable with Tab, never filtered out */}
|
||||||
</ModelSelectorRoot>
|
{(onAddProvider || onConfigure) && (
|
||||||
|
<div
|
||||||
|
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
||||||
|
// The search's Enter would pick the highlighted
|
||||||
|
// model instead of pressing the button
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{onAddProvider && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
goToSettings(onAddProvider)
|
||||||
|
}
|
||||||
|
className={footerButton}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.addProviderEntry
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onConfigure && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
goToSettings(onConfigure)
|
||||||
|
}
|
||||||
|
className={footerButton}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.configureModels
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ interface ProviderCredentialsFieldsProps {
|
|||||||
name?: string
|
name?: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
// Bedrock: also offer an API key (the user dialog; the admin panel
|
|
||||||
// stores access keys only)
|
|
||||||
bedrockApiKey?: boolean
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
// Update a plain text field
|
// Update a plain text field
|
||||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||||
@@ -68,9 +65,6 @@ interface ProviderCredentialsFieldsProps {
|
|||||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||||
// shared component only supplies the label above it.
|
// shared component only supplies the label above it.
|
||||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||||
// Extra content after the fields — used for the Bedrock test row and the
|
|
||||||
// EdgeOne test button, which aren't beside a credential input.
|
|
||||||
footer?: ReactNode
|
|
||||||
// The user's settings page: the secrets come first, the display name and
|
// The user's settings page: the secrets come first, the display name and
|
||||||
// base URL fold under "More options", and Bedrock shows only the way of
|
// base URL fold under "More options", and Bedrock shows only the way of
|
||||||
// signing in that is chosen. Switching it empties the other way's
|
// signing in that is chosen. Switching it empties the other way's
|
||||||
@@ -91,11 +85,9 @@ export function ProviderCredentialsFields({
|
|||||||
name,
|
name,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
bedrockApiKey,
|
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
renderSecret,
|
renderSecret,
|
||||||
footer,
|
|
||||||
settingsLayout,
|
settingsLayout,
|
||||||
clearSecrets,
|
clearSecrets,
|
||||||
bedrockFilled,
|
bedrockFilled,
|
||||||
@@ -269,10 +261,8 @@ export function ProviderCredentialsFields({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
// EdgeOne needs no credentials
|
||||||
if (provider === "edgeone") {
|
if (provider === "edgeone") return null
|
||||||
return <div className="space-y-5">{footer}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsLayout) {
|
if (settingsLayout) {
|
||||||
return (
|
return (
|
||||||
@@ -447,7 +437,6 @@ export function ProviderCredentialsFields({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -458,26 +447,6 @@ export function ProviderCredentialsFields({
|
|||||||
|
|
||||||
{provider === "bedrock" ? (
|
{provider === "bedrock" ? (
|
||||||
<>
|
<>
|
||||||
{/* Bedrock API key: used instead of the access keys */}
|
|
||||||
{bedrockApiKey && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="bedrock-api-key"
|
|
||||||
className="text-xs font-medium flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{dict.modelConfig.bedrockApiKey}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "apiKey",
|
|
||||||
id: "bedrock-api-key",
|
|
||||||
})}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.modelConfig.bedrockApiKeyHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AWS Access Key ID */}
|
{/* AWS Access Key ID */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
@@ -535,8 +504,6 @@ export function ProviderCredentialsFields({
|
|||||||
{baseUrlField}
|
{baseUrlField}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{footer}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Coffee, Settings, X } from "lucide-react"
|
import { Coffee, Settings, X } from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { GitHubIcon } from "@/components/icons/github"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
|
||||||
@@ -28,16 +28,10 @@ export function QuotaLimitToast({
|
|||||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||||
|
|
||||||
const quotaMessage = isTokenLimit
|
const quotaMessage = isTokenLimit
|
||||||
? isSelfHosted
|
? dict.quota.messageToken
|
||||||
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
: dict.quota.messageApi
|
||||||
: dict.quota.messageToken
|
|
||||||
: isSelfHosted
|
|
||||||
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
|
||||||
: dict.quota.messageApi
|
|
||||||
|
|
||||||
const tipHtml = isSelfHosted
|
const tipHtml = isSelfHosted ? dict.quota.tipSelfHosted : dict.quota.tip
|
||||||
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
|
||||||
: dict.quota.tip
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -52,7 +46,7 @@ export function QuotaLimitToast({
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in"
|
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-float animate-message-in"
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
@@ -115,7 +109,7 @@ export function QuotaLimitToast({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<FaGithub className="w-3.5 h-3.5" />
|
<GitHubIcon className="w-3.5 h-3.5" />
|
||||||
{dict.quota.selfHost}
|
{dict.quota.selfHost}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Github,
|
|
||||||
Info,
|
Info,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -13,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||||
|
import { GitHubIcon } from "@/components/icons/github"
|
||||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -510,7 +510,7 @@ function AboutTab() {
|
|||||||
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
|
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
|
||||||
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
|
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
|
||||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||||
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
const aboutPath = `/${lang}/about`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -553,7 +553,7 @@ function AboutTab() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
|
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Github className="size-3.5" />
|
<GitHubIcon className="size-3.5" />
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
{showAbout && (
|
{showAbout && (
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ function AlertDialog({
|
|||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogPortal({
|
function AlertDialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
@@ -144,9 +136,6 @@ function AlertDialogCancel({
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogPortal,
|
|
||||||
AlertDialogOverlay,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
|
|||||||
@@ -5,13 +5,6 @@ import { Command as CommandPrimitive } from "cmdk"
|
|||||||
import { SearchIcon } from "lucide-react"
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
function Command({
|
function Command({
|
||||||
className,
|
className,
|
||||||
@@ -29,32 +22,6 @@ function Command({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandDialog({
|
|
||||||
title = "Command Palette",
|
|
||||||
description = "Search for a command to run...",
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogHeader className="sr-only">
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandInput({
|
function CommandInput({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -164,30 +131,12 @@ function CommandItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="command-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ function DialogPortal({
|
|||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -65,7 +59,7 @@ function DialogContent({
|
|||||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||||
"grid gap-4 p-6",
|
"grid gap-4 p-6",
|
||||||
// Refined visual treatment
|
// Refined visual treatment
|
||||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
"bg-surface-0 rounded-2xl border border-border-subtle shadow-pop",
|
||||||
// Entry/exit animations
|
// Entry/exit animations
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
@@ -151,13 +145,10 @@ function DialogDescription({
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,4 @@ function PopoverContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverAnchor({
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
|
||||||
|
|||||||
@@ -55,4 +55,4 @@ function ScrollBar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
export { ScrollArea }
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ function Select({
|
|||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
@@ -87,19 +81,6 @@ function SelectContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -124,19 +105,6 @@ function SelectItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -176,12 +144,7 @@ function SelectScrollDownButton({
|
|||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ interface IconButtonProps extends React.ComponentProps<"button"> {
|
|||||||
label: string
|
label: string
|
||||||
/** Shown next to the label in the tooltip, e.g. "⌘B" */
|
/** Shown next to the label in the tooltip, e.g. "⌘B" */
|
||||||
shortcut?: string
|
shortcut?: string
|
||||||
active?: boolean
|
|
||||||
tooltipSide?: "top" | "bottom" | "left" | "right"
|
tooltipSide?: "top" | "bottom" | "left" | "right"
|
||||||
size?: "sm" | "md"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Square icon button with a tooltip; the tooltip text is also its label */
|
/** Square icon button with a tooltip; the tooltip text is also its label */
|
||||||
@@ -24,9 +22,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
{
|
{
|
||||||
label,
|
label,
|
||||||
shortcut,
|
shortcut,
|
||||||
active = false,
|
|
||||||
tooltipSide = "bottom",
|
tooltipSide = "bottom",
|
||||||
size = "md",
|
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
@@ -40,17 +36,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={active || undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
||||||
"hover:bg-accent hover:text-foreground",
|
"hover:bg-accent hover:text-foreground",
|
||||||
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
|
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
|
||||||
"disabled:pointer-events-none disabled:opacity-35",
|
"disabled:pointer-events-none disabled:opacity-35",
|
||||||
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
|
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
|
||||||
size === "md" ? "size-8" : "size-7",
|
|
||||||
// A tinted tile: solid ink is for the main action
|
|
||||||
active &&
|
|
||||||
"bg-interactive-active text-foreground hover:bg-interactive-active [&_svg]:stroke-2",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,8 +64,3 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Thin vertical divider between toolbar groups */
|
|
||||||
export function ToolbarDivider() {
|
|
||||||
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ function WorkspaceLayout() {
|
|||||||
const showHero =
|
const showHero =
|
||||||
engine.isRestored &&
|
engine.isRestored &&
|
||||||
engine.messages.length === 0 &&
|
engine.messages.length === 0 &&
|
||||||
!engine.isResending &&
|
|
||||||
!isRealDiagram(chartXML) &&
|
!isRealDiagram(chartXML) &&
|
||||||
!heroDismissed
|
!heroDismissed
|
||||||
|
|
||||||
|
|||||||
+117
-146
@@ -78,20 +78,16 @@ interface DiagramContextType {
|
|||||||
chartXML: string
|
chartXML: string
|
||||||
// chartXML right away, before the re-render (loadDiagram sets both)
|
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||||
chartXMLRef: React.MutableRefObject<string>
|
chartXMLRef: React.MutableRefObject<string>
|
||||||
latestSvg: string
|
// The diagram's latest SVG export, a fallback thumbnail for saves
|
||||||
|
latestSvgRef: React.MutableRefObject<string>
|
||||||
loadDiagram: (
|
loadDiagram: (
|
||||||
chart: string,
|
chart: string,
|
||||||
skipValidation?: boolean,
|
skipValidation?: boolean,
|
||||||
mode?: LoadMode,
|
mode?: LoadMode,
|
||||||
meta?: { toolCallId?: string; pageId?: string | null },
|
meta?: { toolCallId?: string; pageId?: string | null },
|
||||||
) => string | null
|
) => string | null
|
||||||
// Returns the export's tag (empty when draw.io is not there yet)
|
/** The XML of the page on screen, exported from draw.io */
|
||||||
handleExport: () => string
|
fetchDiagramXml: () => Promise<string>
|
||||||
// Pending exports by tag; a plain export's resolver gets the XML of the
|
|
||||||
// page on screen
|
|
||||||
exportResolversRef: React.MutableRefObject<
|
|
||||||
Record<string, (data: string, xml?: string) => void>
|
|
||||||
>
|
|
||||||
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
|
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
|
||||||
handleDiagramExport: (data: DrawioExportEvent) => void
|
handleDiagramExport: (data: DrawioExportEvent) => void
|
||||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
@@ -101,7 +97,7 @@ interface DiagramContextType {
|
|||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
successMessage?: string,
|
successMessage?: string,
|
||||||
) => void
|
) => Promise<void>
|
||||||
getThumbnailSvg: () => Promise<string | null>
|
getThumbnailSvg: () => Promise<string | null>
|
||||||
getVersionSvg: () => Promise<string | null>
|
getVersionSvg: () => Promise<string | null>
|
||||||
captureValidationPng: () => Promise<string | null>
|
captureValidationPng: () => Promise<string | null>
|
||||||
@@ -135,11 +131,11 @@ const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
|||||||
// echoes the request back in the export event, so each result reaches its
|
// echoes the request back in the export event, so each result reaches its
|
||||||
// own caller. Tags end in a request number, so a late result never answers
|
// own caller. Tags end in a request number, so a late result never answers
|
||||||
// a newer request.
|
// a newer request.
|
||||||
type ExportTag = "thumbnail" | "validation" | "version"
|
type ExportTag = "thumbnail" | "validation" | "version" | "fetch" | "save"
|
||||||
|
|
||||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [chartXML, setChartXML] = useState<string>("")
|
const [chartXML, setChartXML] = useState<string>("")
|
||||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
const latestSvgRef = useRef("")
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
@@ -152,7 +148,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const drawioRef = useRef<DrawioFrameHandle | null>(null)
|
const drawioRef = useRef<DrawioFrameHandle | null>(null)
|
||||||
// Pending exports, keyed by their export tag
|
// Pending exports, keyed by their export tag
|
||||||
const exportResolversRef = useRef<
|
const exportResolversRef = useRef<
|
||||||
Record<string, (data: string, xml?: string) => void>
|
Record<string, (result: ExportResult) => void>
|
||||||
>({})
|
>({})
|
||||||
const exportSeqRef = useRef(0)
|
const exportSeqRef = useRef(0)
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
@@ -217,26 +213,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setChartXML(xml)
|
setChartXML(xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
if (!drawioRef.current) return ""
|
|
||||||
// Export of the current state (for the chat and edit_diagram)
|
|
||||||
const tag = `fetch-${++exportSeqRef.current}`
|
|
||||||
drawioRef.current.exportDiagram({
|
|
||||||
format: "xmlsvg",
|
|
||||||
message: tag,
|
|
||||||
})
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||||
// event) and wait for that result. Resolves to null on timeout, which is
|
// event) and wait for that result. Resolves to null on timeout, which is
|
||||||
// expected occasionally.
|
// expected occasionally.
|
||||||
// (Reads refs only, so it keeps one identity)
|
// (Reads refs only, so it keeps one identity)
|
||||||
const requestTaggedExport = useCallback(
|
const requestTaggedExport = useCallback(
|
||||||
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
|
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
|
||||||
new Promise<string | null>((resolve) => {
|
new Promise<ExportResult | null>((resolve) => {
|
||||||
const id = `${tag}-${++exportSeqRef.current}`
|
const id = `${tag}-${++exportSeqRef.current}`
|
||||||
const finish = (value: string | null) => {
|
const finish = (value: ExportResult | null) => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
delete exportResolversRef.current[id]
|
delete exportResolversRef.current[id]
|
||||||
resolve(value)
|
resolve(value)
|
||||||
@@ -249,20 +234,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Get current diagram as SVG for thumbnail (used by session storage).
|
// Get current diagram as SVG for thumbnail (used by session storage).
|
||||||
// One identity: the chat's auto-save depends on it, and each thumbnail
|
// One identity: the chat's auto-save effect depends on it
|
||||||
// renders this provider again (latestSvg), which would otherwise start
|
|
||||||
// the next save
|
|
||||||
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return null
|
||||||
// Don't export if diagram is empty
|
// Don't export if diagram is empty
|
||||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||||
|
|
||||||
// xmlsvg exports return an SVG data URL
|
// xmlsvg exports return an SVG data URL
|
||||||
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
const result = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||||
if (svgData?.startsWith("data:image/svg")) {
|
if (result?.data?.startsWith("data:image/svg")) {
|
||||||
// Update latestSvg so it's available for future saves
|
// Kept for later saves that take no fresh thumbnail
|
||||||
setLatestSvg(svgData)
|
latestSvgRef.current = result.data
|
||||||
return svgData
|
return result.data
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}, [requestTaggedExport])
|
}, [requestTaggedExport])
|
||||||
@@ -270,8 +253,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Plain SVG (no embedded diagram data) for version cards
|
// Plain SVG (no embedded diagram data) for version cards
|
||||||
const getVersionSvg = async (): Promise<string | null> => {
|
const getVersionSvg = async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return null
|
||||||
const svgData = await requestTaggedExport("version", "svg", 4000)
|
const result = await requestTaggedExport("version", "svg", 4000)
|
||||||
return svgData?.startsWith("data:image/svg") ? svgData : null
|
return result?.data?.startsWith("data:image/svg") ? result.data : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current diagram as PNG for VLM validation
|
// Capture current diagram as PNG for VLM validation
|
||||||
@@ -280,9 +263,27 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Don't export if diagram is empty
|
// Don't export if diagram is empty
|
||||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||||
|
|
||||||
const pngData = await requestTaggedExport("validation", "png", 5000)
|
const result = await requestTaggedExport("validation", "png", 5000)
|
||||||
// PNG data should be a base64 data URL
|
// PNG data should be a base64 data URL
|
||||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
return result?.data?.startsWith("data:image/png") ? result.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page the user is viewing (the first one with an external draw.io,
|
||||||
|
// which cannot tell us), for the chat and edit_diagram.
|
||||||
|
// Not written to chartXML: exports don't change the diagram, and the
|
||||||
|
// export's xml has compressed <diagram> payloads that would break
|
||||||
|
// edit_diagram/display_diagram. Autosave keeps chartXML up to date with
|
||||||
|
// the full uncompressed multi-page document (#879).
|
||||||
|
const fetchDiagramXml = async (): Promise<string> => {
|
||||||
|
const result = await requestTaggedExport("fetch", "xmlsvg", 10000)
|
||||||
|
if (!result?.data) {
|
||||||
|
throw new Error("Chart export timed out after 10 seconds")
|
||||||
|
}
|
||||||
|
latestSvgRef.current = result.data
|
||||||
|
return extractDiagramXML(
|
||||||
|
result.data,
|
||||||
|
useCanvasStore.getState().currentPageId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Like requestTaggedExport, with the request's other parameters (a PNG's
|
// Like requestTaggedExport, with the request's other parameters (a PNG's
|
||||||
@@ -301,8 +302,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolve(value)
|
resolve(value)
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||||
exportResolversRef.current[id] = (data, xml) =>
|
exportResolversRef.current[id] = finish
|
||||||
finish({ data, xml })
|
|
||||||
drawioRef.current.exportDiagram({ ...request, message: id })
|
drawioRef.current.exportDiagram({ ...request, message: id })
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@@ -406,34 +406,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resetPreview()
|
resetPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each export result goes to the caller that asked for it, by its tag
|
||||||
const handleDiagramExport = (data: DrawioExportEvent) => {
|
const handleDiagramExport = (data: DrawioExportEvent) => {
|
||||||
// Thumbnail, version, validation PNG, file save and requestExport
|
|
||||||
// exports go only to their own caller
|
|
||||||
const tag = data.message?.message
|
const tag = data.message?.message
|
||||||
if (/^(thumbnail|validation|version|save|export)-/.test(tag ?? "")) {
|
if (!tag) return
|
||||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
exportResolversRef.current[tag]?.({ data: data.data, xml: data.xml })
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't write chartXML here: exports don't change the diagram, and
|
|
||||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
|
||||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
|
||||||
// up to date with the full uncompressed multi-page document (#879).
|
|
||||||
// The chat gets the page the user is viewing (the first one with
|
|
||||||
// an external draw.io, which cannot tell us)
|
|
||||||
const extractedXML = extractDiagramXML(
|
|
||||||
data.data,
|
|
||||||
useCanvasStore.getState().currentPageId,
|
|
||||||
)
|
|
||||||
setLatestSvg(data.data)
|
|
||||||
|
|
||||||
// The chat's own export (onFetchChart), not another one in flight
|
|
||||||
const resolve =
|
|
||||||
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
|
||||||
if (resolve) {
|
|
||||||
delete exportResolversRef.current[tag as string]
|
|
||||||
resolve(extractedXML)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The frame registers this callback once per iframe mount, so it must
|
// The frame registers this callback once per iframe mount, so it must
|
||||||
@@ -459,10 +436,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const emptyDiagram = BLANK_MXFILE
|
const emptyDiagram = BLANK_MXFILE
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
loadDiagram(emptyDiagram, true)
|
loadDiagram(emptyDiagram, true)
|
||||||
setLatestSvg("")
|
latestSvgRef.current = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDiagramToFile = (
|
const saveDiagramToFile = async (
|
||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
@@ -477,85 +454,80 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const drawioFormat =
|
const drawioFormat =
|
||||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||||
|
|
||||||
// Each save has its own tag, so two at once never swap results
|
// Each save has its own tag, so two at once never swap results. A
|
||||||
const tag = `save-${++exportSeqRef.current}`
|
// large PNG takes a while to render.
|
||||||
exportResolversRef.current[tag] = (
|
const result = await requestTaggedExport("save", drawioFormat, 30000)
|
||||||
exportData: string,
|
if (!result?.data) {
|
||||||
fullDiagramXML?: string,
|
console.warn("Diagram export for saving timed out")
|
||||||
) => {
|
return
|
||||||
delete exportResolversRef.current[tag]
|
}
|
||||||
let fileContent: string | Blob
|
const exportData = result.data
|
||||||
let mimeType: string
|
const fullDiagramXML = result.xml
|
||||||
let extension: string
|
let fileContent: string | Blob
|
||||||
|
let mimeType: string
|
||||||
|
let extension: string
|
||||||
|
|
||||||
if (format === "drawio") {
|
if (format === "drawio") {
|
||||||
// Prefer the complete document from the export event so all pages are saved.
|
// Prefer the complete document from the export event so all pages are saved.
|
||||||
const xml = fullDiagramXML?.trim()
|
const xml = fullDiagramXML?.trim()
|
||||||
? fullDiagramXML
|
? fullDiagramXML
|
||||||
: extractDiagramXML(exportData)
|
: extractDiagramXML(exportData)
|
||||||
fileContent =
|
fileContent =
|
||||||
normalizeToMxfile(xml, {
|
normalizeToMxfile(xml, {
|
||||||
pageId: "page-1",
|
pageId: "page-1",
|
||||||
pageName: "Page-1",
|
pageName: "Page-1",
|
||||||
}) ?? xml
|
}) ?? xml
|
||||||
mimeType = "application/xml"
|
mimeType = "application/xml"
|
||||||
extension = ".drawio"
|
extension = ".drawio"
|
||||||
} else if (format === "png") {
|
} else if (format === "png") {
|
||||||
// PNG data comes as base64 data URL
|
// PNG data comes as base64 data URL
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
extension = ".png"
|
extension = ".png"
|
||||||
} else if (format === "xmlsvg") {
|
} else if (format === "xmlsvg") {
|
||||||
// Editable SVG: pass data URL directly (like PNG)
|
// Editable SVG: pass data URL directly (like PNG)
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".drawio.svg"
|
extension = ".drawio.svg"
|
||||||
} else {
|
} else {
|
||||||
// SVG format (view-only)
|
// SVG format (view-only)
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".svg"
|
extension = ".svg"
|
||||||
}
|
|
||||||
|
|
||||||
// Log save event to Langfuse (flags the trace)
|
|
||||||
logSaveToLangfuse(filename, format, sessionId)
|
|
||||||
|
|
||||||
// Handle download
|
|
||||||
let url: string
|
|
||||||
if (
|
|
||||||
typeof fileContent === "string" &&
|
|
||||||
fileContent.startsWith("data:")
|
|
||||||
) {
|
|
||||||
// Already a data URL (PNG)
|
|
||||||
url = fileContent
|
|
||||||
} else {
|
|
||||||
const blob = new Blob([fileContent], { type: mimeType })
|
|
||||||
url = URL.createObjectURL(blob)
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = url
|
|
||||||
a.download = `${filename}${extension}`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
|
|
||||||
// Show success toast after download is initiated
|
|
||||||
if (successMessage) {
|
|
||||||
toast.success(successMessage, { duration: 2500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
|
||||||
if (!url.startsWith("data:")) {
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export diagram - callback will be handled in handleDiagramExport
|
// Log save event to Langfuse (flags the trace)
|
||||||
drawioRef.current.exportDiagram({
|
logSaveToLangfuse(filename, format, sessionId)
|
||||||
format: drawioFormat,
|
|
||||||
message: tag,
|
// Handle download
|
||||||
})
|
let url: string
|
||||||
|
if (
|
||||||
|
typeof fileContent === "string" &&
|
||||||
|
fileContent.startsWith("data:")
|
||||||
|
) {
|
||||||
|
// Already a data URL (PNG)
|
||||||
|
url = fileContent
|
||||||
|
} else {
|
||||||
|
const blob = new Blob([fileContent], { type: mimeType })
|
||||||
|
url = URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `${filename}${extension}`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
|
||||||
|
// Show success toast after download is initiated
|
||||||
|
if (successMessage) {
|
||||||
|
toast.success(successMessage, { duration: 2500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay URL revocation to ensure download completes
|
||||||
|
if (!url.startsWith("data:")) {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||||
@@ -586,10 +558,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
chartXML,
|
chartXML,
|
||||||
chartXMLRef,
|
chartXMLRef,
|
||||||
latestSvg,
|
latestSvgRef,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
fetchDiagramXml,
|
||||||
exportResolversRef,
|
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
handleDiagramAutoSave,
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|||||||
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
||||||
| `models` | 是 | 模型 ID 列表 |
|
| `models` | 是 | 模型 ID 列表 |
|
||||||
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
||||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
|
||||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
||||||
|
|
||||||
### 说明
|
### 说明
|
||||||
|
|||||||
@@ -68,47 +68,15 @@ cp env.example .env.local
|
|||||||
|
|
||||||
## 第三步 — 选择部署类型
|
## 第三步 — 选择部署类型
|
||||||
|
|
||||||
### 选项 A:不使用 R2 部署(简单,免费)
|
### 选项 A:不使用 R2 部署(默认,免费)
|
||||||
|
|
||||||
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
|
仓库自带的就是这套配置:`open-next.config.ts` 为 `defineCloudflareConfig({})`,`wrangler.jsonc` 中没有 `r2_buckets`。无需改动任何文件,直接跳至 **第四步**。
|
||||||
|
|
||||||
**1. 使用简单的 `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. 使用简单的 `wrangler.jsonc`(不包含 r2_buckets):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
直接跳至 **第四步**。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 选项 B:使用 R2 部署(完整的 ISR 支持)
|
### 选项 B:使用 R2 部署(可选,用于 ISR 缓存)
|
||||||
|
|
||||||
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
|
R2 为 OpenNext 提供存放 **增量静态再生 (ISR)** 页面的空间。本应用目前没有 ISR 页面,只有在您自己添加了此类页面时才需要这一步。需要在您的 Cloudflare 账户中绑定支付方式。
|
||||||
|
|
||||||
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
||||||
|
|
||||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. 配置 `wrangler.jsonc`(包含 R2):**
|
**3. 在 `wrangler.jsonc` 中加入 `r2_buckets`:**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
||||||
|
|
||||||
|
> **注意:** 定时重新验证(页面或 fetch 上的 `revalidate`)还需要在 `open-next.config.ts` 中配置 `queue`,请参阅 OpenNext 的 Cloudflare 缓存文档。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
||||||
@@ -264,4 +234,4 @@ npm run preview
|
|||||||
| API 路由 | 支持 | 支持 |
|
| API 路由 | 支持 | 支持 |
|
||||||
| 配置复杂度 | 简单 | 中等 |
|
| 配置复杂度 | 简单 | 中等 |
|
||||||
|
|
||||||
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。
|
仓库默认 **不使用 R2**。只有当您添加了需要 ISR 缓存的页面时,才选择 **使用 R2**。
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ This is shorthand for the equivalent `ai-models.json`. For multiple providers or
|
|||||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
||||||
| `models` | Yes | List of model IDs |
|
| `models` | Yes | List of model IDs |
|
||||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
||||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`). May also be an array of env var names: each request picks one that has a value at random, spreading load over several keys |
|
||||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
| `baseUrlEnv` | No | Custom base URL env var name |
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|||||||
@@ -68,47 +68,15 @@ Fill in the same values there.
|
|||||||
|
|
||||||
## Step 3 — Choose your deployment type
|
## Step 3 — Choose your deployment type
|
||||||
|
|
||||||
### Option A: Deploy WITHOUT R2 (Simple, Free)
|
### Option A: Deploy WITHOUT R2 (Default, Free)
|
||||||
|
|
||||||
If you don't need ISR caching, you can deploy without R2:
|
This is how the repository is configured: `open-next.config.ts` is `defineCloudflareConfig({})` and `wrangler.jsonc` has no `r2_buckets`. Nothing to change here. Skip to **Step 4**.
|
||||||
|
|
||||||
**1. Use simple `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Skip to **Step 4**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Option B: Deploy WITH R2 (Full ISR Support)
|
### Option B: Deploy WITH R2 (Optional, ISR caching)
|
||||||
|
|
||||||
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
|
R2 gives OpenNext a place to store **Incremental Static Regeneration (ISR)** pages. This app currently has no ISR pages, so you only need this step if you add some. Requires a payment method on your Cloudflare account.
|
||||||
|
|
||||||
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
||||||
|
|
||||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Configure `wrangler.jsonc` (with R2):**
|
**3. Add `r2_buckets` to `wrangler.jsonc`:**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
||||||
|
|
||||||
|
> **Note:** Time-based revalidation (`revalidate` on a page or fetch) also needs a `queue` in `open-next.config.ts`. See the OpenNext Cloudflare caching docs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4 — Register a workers.dev subdomain (first-time only)
|
## Step 4 — Register a workers.dev subdomain (first-time only)
|
||||||
@@ -264,4 +234,4 @@ npm run preview
|
|||||||
| API Routes | Yes | Yes |
|
| API Routes | Yes | Yes |
|
||||||
| Setup Complexity | Simple | Moderate |
|
| Setup Complexity | Simple | Moderate |
|
||||||
|
|
||||||
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
|
The repository defaults to **without R2**. Choose **with R2** only if you add pages that need ISR caching.
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|||||||
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
||||||
| `models` | はい | モデルIDのリスト |
|
| `models` | はい | モデルIDのリスト |
|
||||||
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
||||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数)。環境変数名の配列も指定でき、リクエストごとに値のある変数から1つがランダムに選ばれます(複数キーへの負荷分散) |
|
||||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
||||||
|
|
||||||
### 備考
|
### 備考
|
||||||
|
|||||||
@@ -68,47 +68,15 @@ cp env.example .env.local
|
|||||||
|
|
||||||
## ステップ 3 — デプロイタイプの選択
|
## ステップ 3 — デプロイタイプの選択
|
||||||
|
|
||||||
### オプション A: R2 なしでのデプロイ(シンプル、無料)
|
### オプション A: R2 なしでのデプロイ(デフォルト、無料)
|
||||||
|
|
||||||
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
|
リポジトリはこの構成のままです。`open-next.config.ts` は `defineCloudflareConfig({})` で、`wrangler.jsonc` に `r2_buckets` はありません。ファイルを変更する必要はないので、**ステップ 4** へ進んでください。
|
||||||
|
|
||||||
**1. シンプルな `open-next.config.ts` を使用:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. シンプルな `wrangler.jsonc` を使用(r2_buckets なし):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ステップ 4** へ進んでください。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
|
### オプション B: R2 ありでのデプロイ(オプション、ISR キャッシュ用)
|
||||||
|
|
||||||
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
|
R2 は OpenNext が **Incremental Static Regeneration (ISR)** ページを保存する場所になります。このアプリには現在 ISR ページがないため、自分で ISR ページを追加した場合にだけ必要です。Cloudflare アカウントに支払い方法の登録が必要です。
|
||||||
|
|
||||||
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
||||||
|
|
||||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. `wrangler.jsonc` の設定(R2 あり):**
|
**3. `wrangler.jsonc` に `r2_buckets` を追加:**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
||||||
|
|
||||||
|
> **注意:** 時間ベースの再検証(ページや fetch の `revalidate`)には、`open-next.config.ts` で `queue` の設定も必要です。OpenNext の Cloudflare キャッシュドキュメントを参照してください。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
||||||
@@ -264,4 +234,4 @@ npm run preview
|
|||||||
| API ルート | あり | あり |
|
| API ルート | あり | あり |
|
||||||
| 設定の複雑さ | シンプル | 普通 |
|
| 設定の複雑さ | シンプル | 普通 |
|
||||||
|
|
||||||
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。
|
リポジトリのデフォルトは **R2 なし** です。ISR キャッシュが必要なページを追加した場合にだけ **R2 あり** を選んでください。
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const messageItemSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
|
// The fields read here; every other field is passed on to AI.chatCompletions
|
||||||
const messageSchema = z
|
const messageSchema = z
|
||||||
.object({
|
.object({
|
||||||
messages: z.array(messageItemSchema),
|
messages: z.array(messageItemSchema),
|
||||||
@@ -36,21 +37,6 @@ const messageSchema = z
|
|||||||
stream: z.boolean().optional(),
|
stream: z.boolean().optional(),
|
||||||
tools: z.any().optional(),
|
tools: z.any().optional(),
|
||||||
tool_choice: z.any().optional(),
|
tool_choice: z.any().optional(),
|
||||||
functions: z.any().optional(),
|
|
||||||
function_call: z.any().optional(),
|
|
||||||
temperature: z.number().optional(),
|
|
||||||
top_p: z.number().optional(),
|
|
||||||
max_tokens: z.number().optional(),
|
|
||||||
presence_penalty: z.number().optional(),
|
|
||||||
frequency_penalty: z.number().optional(),
|
|
||||||
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
|
||||||
response_format: z.any().optional(),
|
|
||||||
seed: z.number().optional(),
|
|
||||||
user: z.string().optional(),
|
|
||||||
n: z.number().int().optional(),
|
|
||||||
logit_bias: z.record(z.string(), z.number()).optional(),
|
|
||||||
parallel_tool_calls: z.boolean().optional(),
|
|
||||||
stream_options: z.any().optional(),
|
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
@@ -61,12 +47,6 @@ const ALLOWED_MODELS = [
|
|||||||
"@tx/deepseek-ai/deepseek-v3-0324",
|
"@tx/deepseek-ai/deepseek-v3-0324",
|
||||||
]
|
]
|
||||||
|
|
||||||
const MODEL_ALIASES: Record<string, string> = {
|
|
||||||
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
|
|
||||||
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
|
|
||||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create standardized JSON response
|
* Create standardized JSON response
|
||||||
*/
|
*/
|
||||||
@@ -145,8 +125,16 @@ export async function onRequest({ request, env }: any) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
// stream is taken out so it is not passed on: AI.chatCompletions
|
||||||
parseResult.data
|
// only streams, and every caller reads a stream
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
stream: _,
|
||||||
|
tools,
|
||||||
|
tool_choice,
|
||||||
|
...extraParams
|
||||||
|
} = parseResult.data
|
||||||
|
|
||||||
// Validate messages
|
// Validate messages
|
||||||
const userMessages = messages.filter(
|
const userMessages = messages.filter(
|
||||||
@@ -165,14 +153,13 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve model
|
// Resolve model
|
||||||
const requestedModel = model || ALLOWED_MODELS[0]
|
const selectedModel = model || ALLOWED_MODELS[0]
|
||||||
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
|
||||||
|
|
||||||
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
||||||
return createResponse(
|
return createResponse(
|
||||||
{
|
{
|
||||||
error: {
|
error: {
|
||||||
message: `Invalid model: ${requestedModel}.`,
|
message: `Invalid model: ${selectedModel}.`,
|
||||||
type: "invalid_request_error",
|
type: "invalid_request_error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -181,39 +168,10 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isStream = !!stream
|
|
||||||
|
|
||||||
// Non-streaming: return mock response for validation
|
|
||||||
// AI.chatCompletions doesn't support non-streaming mode
|
|
||||||
if (!isStream) {
|
|
||||||
const mockResponse = {
|
|
||||||
id: `chatcmpl-${Date.now()}`,
|
|
||||||
object: "chat.completion",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: selectedModel,
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
message: {
|
|
||||||
role: "assistant",
|
|
||||||
content: "OK",
|
|
||||||
},
|
|
||||||
finish_reason: "stop",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
usage: {
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 1,
|
|
||||||
total_tokens: 11,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return createResponse(mockResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build AI.chatCompletions options for streaming
|
// Build AI.chatCompletions options for streaming
|
||||||
const aiOptions: any = {
|
const aiOptions: any = {
|
||||||
...extraParams,
|
...extraParams,
|
||||||
|
|||||||
Vendored
+3
-69
@@ -2,29 +2,6 @@
|
|||||||
* Type declarations for Electron API exposed via preload script
|
* Type declarations for Electron API exposed via preload script
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Configuration preset interface */
|
|
||||||
interface ConfigPreset {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER?: string
|
|
||||||
AI_MODEL?: string
|
|
||||||
AI_API_KEY?: string
|
|
||||||
AI_BASE_URL?: string
|
|
||||||
TEMPERATURE?: string
|
|
||||||
[key: string]: string | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of applying a preset */
|
|
||||||
interface ApplyPresetResult {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
env?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Proxy configuration interface */
|
/** Proxy configuration interface */
|
||||||
interface ProxyConfig {
|
interface ProxyConfig {
|
||||||
httpProxy?: string
|
httpProxy?: string
|
||||||
@@ -35,7 +12,6 @@ interface ProxyConfig {
|
|||||||
interface SetProxyResult {
|
interface SetProxyResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
devMode?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of setting user locale */
|
/** Result of setting user locale */
|
||||||
@@ -48,31 +24,17 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
/** Main window Electron API */
|
/** Main window Electron API */
|
||||||
electronAPI?: {
|
electronAPI?: {
|
||||||
/** Current platform (darwin, win32, linux) */
|
|
||||||
platform: NodeJS.Platform
|
|
||||||
/** Whether running in Electron environment */
|
/** Whether running in Electron environment */
|
||||||
isElectron: boolean
|
isElectron: boolean
|
||||||
/** Get application version */
|
|
||||||
getVersion: () => Promise<string>
|
|
||||||
/** Minimize the window */
|
|
||||||
minimize: () => void
|
|
||||||
/** Maximize/restore the window */
|
|
||||||
maximize: () => void
|
|
||||||
/** Close the window */
|
|
||||||
close: () => void
|
|
||||||
/** Get proxy configuration */
|
/** Get proxy configuration */
|
||||||
getProxy: () => Promise<ProxyConfig>
|
getProxy: () => Promise<ProxyConfig>
|
||||||
/** Set proxy configuration (saves and restarts server) */
|
/** Set proxy configuration (saves and restarts server) */
|
||||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||||
/** Get user's preferred locale */
|
|
||||||
getUserLocale: () => Promise<
|
|
||||||
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
|
||||||
>
|
|
||||||
/** Set user's preferred locale */
|
/** Set user's preferred locale */
|
||||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||||
/**
|
/**
|
||||||
* Call back after the server restarted on the same port (another
|
* Call back after the server restarted on the same port (proxy
|
||||||
* preset); returns a function that stops the calls
|
* change); returns a function that stops the calls
|
||||||
*/
|
*/
|
||||||
onServerRestarted?: (callback: () => void) => () => void
|
onServerRestarted?: (callback: () => void) => () => void
|
||||||
/** A chat was saved: open this port next launch */
|
/** A chat was saved: open this port next launch */
|
||||||
@@ -80,35 +42,7 @@ declare global {
|
|||||||
/** The page loaded with this many chats */
|
/** The page loaded with this many chats */
|
||||||
chatsLoaded?: (count: number) => Promise<void>
|
chatsLoaded?: (count: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
|
||||||
settingsAPI?: {
|
|
||||||
/** Get all configuration presets */
|
|
||||||
getPresets: () => Promise<ConfigPreset[]>
|
|
||||||
/** Get current preset ID */
|
|
||||||
getCurrentPresetId: () => Promise<string | null>
|
|
||||||
/** Get current preset */
|
|
||||||
getCurrentPreset: () => Promise<ConfigPreset | null>
|
|
||||||
/** Save (create or update) a preset */
|
|
||||||
savePreset: (preset: {
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
config: Record<string, string | undefined>
|
|
||||||
}) => Promise<ConfigPreset>
|
|
||||||
/** Delete a preset */
|
|
||||||
deletePreset: (id: string) => Promise<boolean>
|
|
||||||
/** Apply a preset (sets environment variables and restarts server) */
|
|
||||||
applyPreset: (id: string) => Promise<ApplyPresetResult>
|
|
||||||
/** Close settings window */
|
|
||||||
close: () => void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type { ProxyConfig }
|
||||||
ApplyPresetResult,
|
|
||||||
ConfigPreset,
|
|
||||||
ProxyConfig,
|
|
||||||
SetProxyResult,
|
|
||||||
SetUserLocaleResult,
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-161
@@ -1,23 +1,9 @@
|
|||||||
import {
|
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
||||||
app,
|
|
||||||
BrowserWindow,
|
|
||||||
dialog,
|
|
||||||
Menu,
|
|
||||||
type MenuItemConstructorOptions,
|
|
||||||
shell,
|
|
||||||
} from "electron"
|
|
||||||
import {
|
|
||||||
applyPresetToEnv,
|
|
||||||
getAllPresets,
|
|
||||||
getCurrentPresetId,
|
|
||||||
setCurrentPreset,
|
|
||||||
} from "./config-manager"
|
|
||||||
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
||||||
import { restartNextServer } from "./next-server"
|
|
||||||
import { showSettingsWindow } from "./settings-window"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and set the application menu with i18n support
|
* Build and set the application menu with i18n support
|
||||||
|
* (call again when the language changes)
|
||||||
*/
|
*/
|
||||||
export function buildAppMenu(): void {
|
export function buildAppMenu(): void {
|
||||||
const template = getMenuTemplate()
|
const template = getMenuTemplate()
|
||||||
@@ -25,75 +11,6 @@ export function buildAppMenu(): void {
|
|||||||
Menu.setApplicationMenu(menu)
|
Menu.setApplicationMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild the menu (call this when presets change or language changes)
|
|
||||||
*/
|
|
||||||
export function rebuildAppMenu(): void {
|
|
||||||
buildAppMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number of the latest preset switch
|
|
||||||
let lastSwitch = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a preset and restart the server so it takes effect.
|
|
||||||
* If the restart fails, go back to the previous preset and restart again,
|
|
||||||
* so the running server always matches the saved current preset.
|
|
||||||
* Throws an error describing the outcome on failure.
|
|
||||||
*/
|
|
||||||
export async function switchPreset(
|
|
||||||
id: string,
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
const previousPresetId = getCurrentPresetId()
|
|
||||||
const env = applyPresetToEnv(id)
|
|
||||||
if (!env) {
|
|
||||||
throw new Error("Preset not found")
|
|
||||||
}
|
|
||||||
const switchNumber = ++lastSwitch
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
|
||||||
if (!app.isPackaged) {
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await restartNextServer()
|
|
||||||
return env
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to restart server:", error)
|
|
||||||
const reason = error instanceof Error ? error.message : String(error)
|
|
||||||
|
|
||||||
// A newer switch started meanwhile (also of this same preset), or
|
|
||||||
// the preset was deleted: its own restart follows, and undoing
|
|
||||||
// would lose that choice
|
|
||||||
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
|
||||||
throw new Error(
|
|
||||||
`The server could not be restarted.\n\nError: ${reason}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revert to previous preset on failure
|
|
||||||
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
|
||||||
setCurrentPreset(null)
|
|
||||||
}
|
|
||||||
// Rebuild menu to restore previous checkmark state
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await restartNextServer()
|
|
||||||
} catch (retryError) {
|
|
||||||
console.error("Failed to restart server again:", retryError)
|
|
||||||
throw new Error(
|
|
||||||
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template with translations
|
* Get the menu template with translations
|
||||||
*/
|
*/
|
||||||
@@ -113,15 +30,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" }, // System-translated
|
{ role: "about" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
|
||||||
label: t.settings,
|
|
||||||
accelerator: "CmdOrCtrl+,",
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "services" }, // System-translated
|
{ role: "services" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" }, // System-translated
|
{ role: "hide" }, // System-translated
|
||||||
@@ -137,19 +45,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
template.push({
|
template.push({
|
||||||
label: t.file,
|
label: t.file,
|
||||||
submenu: [
|
submenu: [
|
||||||
...(isMac
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: t.settings,
|
|
||||||
accelerator: "CmdOrCtrl+,",
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
]),
|
|
||||||
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -196,9 +91,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
|
||||||
template.push(buildConfigMenu(t))
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.window,
|
label: t.window,
|
||||||
@@ -239,54 +131,3 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
|
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Configuration menu with presets
|
|
||||||
*/
|
|
||||||
function buildConfigMenu(
|
|
||||||
t: ReturnType<typeof getMenuTranslations>,
|
|
||||||
): MenuItemConstructorOptions {
|
|
||||||
const presets = getAllPresets()
|
|
||||||
const currentPresetId = getCurrentPresetId()
|
|
||||||
|
|
||||||
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
|
|
||||||
label: preset.name,
|
|
||||||
type: "radio",
|
|
||||||
checked: preset.id === currentPresetId,
|
|
||||||
click: async () => {
|
|
||||||
try {
|
|
||||||
await switchPreset(preset.id)
|
|
||||||
} catch (error) {
|
|
||||||
// Show error dialog to notify user
|
|
||||||
dialog.showErrorBox(
|
|
||||||
"Configuration Error",
|
|
||||||
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: t.configuration,
|
|
||||||
submenu: [
|
|
||||||
...(presetItems.length > 0
|
|
||||||
? [
|
|
||||||
{ label: t.switchPreset, enabled: false },
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
...presetItems,
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
label:
|
|
||||||
presetItems.length > 0
|
|
||||||
? t.managePresets
|
|
||||||
: t.addConfigurationPreset,
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+36
-549
@@ -1,570 +1,57 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { app } from "electron"
|
||||||
import {
|
import * as fs from "fs"
|
||||||
existsSync,
|
import * as path from "path"
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
renameSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs"
|
|
||||||
import path from "node:path"
|
|
||||||
import { app, safeStorage } from "electron"
|
|
||||||
|
|
||||||
/**
|
export const USER_LOCALES = ["en", "zh", "ja", "zh-Hant"] as const
|
||||||
* Fields that contain sensitive data and should be encrypted
|
export type UserLocale = (typeof USER_LOCALES)[number]
|
||||||
*/
|
|
||||||
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
|
|
||||||
|
|
||||||
/**
|
// Earlier versions kept their configuration presets in this file too; the
|
||||||
* Prefix to identify encrypted values
|
// name stays so the saved language survives the upgrade
|
||||||
*/
|
const CONFIG_FILE = "config-presets.json"
|
||||||
const ENCRYPTED_PREFIX = "encrypted:"
|
|
||||||
|
|
||||||
/**
|
function getConfigPath(): string {
|
||||||
* Check if safeStorage encryption is available
|
return path.join(app.getPath("userData"), CONFIG_FILE)
|
||||||
*/
|
|
||||||
function isEncryptionAvailable(): boolean {
|
|
||||||
return safeStorage.isEncryptionAvailable()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track if we've already warned about plaintext storage
|
* Load the config file as an object ({} if it is missing or invalid)
|
||||||
*/
|
*/
|
||||||
let hasWarnedAboutPlaintext = false
|
function loadConfig(): Record<string, unknown> {
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a sensitive value using safeStorage
|
|
||||||
* Warns if encryption is not available (API key stored in plaintext)
|
|
||||||
*/
|
|
||||||
function encryptValue(value: string): string {
|
|
||||||
// Already encrypted (a value that could not be decrypted): keep it as is
|
|
||||||
// instead of wrapping it in a second layer of encryption
|
|
||||||
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEncryptionAvailable()) {
|
|
||||||
if (!hasWarnedAboutPlaintext) {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ SECURITY WARNING: safeStorage not available. " +
|
|
||||||
"API keys will be stored in PLAINTEXT. " +
|
|
||||||
"On Linux, install gnome-keyring or similar for secure storage.",
|
|
||||||
)
|
|
||||||
hasWarnedAboutPlaintext = true
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = safeStorage.encryptString(value)
|
const configPath = getConfigPath()
|
||||||
return ENCRYPTED_PREFIX + encrypted.toString("base64")
|
if (fs.existsSync(configPath)) {
|
||||||
} catch (error) {
|
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||||
console.error("Encryption failed:", error)
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||||
// Fail secure: don't store if encryption fails
|
return data
|
||||||
throw new Error(
|
|
||||||
"Failed to encrypt API key. Cannot securely store credentials.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a sensitive value using safeStorage
|
|
||||||
* Returns the original value if it's not encrypted or decryption fails
|
|
||||||
* (so saving writes the stored ciphertext back unchanged)
|
|
||||||
*/
|
|
||||||
function decryptValue(value: string): string {
|
|
||||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (!isEncryptionAvailable()) {
|
|
||||||
console.warn(
|
|
||||||
"Cannot decrypt value: safeStorage encryption is not available",
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
|
|
||||||
const buffer = Buffer.from(base64Data, "base64")
|
|
||||||
return safeStorage.decryptString(buffer)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to decrypt value:", error)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt sensitive fields in a config object
|
|
||||||
*/
|
|
||||||
function encryptConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const encrypted = { ...config }
|
|
||||||
for (const field of SENSITIVE_FIELDS) {
|
|
||||||
if (encrypted[field]) {
|
|
||||||
encrypted[field] = encryptValue(encrypted[field] as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt sensitive fields in a config object
|
|
||||||
*/
|
|
||||||
function decryptConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const decrypted = { ...config }
|
|
||||||
for (const field of SENSITIVE_FIELDS) {
|
|
||||||
if (decrypted[field]) {
|
|
||||||
decrypted[field] = decryptValue(decrypted[field] as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration preset interface
|
|
||||||
*/
|
|
||||||
export interface ConfigPreset {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER?: string
|
|
||||||
AI_MODEL?: string
|
|
||||||
AI_API_KEY?: string
|
|
||||||
AI_BASE_URL?: string
|
|
||||||
TEMPERATURE?: string
|
|
||||||
[key: string]: string | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration file structure
|
|
||||||
*/
|
|
||||||
interface ConfigPresetsFile {
|
|
||||||
version: 1
|
|
||||||
currentPresetId: string | null
|
|
||||||
presets: ConfigPreset[]
|
|
||||||
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to the config file
|
|
||||||
*/
|
|
||||||
function getConfigFilePath(): string {
|
|
||||||
const userDataPath = app.getPath("userData")
|
|
||||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The presets file exists but the last read failed: a save now would
|
|
||||||
// replace the user's presets with the empty list that read returned
|
|
||||||
let presetsUnreadable = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load presets from the config file
|
|
||||||
* Decrypts sensitive fields automatically
|
|
||||||
*/
|
|
||||||
export function loadPresets(): ConfigPresetsFile {
|
|
||||||
const configPath = getConfigFilePath()
|
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
|
||||||
// Nothing left that a save could overwrite
|
|
||||||
presetsUnreadable = false
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
currentPresetId: null,
|
|
||||||
presets: [],
|
|
||||||
userLocale: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content: string
|
|
||||||
try {
|
|
||||||
content = readFileSync(configPath, "utf-8")
|
|
||||||
presetsUnreadable = false
|
|
||||||
} catch (error) {
|
|
||||||
// Often only for now (on Windows an antivirus scanner can hold the
|
|
||||||
// file): keep the file, and refuse saves based on this empty list
|
|
||||||
console.error("Failed to read config presets:", error)
|
|
||||||
presetsUnreadable = true
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
currentPresetId: null,
|
|
||||||
presets: [],
|
|
||||||
userLocale: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(content) as ConfigPresetsFile
|
|
||||||
|
|
||||||
// Decrypt sensitive fields in each preset
|
|
||||||
data.presets = data.presets.map((preset) => ({
|
|
||||||
...preset,
|
|
||||||
config: decryptConfig(preset.config) as ConfigPreset["config"],
|
|
||||||
}))
|
|
||||||
|
|
||||||
return data
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load config presets:", error)
|
|
||||||
// Move the unreadable file aside so the next save can't overwrite
|
|
||||||
// the user's presets with an empty list
|
|
||||||
const backupPath = `${configPath}.corrupt-${Date.now()}`
|
|
||||||
try {
|
|
||||||
renameSync(configPath, backupPath)
|
|
||||||
console.error(`Unreadable config presets moved to ${backupPath}`)
|
|
||||||
} catch (renameError) {
|
|
||||||
// Still there: refuse saves that would overwrite it
|
|
||||||
console.error("Failed to back up config presets:", renameError)
|
|
||||||
presetsUnreadable = true
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
currentPresetId: null,
|
|
||||||
presets: [],
|
|
||||||
userLocale: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save presets to the config file
|
|
||||||
* Encrypts sensitive fields automatically
|
|
||||||
*/
|
|
||||||
export function savePresets(data: ConfigPresetsFile): void {
|
|
||||||
if (presetsUnreadable) {
|
|
||||||
throw new Error(
|
|
||||||
"The presets file could not be read, so it was not overwritten. Please try again.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const configPath = getConfigFilePath()
|
|
||||||
const userDataPath = app.getPath("userData")
|
|
||||||
|
|
||||||
// Ensure the directory exists
|
|
||||||
if (!existsSync(userDataPath)) {
|
|
||||||
mkdirSync(userDataPath, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt sensitive fields before saving
|
|
||||||
const dataToSave: ConfigPresetsFile = {
|
|
||||||
...data,
|
|
||||||
presets: data.presets.map((preset) => ({
|
|
||||||
...preset,
|
|
||||||
config: encryptConfig(preset.config) as ConfigPreset["config"],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
|
||||||
// a truncated config file
|
|
||||||
const tempPath = `${configPath}.tmp`
|
|
||||||
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
|
||||||
renameSync(tempPath, configPath)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save config presets:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all presets
|
|
||||||
*/
|
|
||||||
export function getAllPresets(): ConfigPreset[] {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.presets
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current preset ID
|
|
||||||
*/
|
|
||||||
export function getCurrentPresetId(): string | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.currentPresetId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current preset
|
|
||||||
*/
|
|
||||||
export function getCurrentPreset(): ConfigPreset | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
if (!data.currentPresetId) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return data.presets.find((p) => p.id === data.currentPresetId) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new preset
|
|
||||||
*/
|
|
||||||
export function createPreset(
|
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
|
|
||||||
): ConfigPreset {
|
|
||||||
const data = loadPresets()
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
const newPreset: ConfigPreset = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: preset.name,
|
|
||||||
config: preset.config,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets.push(newPreset)
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return newPreset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing preset
|
|
||||||
*/
|
|
||||||
export function updatePreset(
|
|
||||||
id: string,
|
|
||||||
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
|
|
||||||
): ConfigPreset | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
const index = data.presets.findIndex((p) => p.id === id)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedPreset: ConfigPreset = {
|
|
||||||
...data.presets[index],
|
|
||||||
...updates,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets[index] = updatedPreset
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return updatedPreset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a preset
|
|
||||||
*/
|
|
||||||
export function deletePreset(id: string): boolean {
|
|
||||||
const data = loadPresets()
|
|
||||||
const index = data.presets.findIndex((p) => p.id === id)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets.splice(index, 1)
|
|
||||||
|
|
||||||
// Clear current preset (and its env vars) if it was deleted
|
|
||||||
if (data.currentPresetId === id) {
|
|
||||||
data.currentPresetId = null
|
|
||||||
setPresetEnv(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
savePresets(data)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the current preset
|
|
||||||
*/
|
|
||||||
export function setCurrentPreset(id: string | null): boolean {
|
|
||||||
const data = loadPresets()
|
|
||||||
|
|
||||||
let preset: ConfigPreset | null = null
|
|
||||||
if (id !== null) {
|
|
||||||
preset = data.presets.find((p) => p.id === id) || null
|
|
||||||
if (!preset) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPresetEnv(preset)
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
|
|
||||||
*/
|
|
||||||
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|
||||||
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
|
|
||||||
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
|
|
||||||
google: {
|
|
||||||
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
||||||
baseUrl: "GOOGLE_BASE_URL",
|
|
||||||
},
|
|
||||||
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
|
|
||||||
openrouter: {
|
|
||||||
apiKey: "OPENROUTER_API_KEY",
|
|
||||||
baseUrl: "OPENROUTER_BASE_URL",
|
|
||||||
},
|
|
||||||
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
|
|
||||||
siliconflow: {
|
|
||||||
apiKey: "SILICONFLOW_API_KEY",
|
|
||||||
baseUrl: "SILICONFLOW_BASE_URL",
|
|
||||||
},
|
|
||||||
modelscope: {
|
|
||||||
apiKey: "MODELSCOPE_API_KEY",
|
|
||||||
baseUrl: "MODELSCOPE_BASE_URL",
|
|
||||||
},
|
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
|
||||||
// bedrock doesn't use API keys in the same way
|
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
|
||||||
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a preset's config to environment variables
|
|
||||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
|
||||||
*/
|
|
||||||
function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
|
||||||
const env: Record<string, string> = {}
|
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
// A key that could not be decrypted is useless to the server
|
|
||||||
if (value.startsWith(ENCRYPTED_PREFIX)) {
|
|
||||||
console.warn(
|
|
||||||
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
|
||||||
else if (
|
|
||||||
key === "AI_API_KEY" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
||||||
if (providerApiKey) {
|
|
||||||
env[providerApiKey] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Map generic AI_BASE_URL to provider-specific key
|
|
||||||
else if (
|
|
||||||
key === "AI_BASE_URL" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
|
||||||
if (providerBaseUrl) {
|
|
||||||
env[providerBaseUrl] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Apply other env vars directly
|
|
||||||
else {
|
|
||||||
env[key] = value
|
|
||||||
}
|
}
|
||||||
|
console.error("Ignoring invalid config file:", data)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Values that env vars had before a preset first set them
|
|
||||||
* (from the system or .env files), and the keys the active preset set
|
|
||||||
*/
|
|
||||||
const originalEnv: Record<string, string | undefined> = {}
|
|
||||||
let presetEnvKeys: string[] = []
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the env vars of the previous preset with those of the given preset
|
|
||||||
* (null leaves no preset applied). Restoring first means switching presets
|
|
||||||
* never leaves the previous preset's base URL, model or key behind.
|
|
||||||
*/
|
|
||||||
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
|
||||||
for (const key of presetEnvKeys) {
|
|
||||||
if (originalEnv[key] === undefined) {
|
|
||||||
delete process.env[key]
|
|
||||||
} else {
|
|
||||||
process.env[key] = originalEnv[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = preset ? presetToEnv(preset) : {}
|
|
||||||
for (const [key, value] of Object.entries(env)) {
|
|
||||||
if (!(key in originalEnv)) {
|
|
||||||
originalEnv[key] = process.env[key]
|
|
||||||
}
|
|
||||||
process.env[key] = value
|
|
||||||
}
|
|
||||||
presetEnvKeys = Object.keys(env)
|
|
||||||
|
|
||||||
writeDevPresetEnv(env)
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Development only: write the active preset's env vars (decrypted and mapped)
|
|
||||||
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
|
||||||
* this file changes. The dev server can't decrypt the config file itself.
|
|
||||||
*/
|
|
||||||
function writeDevPresetEnv(env: Record<string, string>): void {
|
|
||||||
if (app.isPackaged) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
|
||||||
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
|
||||||
encoding: "utf-8",
|
|
||||||
mode: 0o600,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to write dev preset env:", error)
|
console.error("Failed to load config file:", error)
|
||||||
}
|
}
|
||||||
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply preset environment variables to the current process
|
* Get the menu locale the user chose, if any
|
||||||
* Returns the environment variables that were applied
|
|
||||||
*/
|
*/
|
||||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
export function getUserLocale(): UserLocale | undefined {
|
||||||
const data = loadPresets()
|
const { userLocale } = loadConfig()
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
return (USER_LOCALES as readonly unknown[]).includes(userLocale)
|
||||||
|
? (userLocale as UserLocale)
|
||||||
if (!preset) {
|
: undefined
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedEnv = setPresetEnv(preset)
|
|
||||||
|
|
||||||
// Set as current preset
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return appliedEnv
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the saved current preset's environment variables (used at startup)
|
* Save the menu locale the user chose
|
||||||
*/
|
*/
|
||||||
export function applyCurrentPresetToEnv(): void {
|
export function setUserLocale(locale: UserLocale): void {
|
||||||
setPresetEnv(getCurrentPreset())
|
const config = { ...loadConfig(), userLocale: locale }
|
||||||
}
|
const configPath = getConfigPath()
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
||||||
/**
|
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||||
* Get user's preferred locale from config
|
// a truncated file
|
||||||
* Returns undefined if not set
|
const tempPath = `${configPath}.tmp`
|
||||||
*/
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||||
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
fs.renameSync(tempPath, configPath)
|
||||||
const data = loadPresets()
|
|
||||||
return data.userLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user's preferred locale in config
|
|
||||||
*/
|
|
||||||
export function setUserLocale(
|
|
||||||
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
|
||||||
): void {
|
|
||||||
const data = loadPresets()
|
|
||||||
data.userLocale = locale === null ? undefined : locale
|
|
||||||
savePresets(data)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { parse } from "dotenv"
|
||||||
import { app } from "electron"
|
import { app } from "electron"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,69 +29,13 @@ export function loadEnvFile(): void {
|
|||||||
console.log("No .env file found, using system environment variables")
|
console.log("No .env file found, using system environment variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Index of the quote that closes a value starting with a quote, or -1. A
|
|
||||||
* backslash before the quote character escapes it, as in dotenv; the
|
|
||||||
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
|
||||||
* comment or nothing after it still closes the value when no other quote
|
|
||||||
* does ("C:\dir\" keeps its trailing backslash).
|
|
||||||
*/
|
|
||||||
function findClosingQuote(value: string): number {
|
|
||||||
const quote = value[0]
|
|
||||||
let lastEscaped = -1
|
|
||||||
for (let i = 1; i < value.length; i++) {
|
|
||||||
if (value[i] === "\\" && value[i + 1] === quote) {
|
|
||||||
i++
|
|
||||||
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
|
||||||
} else if (value[i] === quote) return i
|
|
||||||
}
|
|
||||||
return lastEscaped
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse and load environment variables from a file
|
* Parse and load environment variables from a file
|
||||||
*/
|
*/
|
||||||
function loadEnvFromFile(filePath: string): void {
|
function loadEnvFromFile(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath, "utf-8")
|
const parsed = parse(fs.readFileSync(filePath, "utf-8"))
|
||||||
const lines = content.split("\n")
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
|
|
||||||
// Skip comments and empty lines
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue
|
|
||||||
|
|
||||||
const equalIndex = trimmed.indexOf("=")
|
|
||||||
if (equalIndex === -1) continue
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, equalIndex).trim()
|
|
||||||
let value = trimmed.slice(equalIndex + 1).trim()
|
|
||||||
|
|
||||||
const quote = value[0]
|
|
||||||
const closingQuote =
|
|
||||||
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
|
||||||
if (
|
|
||||||
closingQuote > 0 &&
|
|
||||||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
|
||||||
) {
|
|
||||||
// Quoted value, then nothing or a comment: keep what is
|
|
||||||
// inside the quotes, as dotenv reads it
|
|
||||||
value = value.slice(1, closingQuote)
|
|
||||||
} else {
|
|
||||||
// Unquoted value: drop an inline comment ("value # comment").
|
|
||||||
// A value quoted from start to end with quotes inside (JSON
|
|
||||||
// with an apostrophe) loses only the outer two, as in dotenv.
|
|
||||||
value = value.replace(/\s+#.*$/, "")
|
|
||||||
if (
|
|
||||||
closingQuote > 0 &&
|
|
||||||
value.length > 1 &&
|
|
||||||
value.endsWith(quote)
|
|
||||||
) {
|
|
||||||
value = value.slice(1, -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't override existing environment variables
|
// Don't override existing environment variables
|
||||||
if (!(key in process.env)) {
|
if (!(key in process.env)) {
|
||||||
process.env[key] = value
|
process.env[key] = value
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import { applyCurrentPresetToEnv } from "./config-manager"
|
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
import { applyProxyToEnv } from "./proxy-manager"
|
import { applyProxyToEnv } from "./proxy-manager"
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
|
||||||
import {
|
import {
|
||||||
createWindow,
|
createWindow,
|
||||||
getAppUrl,
|
getAppUrl,
|
||||||
@@ -36,14 +34,8 @@ if (!gotTheLock) {
|
|||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Apply saved preset environment variables (overrides .env).
|
|
||||||
// Must run after ready: on Windows and Linux safeStorage can't
|
|
||||||
// decrypt the API key before that.
|
|
||||||
applyCurrentPresetToEnv()
|
|
||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
registerSettingsWindowHandlers()
|
|
||||||
|
|
||||||
// Build application menu
|
// Build application menu
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
|
|||||||
+11
-171
@@ -1,17 +1,6 @@
|
|||||||
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
import { app, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import {
|
import { setUserLocale, USER_LOCALES, type UserLocale } from "./config-manager"
|
||||||
type ConfigPreset,
|
|
||||||
createPreset,
|
|
||||||
deletePreset,
|
|
||||||
getAllPresets,
|
|
||||||
getCurrentPreset,
|
|
||||||
getCurrentPresetId,
|
|
||||||
getUserLocale,
|
|
||||||
setCurrentPreset,
|
|
||||||
setUserLocale,
|
|
||||||
updatePreset,
|
|
||||||
} from "./config-manager"
|
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import { noteNoChats, rememberChatPort } from "./port-manager"
|
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||||
import {
|
import {
|
||||||
@@ -23,37 +12,9 @@ import {
|
|||||||
import { isAppUrl } from "./window-manager"
|
import { isAppUrl } from "./window-manager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Register an IPC handler that only answers the app's own pages (on the app
|
||||||
* This whitelist prevents arbitrary environment variable injection
|
* server). A main window that somehow ends up on an external site still
|
||||||
*/
|
* gets the preload API, so its calls must be rejected here.
|
||||||
const ALLOWED_CONFIG_KEYS = new Set([
|
|
||||||
"AI_PROVIDER",
|
|
||||||
"AI_MODEL",
|
|
||||||
"AI_API_KEY",
|
|
||||||
"AI_BASE_URL",
|
|
||||||
"TEMPERATURE",
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize preset config to only include allowed keys
|
|
||||||
*/
|
|
||||||
function sanitizePresetConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const sanitized: Record<string, string | undefined> = {}
|
|
||||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
|
||||||
if (key in config && typeof config[key] === "string") {
|
|
||||||
sanitized[key] = config[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an IPC handler that only answers the app's own pages
|
|
||||||
* (the main window on the app server, or the local settings page).
|
|
||||||
* A main window that somehow ends up on an external site still gets the
|
|
||||||
* preload API, so its calls must be rejected here.
|
|
||||||
*/
|
*/
|
||||||
function handle<Args extends unknown[]>(
|
function handle<Args extends unknown[]>(
|
||||||
channel: string,
|
channel: string,
|
||||||
@@ -61,7 +22,7 @@ function handle<Args extends unknown[]>(
|
|||||||
): void {
|
): void {
|
||||||
ipcMain.handle(channel, (event, ...args) => {
|
ipcMain.handle(channel, (event, ...args) => {
|
||||||
const url = event.senderFrame?.url
|
const url = event.senderFrame?.url
|
||||||
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
if (!isAppUrl(url)) {
|
||||||
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
||||||
}
|
}
|
||||||
return listener(event, ...(args as Args))
|
return listener(event, ...(args as Args))
|
||||||
@@ -72,12 +33,6 @@ function handle<Args extends unknown[]>(
|
|||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
*/
|
*/
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ==================== App Info ====================
|
|
||||||
|
|
||||||
handle("get-version", () => {
|
|
||||||
return app.getVersion()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Where the chats are ====================
|
// ==================== Where the chats are ====================
|
||||||
|
|
||||||
// The page saved a chat, or loaded without any: decides which port
|
// The page saved a chat, or loaded without any: decides which port
|
||||||
@@ -87,116 +42,6 @@ export function registerIpcHandlers(): void {
|
|||||||
if (count === 0) noteNoChats()
|
if (count === 0) noteNoChats()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Window Controls ====================
|
|
||||||
|
|
||||||
ipcMain.on("window-minimize", (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
win?.minimize()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on("window-maximize", (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
if (win?.isMaximized()) {
|
|
||||||
win.unmaximize()
|
|
||||||
} else {
|
|
||||||
win?.maximize()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on("window-close", (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
win?.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Config Presets ====================
|
|
||||||
|
|
||||||
handle("config-presets:get-all", () => {
|
|
||||||
return getAllPresets()
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:get-current", () => {
|
|
||||||
return getCurrentPreset()
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:get-current-id", () => {
|
|
||||||
return getCurrentPresetId()
|
|
||||||
})
|
|
||||||
|
|
||||||
handle(
|
|
||||||
"config-presets:save",
|
|
||||||
async (
|
|
||||||
_event,
|
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
|
||||||
id?: string
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
// Validate preset name
|
|
||||||
if (typeof preset?.name !== "string" || !preset.name.trim()) {
|
|
||||||
throw new Error("Invalid preset name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize config to only allow whitelisted keys
|
|
||||||
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
|
|
||||||
|
|
||||||
if (preset.id) {
|
|
||||||
// Update existing preset
|
|
||||||
const updated = updatePreset(preset.id, {
|
|
||||||
name: preset.name.trim(),
|
|
||||||
config: sanitizedConfig,
|
|
||||||
})
|
|
||||||
// Re-apply the active preset so the edit takes effect
|
|
||||||
if (updated && updated.id === getCurrentPresetId()) {
|
|
||||||
await switchPreset(updated.id)
|
|
||||||
} else {
|
|
||||||
rebuildAppMenu()
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
// Create new preset
|
|
||||||
const created = createPreset({
|
|
||||||
name: preset.name.trim(),
|
|
||||||
config: sanitizedConfig,
|
|
||||||
})
|
|
||||||
rebuildAppMenu()
|
|
||||||
return created
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
handle("config-presets:delete", async (_event, id: string) => {
|
|
||||||
const wasCurrent = id === getCurrentPresetId()
|
|
||||||
// Deleting the active preset also clears its env vars
|
|
||||||
const deleted = deletePreset(id)
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
// Restart so the server stops using the deleted preset
|
|
||||||
if (deleted && wasCurrent && app.isPackaged) {
|
|
||||||
await restartNextServer()
|
|
||||||
}
|
|
||||||
return deleted
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:apply", async (_event, id: string) => {
|
|
||||||
try {
|
|
||||||
const env = await switchPreset(id)
|
|
||||||
// In development mode, electron-dev.mjs restarts Next.js
|
|
||||||
return app.isPackaged
|
|
||||||
? { success: true, env }
|
|
||||||
: { success: true, env, devMode: true }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to restart server",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:set-current", (_event, id: string | null) => {
|
|
||||||
return setCurrentPreset(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Proxy Settings ====================
|
// ==================== Proxy Settings ====================
|
||||||
|
|
||||||
handle("get-proxy", () => {
|
handle("get-proxy", () => {
|
||||||
@@ -228,7 +73,7 @@ export function registerIpcHandlers(): void {
|
|||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
// In development, env vars are already applied
|
// In development, env vars are already applied
|
||||||
// Next.js dev server may need manual restart
|
// Next.js dev server may need manual restart
|
||||||
return { success: true, devMode: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: restart Next.js server to pick up new env vars
|
// Production: restart Next.js server to pick up new env vars
|
||||||
@@ -247,20 +92,15 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// ==================== User Locale ====================
|
// ==================== User Locale ====================
|
||||||
|
|
||||||
handle("get-user-locale", () => {
|
|
||||||
return getUserLocale()
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("set-user-locale", (_event, locale: string) => {
|
handle("set-user-locale", (_event, locale: string) => {
|
||||||
// Validate locale is one of the supported values
|
if (!(USER_LOCALES as readonly string[]).includes(locale)) {
|
||||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
|
||||||
return { success: false, error: "Invalid locale" }
|
return { success: false, error: "Invalid locale" }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
setUserLocale(locale as UserLocale)
|
||||||
// Rebuild the menu to reflect the new locale
|
// Rebuild the menu to reflect the new locale
|
||||||
rebuildAppMenu()
|
buildAppMenu()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+27
-113
@@ -3,71 +3,35 @@
|
|||||||
* Translations for menu labels that don't use Electron's built-in roles
|
* Translations for menu labels that don't use Electron's built-in roles
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getUserLocale } from "./config-manager"
|
import { getUserLocale, type UserLocale } from "./config-manager"
|
||||||
|
|
||||||
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
export type MenuLocale = UserLocale
|
||||||
|
|
||||||
export interface MenuTranslations {
|
|
||||||
// App menu (macOS only)
|
|
||||||
settings: string
|
|
||||||
|
|
||||||
|
const en = {
|
||||||
// File menu
|
// File menu
|
||||||
file: string
|
file: "File",
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
edit: string
|
edit: "Edit",
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
view: string
|
view: "View",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: string
|
|
||||||
switchPreset: string
|
|
||||||
managePresets: string
|
|
||||||
addConfigurationPreset: string
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: string
|
window: "Window",
|
||||||
|
|
||||||
// Help menu
|
// Help menu
|
||||||
help: string
|
help: "Help",
|
||||||
documentation: string
|
documentation: "Documentation",
|
||||||
reportIssue: string
|
reportIssue: "Report Issue",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MenuTranslations = typeof en
|
||||||
|
|
||||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||||
en: {
|
en,
|
||||||
// App menu
|
|
||||||
settings: "Settings...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "File",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "Edit",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "View",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "Configuration",
|
|
||||||
switchPreset: "Switch Preset",
|
|
||||||
managePresets: "Manage Presets...",
|
|
||||||
addConfigurationPreset: "Add Configuration Preset...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "Window",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "Help",
|
|
||||||
documentation: "Documentation",
|
|
||||||
reportIssue: "Report Issue",
|
|
||||||
},
|
|
||||||
|
|
||||||
zh: {
|
zh: {
|
||||||
// App menu
|
|
||||||
settings: "设置...",
|
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "文件",
|
file: "文件",
|
||||||
|
|
||||||
@@ -77,12 +41,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "查看",
|
view: "查看",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "配置",
|
|
||||||
switchPreset: "切换预设",
|
|
||||||
managePresets: "管理预设...",
|
|
||||||
addConfigurationPreset: "添加配置预设...",
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "窗口",
|
window: "窗口",
|
||||||
|
|
||||||
@@ -93,9 +51,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ja: {
|
ja: {
|
||||||
// App menu
|
|
||||||
settings: "設定...",
|
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "ファイル",
|
file: "ファイル",
|
||||||
|
|
||||||
@@ -105,12 +60,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "表示",
|
view: "表示",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "設定",
|
|
||||||
switchPreset: "プリセット切り替え",
|
|
||||||
managePresets: "プリセット管理...",
|
|
||||||
addConfigurationPreset: "設定プリセットを追加...",
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "ウインドウ",
|
window: "ウインドウ",
|
||||||
|
|
||||||
@@ -121,9 +70,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"zh-Hant": {
|
"zh-Hant": {
|
||||||
// App menu
|
|
||||||
settings: "設定...",
|
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "檔案",
|
file: "檔案",
|
||||||
|
|
||||||
@@ -133,12 +79,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "檢視",
|
view: "檢視",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "配置",
|
|
||||||
switchPreset: "切換預設",
|
|
||||||
managePresets: "管理預設...",
|
|
||||||
addConfigurationPreset: "新增配置預設...",
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "視窗",
|
window: "視窗",
|
||||||
|
|
||||||
@@ -150,33 +90,22 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get menu translations for a given locale
|
* Get menu translations for a locale (English for an unknown one)
|
||||||
* Falls back to English if locale is not supported
|
|
||||||
*/
|
*/
|
||||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
export function getMenuTranslations(locale: MenuLocale): MenuTranslations {
|
||||||
// Check for zh-Hant before normalizing
|
return translations[locale] ?? translations.en
|
||||||
if (
|
|
||||||
locale === "zh-Hant" ||
|
|
||||||
locale.toLowerCase().startsWith("zh-hant") ||
|
|
||||||
locale.toLowerCase().startsWith("zh-tw") ||
|
|
||||||
locale.toLowerCase().startsWith("zh-hk")
|
|
||||||
) {
|
|
||||||
return translations["zh-Hant"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
|
||||||
const normalized = locale.toLowerCase().split("-")[0]
|
|
||||||
|
|
||||||
if (normalized === "zh") return translations.zh
|
|
||||||
if (normalized === "ja") return translations.ja
|
|
||||||
return translations.en
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect system locale from Electron app
|
* Get locale from stored preference or system default
|
||||||
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
* Checks config file for user's language preference first
|
||||||
*/
|
*/
|
||||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
export function getPreferredLocale(appLocale: string): MenuLocale {
|
||||||
|
const savedLocale = getUserLocale()
|
||||||
|
if (savedLocale) {
|
||||||
|
return savedLocale
|
||||||
|
}
|
||||||
|
|
||||||
const lower = appLocale.toLowerCase()
|
const lower = appLocale.toLowerCase()
|
||||||
|
|
||||||
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
||||||
@@ -188,24 +117,9 @@ export function detectSystemLocale(appLocale: string): MenuLocale {
|
|||||||
return "zh-Hant"
|
return "zh-Hant"
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = lower.split("-")[0]
|
const language = lower.split("-")[0]
|
||||||
|
|
||||||
if (normalized === "zh") return "zh"
|
if (language === "zh") return "zh"
|
||||||
if (normalized === "ja") return "ja"
|
if (language === "ja") return "ja"
|
||||||
return "en"
|
return "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get locale from stored preference or system default
|
|
||||||
* Checks config file for user's language preference first
|
|
||||||
*/
|
|
||||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
|
||||||
// Try to get from saved preference first
|
|
||||||
const savedLocale = getUserLocale()
|
|
||||||
if (savedLocale) {
|
|
||||||
return savedLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to system locale
|
|
||||||
return detectSystemLocale(appLocale)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { setAppUrl } from "./window-manager"
|
|||||||
let serverProcess: UtilityProcess | null = null
|
let serverProcess: UtilityProcess | null = null
|
||||||
|
|
||||||
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
||||||
// preset switches) can't leave two servers running
|
// proxy changes) can't leave two servers running
|
||||||
let serverQueue: Promise<unknown> = Promise.resolve()
|
let serverQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||||
@@ -21,18 +21,6 @@ function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to the standalone server resources
|
|
||||||
* In packaged app: resources/standalone
|
|
||||||
* In development: .next/standalone
|
|
||||||
*/
|
|
||||||
function getResourcePath(): string {
|
|
||||||
if (app.isPackaged) {
|
|
||||||
return path.join(process.resourcesPath, "standalone")
|
|
||||||
}
|
|
||||||
return path.join(app.getAppPath(), ".next", "standalone")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the server to be ready by polling the health endpoint
|
* Wait for the server to be ready by polling the health endpoint
|
||||||
*/
|
*/
|
||||||
@@ -61,7 +49,8 @@ export function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer(): Promise<string> {
|
async function startServer(): Promise<string> {
|
||||||
const resourcePath = getResourcePath()
|
// The standalone server shipped in the packaged app's resources
|
||||||
|
const resourcePath = path.join(process.resourcesPath, "standalone")
|
||||||
const serverPath = path.join(resourcePath, "server.js")
|
const serverPath = path.join(resourcePath, "server.js")
|
||||||
|
|
||||||
console.log(`Starting Next.js server from: ${resourcePath}`)
|
console.log(`Starting Next.js server from: ${resourcePath}`)
|
||||||
@@ -75,7 +64,7 @@ async function startServer(): Promise<string> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find an available port (random in production, fixed in development)
|
// Find an available port (the fixed ports 61337 / 13370, then fallbacks)
|
||||||
const port = await findAvailablePort()
|
const port = await findAvailablePort()
|
||||||
console.log(`Using port: ${port}`)
|
console.log(`Using port: ${port}`)
|
||||||
|
|
||||||
@@ -86,7 +75,7 @@ async function startServer(): Promise<string> {
|
|||||||
HOSTNAME: "127.0.0.1",
|
HOSTNAME: "127.0.0.1",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||||
NODE_USE_ENV_PROXY: "1",
|
NODE_USE_ENV_PROXY: "1",
|
||||||
// The preset keys are the user's own, not a server's
|
// The local server's env keys are the user's own, not a shared server's
|
||||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +86,7 @@ async function startServer(): Promise<string> {
|
|||||||
|
|
||||||
// Set cache directory to a writable location (user's app data folder)
|
// Set cache directory to a writable location (user's app data folder)
|
||||||
// This is necessary because the packaged app might be on a read-only volume
|
// This is necessary because the packaged app might be on a read-only volume
|
||||||
if (app.isPackaged) {
|
env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache")
|
||||||
const cacheDir = path.join(app.getPath("userData"), "cache")
|
|
||||||
env.NEXT_CACHE_DIR = cacheDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy existing environment variables
|
// Copy existing environment variables
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { app } from "electron"
|
|||||||
* (localStorage is origin-specific, so changing ports loses all saved data)
|
* (localStorage is origin-specific, so changing ports loses all saved data)
|
||||||
*/
|
*/
|
||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
|
||||||
development: 6002,
|
|
||||||
// Legacy production port — tried first to preserve localStorage for existing users
|
// Legacy production port — tried first to preserve localStorage for existing users
|
||||||
legacyProduction: 61337,
|
legacyProduction: 61337,
|
||||||
// New production port below the ephemeral range (49152-65535)
|
// New production port below the ephemeral range (49152-65535)
|
||||||
@@ -108,9 +106,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - Uses the legacy port (61337), then 13370, to preserve localStorage;
|
||||||
* - In production: uses the legacy port (61337), then 13370, to preserve
|
* 13370 first when only it has saved chats
|
||||||
* localStorage; 13370 first when only it has saved chats
|
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
* - Last resort: lets the OS assign a port (port 0)
|
* - Last resort: lets the OS assign a port (port 0)
|
||||||
*
|
*
|
||||||
@@ -118,10 +115,7 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const preferredPort = PORT_CONFIG.production
|
||||||
const preferredPort = isDev
|
|
||||||
? PORT_CONFIG.development
|
|
||||||
: PORT_CONFIG.production
|
|
||||||
|
|
||||||
// Try to reuse cached port if requested and available
|
// Try to reuse cached port if requested and available
|
||||||
if (reuseExisting && allocatedPort !== null) {
|
if (reuseExisting && allocatedPort !== null) {
|
||||||
@@ -135,19 +129,18 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, first the port where a chat was last saved. Without
|
// First the port where a chat was last saved. Without one, the legacy
|
||||||
// one, the legacy port first to preserve existing users' data, unless
|
// port first to preserve existing users' data, unless only the new port
|
||||||
// only the new port has data: their app started on 13370 while Windows
|
// has data: their app started on 13370 while Windows reserved 61337,
|
||||||
// reserved 61337, and 61337 being free now would hide it
|
// and 61337 being free now would hide it
|
||||||
const chatPort = isDev ? null : readChatPort()
|
const chatPort = readChatPort()
|
||||||
const candidates = isDev
|
const candidates =
|
||||||
? [preferredPort]
|
chatPort !== null
|
||||||
: chatPort !== null
|
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
: hasStoredData(PORT_CONFIG.production) &&
|
||||||
: hasStoredData(PORT_CONFIG.production) &&
|
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
|
||||||
for (const port of candidates) {
|
for (const port of candidates) {
|
||||||
if (await isPortAvailable(port)) {
|
if (await isPortAvailable(port)) {
|
||||||
allocatedPort = port
|
allocatedPort = port
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import path from "node:path"
|
|
||||||
import { app, BrowserWindow, ipcMain } from "electron"
|
|
||||||
|
|
||||||
let settingsWindow: BrowserWindow | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and show the settings window
|
|
||||||
*/
|
|
||||||
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
|
|
||||||
// If settings window already exists, focus it
|
|
||||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
||||||
settingsWindow.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine path to settings preload script
|
|
||||||
// In compiled output: dist-electron/preload/settings.js
|
|
||||||
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
|
|
||||||
|
|
||||||
// Determine path to settings HTML
|
|
||||||
// In packaged app: app.asar/dist-electron/settings/index.html
|
|
||||||
// In development: electron/settings/index.html
|
|
||||||
const settingsHtmlPath = app.isPackaged
|
|
||||||
? path.join(__dirname, "..", "settings", "index.html")
|
|
||||||
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
|
|
||||||
|
|
||||||
settingsWindow = new BrowserWindow({
|
|
||||||
width: 600,
|
|
||||||
height: 700,
|
|
||||||
minWidth: 500,
|
|
||||||
minHeight: 500,
|
|
||||||
parent: parentWindow,
|
|
||||||
modal: false,
|
|
||||||
show: false,
|
|
||||||
title: "Settings - Next AI Draw.io",
|
|
||||||
webPreferences: {
|
|
||||||
preload: preloadPath,
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
sandbox: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
settingsWindow.loadFile(settingsHtmlPath)
|
|
||||||
|
|
||||||
settingsWindow.once("ready-to-show", () => {
|
|
||||||
settingsWindow?.show()
|
|
||||||
})
|
|
||||||
|
|
||||||
settingsWindow.on("closed", () => {
|
|
||||||
settingsWindow = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the settings window if it exists
|
|
||||||
*/
|
|
||||||
export function closeSettingsWindow(): void {
|
|
||||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
||||||
settingsWindow.close()
|
|
||||||
settingsWindow = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if settings window is open
|
|
||||||
*/
|
|
||||||
export function isSettingsWindowOpen(): boolean {
|
|
||||||
return settingsWindow !== null && !settingsWindow.isDestroyed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register settings window IPC handlers
|
|
||||||
*/
|
|
||||||
export function registerSettingsWindowHandlers(): void {
|
|
||||||
ipcMain.on("settings:close", () => {
|
|
||||||
closeSettingsWindow()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -107,9 +107,9 @@ export function getAppUrl(): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Point the main window at the restarted app server (it can come up on a
|
* Point the main window at the restarted app server (it can come up on a
|
||||||
* different port). On the same port the page fetches the new preset's
|
* different port). On the same port the page is told so it can fetch the
|
||||||
* server models instead of sending the old preset's choice; it is not
|
* server's models again; it is not reloaded, which would drop unsent
|
||||||
* reloaded, which would drop unsent attachments.
|
* attachments.
|
||||||
*/
|
*/
|
||||||
export function setAppUrl(url: string): void {
|
export function setAppUrl(url: string): void {
|
||||||
if (url === appUrl) {
|
if (url === appUrl) {
|
||||||
|
|||||||
@@ -4,27 +4,15 @@ import { contextBridge, ipcRenderer } from "electron"
|
|||||||
* Expose safe APIs to the renderer process
|
* Expose safe APIs to the renderer process
|
||||||
*/
|
*/
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
// Platform information
|
|
||||||
platform: process.platform,
|
|
||||||
|
|
||||||
// Check if running in Electron
|
// Check if running in Electron
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
|
|
||||||
// Application version
|
|
||||||
getVersion: () => ipcRenderer.invoke("get-version"),
|
|
||||||
|
|
||||||
// Window controls (optional, for custom title bar)
|
|
||||||
minimize: () => ipcRenderer.send("window-minimize"),
|
|
||||||
maximize: () => ipcRenderer.send("window-maximize"),
|
|
||||||
close: () => ipcRenderer.send("window-close"),
|
|
||||||
|
|
||||||
// Proxy settings
|
// Proxy settings
|
||||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||||
ipcRenderer.invoke("set-proxy", config),
|
ipcRenderer.invoke("set-proxy", config),
|
||||||
|
|
||||||
// User locale settings
|
// User locale settings
|
||||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
|
||||||
setUserLocale: (locale: string) =>
|
setUserLocale: (locale: string) =>
|
||||||
ipcRenderer.invoke("set-user-locale", locale),
|
ipcRenderer.invoke("set-user-locale", locale),
|
||||||
|
|
||||||
@@ -33,7 +21,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
||||||
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
||||||
|
|
||||||
// The server restarted on the same port (another preset)
|
// The server restarted on the same port (proxy change)
|
||||||
onServerRestarted: (callback: () => void) => {
|
onServerRestarted: (callback: () => void) => {
|
||||||
const listener = () => callback()
|
const listener = () => callback()
|
||||||
ipcRenderer.on("server-restarted", listener)
|
ipcRenderer.on("server-restarted", listener)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Preload script for settings window
|
|
||||||
* Exposes APIs for managing configuration presets
|
|
||||||
*/
|
|
||||||
import { contextBridge, ipcRenderer } from "electron"
|
|
||||||
|
|
||||||
// Expose settings API to the renderer process
|
|
||||||
contextBridge.exposeInMainWorld("settingsAPI", {
|
|
||||||
// Get all presets
|
|
||||||
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
|
|
||||||
|
|
||||||
// Get current preset ID
|
|
||||||
getCurrentPresetId: () =>
|
|
||||||
ipcRenderer.invoke("config-presets:get-current-id"),
|
|
||||||
|
|
||||||
// Get current preset
|
|
||||||
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
|
|
||||||
|
|
||||||
// Save (create or update) a preset
|
|
||||||
savePreset: (preset: {
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
config: Record<string, string | undefined>
|
|
||||||
}) => ipcRenderer.invoke("config-presets:save", preset),
|
|
||||||
|
|
||||||
// Delete a preset
|
|
||||||
deletePreset: (id: string) =>
|
|
||||||
ipcRenderer.invoke("config-presets:delete", id),
|
|
||||||
|
|
||||||
// Apply a preset (sets environment variables and restarts server)
|
|
||||||
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
|
|
||||||
|
|
||||||
// Close settings window
|
|
||||||
close: () => ipcRenderer.send("settings:close"),
|
|
||||||
})
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
|
|
||||||
<title>Settings - Next AI Draw.io</title>
|
|
||||||
<link rel="stylesheet" href="./settings.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="deprecation-notice">
|
|
||||||
<strong>⚠️ Deprecation Notice</strong>
|
|
||||||
<p>This settings panel will be removed in a future update.</p>
|
|
||||||
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1>Configuration Presets</h1>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2>Presets</h2>
|
|
||||||
<div id="preset-list" class="preset-list">
|
|
||||||
<!-- Presets will be loaded here -->
|
|
||||||
</div>
|
|
||||||
<button id="add-preset-btn" class="btn btn-primary">
|
|
||||||
+ Add New Preset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Preset Modal -->
|
|
||||||
<div id="preset-modal" class="modal-overlay">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 id="modal-title">Add Preset</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="preset-form">
|
|
||||||
<input type="hidden" id="preset-id">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="preset-name">Preset Name *</label>
|
|
||||||
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-provider">AI Provider</label>
|
|
||||||
<select id="ai-provider">
|
|
||||||
<option value="">-- Select Provider --</option>
|
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
|
||||||
<option value="google">Google AI (Gemini)</option>
|
|
||||||
<option value="azure">Azure OpenAI</option>
|
|
||||||
<option value="bedrock">AWS Bedrock</option>
|
|
||||||
<option value="openrouter">OpenRouter</option>
|
|
||||||
<option value="deepseek">DeepSeek</option>
|
|
||||||
<option value="siliconflow">SiliconFlow</option>
|
|
||||||
<option value="modelscope">ModelScope</option>
|
|
||||||
<option value="ollama">Ollama (Local)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-model">Model ID</label>
|
|
||||||
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
|
|
||||||
<div class="hint">The model identifier to use with the selected provider</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-api-key">API Key</label>
|
|
||||||
<input type="password" id="ai-api-key" placeholder="Your API key">
|
|
||||||
<div class="hint">This will be stored locally on your device</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-base-url">Base URL (Optional)</label>
|
|
||||||
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
|
|
||||||
<div class="hint">Custom API endpoint URL</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="temperature">Temperature (Optional)</label>
|
|
||||||
<input type="text" id="temperature" placeholder="0.7">
|
|
||||||
<div class="hint">Controls randomness (0.0 - 2.0)</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
|
||||||
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div id="delete-modal" class="modal-overlay">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Delete Preset</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
|
|
||||||
<p class="delete-warning">This action cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
|
|
||||||
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast notification -->
|
|
||||||
<div id="toast" class="toast"></div>
|
|
||||||
|
|
||||||
<script src="./settings.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-primary: #ffffff;
|
|
||||||
--bg-secondary: #f5f5f5;
|
|
||||||
--bg-hover: #e8e8e8;
|
|
||||||
--text-primary: #1a1a1a;
|
|
||||||
--text-secondary: #666666;
|
|
||||||
--border-color: #e0e0e0;
|
|
||||||
--accent-color: #0066cc;
|
|
||||||
--accent-hover: #0052a3;
|
|
||||||
--danger-color: #dc3545;
|
|
||||||
--success-color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--bg-primary: #1a1a1a;
|
|
||||||
--bg-secondary: #2d2d2d;
|
|
||||||
--bg-hover: #3d3d3d;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0a0;
|
|
||||||
--border-color: #404040;
|
|
||||||
--accent-color: #4da6ff;
|
|
||||||
--accent-hover: #66b3ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border: 1px solid #ffc107;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice strong {
|
|
||||||
color: #856404;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice p {
|
|
||||||
color: #856404;
|
|
||||||
font-size: 13px;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.deprecation-notice {
|
|
||||||
background-color: #332701;
|
|
||||||
border-color: #665200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice strong,
|
|
||||||
.deprecation-notice p {
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
|
||||||
sans-serif;
|
|
||||||
background-color: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card.active {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 1px var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-badge {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-info {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--danger-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 100;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay.show {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 480px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group .hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: inline-block;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-top-color: var(--accent-color);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: var(--bg-primary);
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
z-index: 200;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.success {
|
|
||||||
background: var(--success-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.error {
|
|
||||||
background: var(--danger-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline style replacements */
|
|
||||||
.delete-warning {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
// Settings page JavaScript
|
|
||||||
// This file handles the UI interactions for the settings window
|
|
||||||
|
|
||||||
let presets = []
|
|
||||||
let currentPresetId = null
|
|
||||||
let editingPresetId = null
|
|
||||||
let deletingPresetId = null
|
|
||||||
|
|
||||||
// DOM Elements
|
|
||||||
const presetList = document.getElementById("preset-list")
|
|
||||||
const addPresetBtn = document.getElementById("add-preset-btn")
|
|
||||||
const presetModal = document.getElementById("preset-modal")
|
|
||||||
const deleteModal = document.getElementById("delete-modal")
|
|
||||||
const presetForm = document.getElementById("preset-form")
|
|
||||||
const modalTitle = document.getElementById("modal-title")
|
|
||||||
const toast = document.getElementById("toast")
|
|
||||||
|
|
||||||
// Form fields
|
|
||||||
const presetIdField = document.getElementById("preset-id")
|
|
||||||
const presetNameField = document.getElementById("preset-name")
|
|
||||||
const aiProviderField = document.getElementById("ai-provider")
|
|
||||||
const aiModelField = document.getElementById("ai-model")
|
|
||||||
const aiApiKeyField = document.getElementById("ai-api-key")
|
|
||||||
const aiBaseUrlField = document.getElementById("ai-base-url")
|
|
||||||
const temperatureField = document.getElementById("temperature")
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
const cancelBtn = document.getElementById("cancel-btn")
|
|
||||||
const saveBtn = document.getElementById("save-btn")
|
|
||||||
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
|
|
||||||
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
|
||||||
await loadPresets()
|
|
||||||
setupEventListeners()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load presets from main process
|
|
||||||
async function loadPresets() {
|
|
||||||
try {
|
|
||||||
presets = await window.settingsAPI.getPresets()
|
|
||||||
currentPresetId = await window.settingsAPI.getCurrentPresetId()
|
|
||||||
renderPresets()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load presets:", error)
|
|
||||||
showToast("Failed to load presets", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render presets list
|
|
||||||
function renderPresets() {
|
|
||||||
if (presets.length === 0) {
|
|
||||||
presetList.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>No presets configured yet.</p>
|
|
||||||
<p>Add a preset to quickly switch between different AI configurations.</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
presetList.innerHTML = presets
|
|
||||||
.map((preset) => {
|
|
||||||
const isActive = preset.id === currentPresetId
|
|
||||||
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
|
|
||||||
<div class="preset-header">
|
|
||||||
<span class="preset-name">${escapeHtml(preset.name)}</span>
|
|
||||||
${isActive ? '<span class="preset-badge">Active</span>' : ""}
|
|
||||||
</div>
|
|
||||||
<div class="preset-info">
|
|
||||||
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
|
|
||||||
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
|
|
||||||
</div>
|
|
||||||
<div class="preset-actions">
|
|
||||||
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
|
|
||||||
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
|
|
||||||
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
|
|
||||||
// Add event listeners to buttons
|
|
||||||
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
applyPreset(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
openEditModal(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
openDeleteModal(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup event listeners
|
|
||||||
function setupEventListeners() {
|
|
||||||
addPresetBtn.addEventListener("click", () => openAddModal())
|
|
||||||
cancelBtn.addEventListener("click", () => closeModal())
|
|
||||||
saveBtn.addEventListener("click", () => savePreset())
|
|
||||||
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
|
|
||||||
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
|
|
||||||
|
|
||||||
// Close modal on overlay click
|
|
||||||
presetModal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === presetModal) closeModal()
|
|
||||||
})
|
|
||||||
deleteModal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === deleteModal) closeDeleteModal()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle Enter key in form
|
|
||||||
presetForm.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
savePreset()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open add modal
|
|
||||||
function openAddModal() {
|
|
||||||
editingPresetId = null
|
|
||||||
modalTitle.textContent = "Add Preset"
|
|
||||||
presetForm.reset()
|
|
||||||
presetIdField.value = ""
|
|
||||||
presetModal.classList.add("show")
|
|
||||||
presetNameField.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
function openEditModal(id) {
|
|
||||||
const preset = presets.find((p) => p.id === id)
|
|
||||||
if (!preset) return
|
|
||||||
|
|
||||||
editingPresetId = id
|
|
||||||
modalTitle.textContent = "Edit Preset"
|
|
||||||
|
|
||||||
presetIdField.value = preset.id
|
|
||||||
presetNameField.value = preset.name
|
|
||||||
aiProviderField.value = preset.config.AI_PROVIDER || ""
|
|
||||||
aiModelField.value = preset.config.AI_MODEL || ""
|
|
||||||
aiApiKeyField.value = preset.config.AI_API_KEY || ""
|
|
||||||
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
|
|
||||||
temperatureField.value = preset.config.TEMPERATURE || ""
|
|
||||||
|
|
||||||
presetModal.classList.add("show")
|
|
||||||
presetNameField.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
function closeModal() {
|
|
||||||
presetModal.classList.remove("show")
|
|
||||||
editingPresetId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open delete modal
|
|
||||||
function openDeleteModal(id) {
|
|
||||||
const preset = presets.find((p) => p.id === id)
|
|
||||||
if (!preset) return
|
|
||||||
|
|
||||||
deletingPresetId = id
|
|
||||||
document.getElementById("delete-preset-name").textContent = preset.name
|
|
||||||
deleteModal.classList.add("show")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close delete modal
|
|
||||||
function closeDeleteModal() {
|
|
||||||
deleteModal.classList.remove("show")
|
|
||||||
deletingPresetId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save preset
|
|
||||||
async function savePreset() {
|
|
||||||
const name = presetNameField.value.trim()
|
|
||||||
if (!name) {
|
|
||||||
showToast("Please enter a preset name", "error")
|
|
||||||
presetNameField.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = {
|
|
||||||
id: editingPresetId || undefined,
|
|
||||||
name: name,
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER: aiProviderField.value || undefined,
|
|
||||||
AI_MODEL: aiModelField.value.trim() || undefined,
|
|
||||||
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
|
|
||||||
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
|
|
||||||
TEMPERATURE: temperatureField.value.trim() || undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove undefined values
|
|
||||||
Object.keys(preset.config).forEach((key) => {
|
|
||||||
if (preset.config[key] === undefined) {
|
|
||||||
delete preset.config[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// closeModal() clears editingPresetId, so remember it for the toast
|
|
||||||
const isEdit = Boolean(editingPresetId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
saveBtn.disabled = true
|
|
||||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
|
||||||
|
|
||||||
await window.settingsAPI.savePreset(preset)
|
|
||||||
await loadPresets()
|
|
||||||
closeModal()
|
|
||||||
showToast(isEdit ? "Preset updated" : "Preset created", "success")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save preset:", error)
|
|
||||||
showToast("Failed to save preset", "error")
|
|
||||||
} finally {
|
|
||||||
saveBtn.disabled = false
|
|
||||||
saveBtn.textContent = "Save"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm delete
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deletingPresetId) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
deleteConfirmBtn.disabled = true
|
|
||||||
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
|
|
||||||
|
|
||||||
await window.settingsAPI.deletePreset(deletingPresetId)
|
|
||||||
await loadPresets()
|
|
||||||
closeDeleteModal()
|
|
||||||
showToast("Preset deleted", "success")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete preset:", error)
|
|
||||||
showToast("Failed to delete preset", "error")
|
|
||||||
} finally {
|
|
||||||
deleteConfirmBtn.disabled = false
|
|
||||||
deleteConfirmBtn.textContent = "Delete"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply preset
|
|
||||||
async function applyPreset(id) {
|
|
||||||
try {
|
|
||||||
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true
|
|
||||||
btn.innerHTML = '<span class="loading"></span>'
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await window.settingsAPI.applyPreset(id)
|
|
||||||
if (result.success) {
|
|
||||||
showToast("Preset applied, server restarting...", "success")
|
|
||||||
} else {
|
|
||||||
showToast(result.error || "Failed to apply preset", "error")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to apply preset:", error)
|
|
||||||
showToast("Failed to apply preset", "error")
|
|
||||||
} finally {
|
|
||||||
// Reload to show the active preset and reset the Apply button
|
|
||||||
await loadPresets()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get provider display label
|
|
||||||
function getProviderLabel(provider) {
|
|
||||||
const labels = {
|
|
||||||
openai: "OpenAI",
|
|
||||||
anthropic: "Anthropic",
|
|
||||||
google: "Google AI",
|
|
||||||
azure: "Azure OpenAI",
|
|
||||||
bedrock: "AWS Bedrock",
|
|
||||||
openrouter: "OpenRouter",
|
|
||||||
deepseek: "DeepSeek",
|
|
||||||
siliconflow: "SiliconFlow",
|
|
||||||
modelscope: "ModelScope",
|
|
||||||
ollama: "Ollama",
|
|
||||||
}
|
|
||||||
return labels[provider] || provider
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show toast notification
|
|
||||||
function showToast(message, type = "") {
|
|
||||||
toast.textContent = message
|
|
||||||
toast.className = "toast show" + (type ? ` ${type}` : "")
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove("show")
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape HTML to prevent XSS
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement("div")
|
|
||||||
div.textContent = text
|
|
||||||
return div.innerHTML
|
|
||||||
}
|
|
||||||
+18
-5
@@ -8,6 +8,7 @@ AI_PROVIDER=bedrock
|
|||||||
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
||||||
# The first one becomes the default and the rest appear in the model picker.
|
# The first one becomes the default and the rest appear in the model picker.
|
||||||
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
||||||
|
# There, apiKeyEnv may also be an array of env var names: each request picks one that has a value at random.
|
||||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
@@ -28,8 +29,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# OpenAI Configuration
|
# OpenAI Configuration
|
||||||
# OPENAI_API_KEY=sk-...
|
# OPENAI_API_KEY=sk-...
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
|
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
|
||||||
# OPENAI_ORGANIZATION=org-... # Optional
|
|
||||||
# OPENAI_PROJECT=proj_... # Optional
|
|
||||||
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
|
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
|
||||||
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
|
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
|
||||||
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||||
@@ -142,6 +141,18 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# ADMIN_PASSWORD=your-admin-password
|
# ADMIN_PASSWORD=your-admin-password
|
||||||
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
||||||
|
|
||||||
|
# Quota & Validation (Optional, also editable in the admin panel)
|
||||||
|
# Quota enforcement is disabled unless DYNAMODB_QUOTA_TABLE is set.
|
||||||
|
# DYNAMODB_QUOTA_TABLE=next-ai-draw-io-quota # DynamoDB table that stores the per-IP counters
|
||||||
|
# DYNAMODB_REGION=ap-northeast-1 # Region of the table
|
||||||
|
# QUOTA_TIMEZONE=UTC # Timezone for the daily reset boundary
|
||||||
|
# DAILY_REQUEST_LIMIT=10 # Requests per IP per day
|
||||||
|
# DAILY_TOKEN_LIMIT=200000 # Tokens per IP per day
|
||||||
|
# TPM_LIMIT=20000 # Tokens per minute
|
||||||
|
# ENABLE_VLM_VALIDATION=true # Visually validate generated diagrams with a vision model
|
||||||
|
# VALIDATION_MODEL= # Falls back to the default AI model when empty
|
||||||
|
# VALIDATION_TIMEOUT=30000 # Validation timeout in ms
|
||||||
|
|
||||||
# Draw.io Configuration (Optional)
|
# Draw.io Configuration (Optional)
|
||||||
# By default the app serves its own copy of draw.io from /drawio (downloaded into
|
# By default the app serves its own copy of draw.io from /drawio (downloaded into
|
||||||
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
|
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
|
||||||
@@ -156,9 +167,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
|
|
||||||
# PDF Input Feature (Optional)
|
# PDF Input Feature (Optional)
|
||||||
# Enable PDF file upload to extract text and generate diagrams
|
# PDF file upload to extract text and generate diagrams
|
||||||
# Enabled by default. Set to "false" to disable.
|
|
||||||
# ENABLE_PDF_INPUT=true
|
|
||||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
||||||
|
|
||||||
# Security Settings (Optional)
|
# Security Settings (Optional)
|
||||||
@@ -184,6 +193,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# It also turns off the MCP server advertisement in Settings > About.
|
# It also turns off the MCP server advertisement in Settings > About.
|
||||||
# NEXT_PUBLIC_SELFHOSTED=true
|
# NEXT_PUBLIC_SELFHOSTED=true
|
||||||
|
|
||||||
|
# Analytics (Optional)
|
||||||
|
# Google Analytics measurement ID; nothing is loaded when unset
|
||||||
|
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||||
|
|
||||||
# Minimax Configuration (Optional)
|
# Minimax Configuration (Optional)
|
||||||
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
||||||
# MINIMAX_API_KEY=your_minimax_api_key
|
# MINIMAX_API_KEY=your_minimax_api_key
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ChatAddToolOutputFunction } from "ai"
|
||||||
import type { RefObject } from "react"
|
import type { RefObject } from "react"
|
||||||
import type { DiagramOperation } from "@/components/chat/types"
|
import type { DiagramOperation } from "@/components/chat/types"
|
||||||
import type {
|
import type {
|
||||||
@@ -5,6 +6,7 @@ import type {
|
|||||||
ValidationStatus,
|
ValidationStatus,
|
||||||
} from "@/components/chat/ValidationCard"
|
} from "@/components/chat/ValidationCard"
|
||||||
import type { LoadMode } from "@/contexts/diagram-context"
|
import type { LoadMode } from "@/contexts/diagram-context"
|
||||||
|
import type { ChatUIMessage } from "@/lib/chat-message"
|
||||||
import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages"
|
import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages"
|
||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||||
@@ -14,57 +16,18 @@ import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
|||||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
|
||||||
|
|
||||||
// The page a drawing is wrapped in; on the canvas it replaces the page the
|
// The page a drawing is wrapped in; on the canvas it replaces the page the
|
||||||
// model works on, so the canvas page keeps its own id and name
|
// model works on, so the canvas page keeps its own id and name
|
||||||
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
||||||
|
|
||||||
/**
|
|
||||||
* A new diagram written without file variables (%name% placeholders) keeps
|
|
||||||
* the canvas file's, as replacing the page in draw.io does
|
|
||||||
*/
|
|
||||||
export function keepFileVars(xml: string, canvasXml: string): string {
|
|
||||||
const varsOf = (doc: Document) =>
|
|
||||||
doc.documentElement?.nodeName === "mxfile"
|
|
||||||
? doc.documentElement.getAttribute("vars")
|
|
||||||
: null
|
|
||||||
const parser = new DOMParser()
|
|
||||||
const canvasVars = varsOf(parser.parseFromString(canvasXml, "text/xml"))
|
|
||||||
if (!canvasVars) return xml
|
|
||||||
const doc = parser.parseFromString(xml, "text/xml")
|
|
||||||
if (doc.documentElement?.nodeName !== "mxfile" || varsOf(doc) !== null) {
|
|
||||||
return xml
|
|
||||||
}
|
|
||||||
doc.documentElement.setAttribute("vars", canvasVars)
|
|
||||||
return new XMLSerializer().serializeToString(doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolCall {
|
interface ToolCall {
|
||||||
toolCallId: string
|
toolCallId: string
|
||||||
toolName: string
|
toolName: string
|
||||||
input: unknown
|
input: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddToolOutputSuccess = {
|
// useChat's addToolOutput, typed with the chat's tools
|
||||||
tool: string
|
type AddToolOutputFn = ChatAddToolOutputFunction<ChatUIMessage>
|
||||||
toolCallId: string
|
|
||||||
state?: "output-available"
|
|
||||||
output: string
|
|
||||||
errorText?: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddToolOutputError = {
|
|
||||||
tool: string
|
|
||||||
toolCallId: string
|
|
||||||
state: "output-error"
|
|
||||||
output?: undefined
|
|
||||||
errorText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
|
||||||
|
|
||||||
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
|
||||||
|
|
||||||
const MAX_VALIDATION_RETRIES = 3
|
const MAX_VALIDATION_RETRIES = 3
|
||||||
|
|
||||||
@@ -157,12 +120,6 @@ export function useDiagramToolHandlers({
|
|||||||
{ toolCall }: { toolCall: ToolCall },
|
{ toolCall }: { toolCall: ToolCall },
|
||||||
addToolOutput: AddToolOutputFn,
|
addToolOutput: AddToolOutputFn,
|
||||||
) => {
|
) => {
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
processedToolCallsRef.current.add(toolCall.toolCallId)
|
processedToolCallsRef.current.add(toolCall.toolCallId)
|
||||||
// Only display_diagram, edit_diagram and a completing append_diagram
|
// Only display_diagram, edit_diagram and a completing append_diagram
|
||||||
// put their result on the canvas. Other tools (get_shape_library,
|
// put their result on the canvas. Other tools (get_shape_library,
|
||||||
@@ -204,12 +161,9 @@ export function useDiagramToolHandlers({
|
|||||||
|
|
||||||
// A drawn page goes onto the model's page of the canvas file, which
|
// A drawn page goes onto the model's page of the canvas file, which
|
||||||
// keeps its other pages; a drawing that brings several pages replaces
|
// keeps its other pages; a drawing that brings several pages replaces
|
||||||
// the file, and gets its variables
|
// the file. Either way it keeps the file's variables unless it sets its own.
|
||||||
const onAiPage = (drawnXml: string) =>
|
const onAiPage = (drawnXml: string) =>
|
||||||
keepFileVars(
|
placeOnPage(drawnXml, chartXMLRef.current, aiPageId())
|
||||||
placeOnPage(drawnXml, chartXMLRef.current, aiPageId()),
|
|
||||||
chartXMLRef.current,
|
|
||||||
)
|
|
||||||
|
|
||||||
const takeOriginals = (): string | undefined => {
|
const takeOriginals = (): string | undefined => {
|
||||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||||
@@ -228,20 +182,8 @@ export function useDiagramToolHandlers({
|
|||||||
) => {
|
) => {
|
||||||
const { xml } = toolCall.input as { xml: string }
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
// DEBUG: Log raw input to diagnose false truncation detection
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] XML ending (last 100 chars):",
|
|
||||||
xml.slice(-100),
|
|
||||||
)
|
|
||||||
console.log("[display_diagram] XML length:", xml.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||||
const isTruncated = !isMxCellXmlComplete(xml)
|
const isTruncated = !isMxCellXmlComplete(xml)
|
||||||
if (DEBUG) {
|
|
||||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTruncated) {
|
if (isTruncated) {
|
||||||
// Store the partial XML for continuation via append_diagram
|
// Store the partial XML for continuation via append_diagram
|
||||||
@@ -298,11 +240,6 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
|||||||
// keeps the diagram from before this failed call
|
// keeps the diagram from before this failed call
|
||||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Adding tool output with state: output-error",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "display_diagram",
|
tool: "display_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
@@ -318,12 +255,6 @@ ${finalXml}
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Success - diagram will be rendered by chat-message-display
|
// Success - diagram will be rendered by chat-message-display
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VLM validation after successful display
|
// VLM validation after successful display
|
||||||
if (
|
if (
|
||||||
enableVlmValidation &&
|
enableVlmValidation &&
|
||||||
@@ -348,12 +279,6 @@ ${finalXml}
|
|||||||
if (stopped?.()) {
|
if (stopped?.()) {
|
||||||
updateValidationState(toolCall.toolCallId, "skipped")
|
updateValidationState(toolCall.toolCallId, "skipped")
|
||||||
} else if (capturedPngData) {
|
} else if (capturedPngData) {
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Captured PNG for validation",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each retry is a new tool call, so count attempts
|
// Each retry is a new tool call, so count attempts
|
||||||
// per user turn (the chat resets it when the user sends)
|
// per user turn (the chat resets it when the user sends)
|
||||||
const attempt = validationRetryCountRef.current + 1
|
const attempt = validationRetryCountRef.current + 1
|
||||||
@@ -379,12 +304,6 @@ ${finalXml}
|
|||||||
if (attempt < MAX_VALIDATION_RETRIES) {
|
if (attempt < MAX_VALIDATION_RETRIES) {
|
||||||
const feedback =
|
const feedback =
|
||||||
formatValidationFeedback(result)
|
formatValidationFeedback(result)
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
|
|
||||||
result.issues,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify UI of validation failure (include the image)
|
// Notify UI of validation failure (include the image)
|
||||||
updateValidationState(
|
updateValidationState(
|
||||||
@@ -407,11 +326,6 @@ ${finalXml}
|
|||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// Last attempt - accept the diagram with warning
|
// Last attempt - accept the diagram with warning
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Notify UI that we're accepting with issues (include the image)
|
// Notify UI that we're accepting with issues (include the image)
|
||||||
updateValidationState(
|
updateValidationState(
|
||||||
toolCall.toolCallId,
|
toolCall.toolCallId,
|
||||||
@@ -427,12 +341,6 @@ ${finalXml}
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Validation passed!",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify UI of success (include the image)
|
// Notify UI of success (include the image)
|
||||||
// Use "success_with_warnings" if valid but has issues
|
// Use "success_with_warnings" if valid but has issues
|
||||||
const hasWarnings = result.issues.length > 0
|
const hasWarnings = result.issues.length > 0
|
||||||
@@ -474,21 +382,11 @@ ${finalXml}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Adding tool output with state: output-available",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
addToolOutput({
|
addToolOutput({
|
||||||
tool: "display_diagram",
|
tool: "display_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
output: "Successfully displayed the diagram.",
|
output: "Successfully displayed the diagram.",
|
||||||
})
|
})
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,5 +25,3 @@ export function useDictionary() {
|
|||||||
}
|
}
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useDictionary
|
|
||||||
|
|||||||
@@ -18,56 +18,6 @@ import {
|
|||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
|
|
||||||
// Old storage keys for migration
|
|
||||||
const OLD_KEYS = {
|
|
||||||
aiProvider: "next-ai-draw-io-ai-provider",
|
|
||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
|
||||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
|
||||||
aiModel: "next-ai-draw-io-ai-model",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate from old single-provider format to new multi-model format
|
|
||||||
*/
|
|
||||||
function migrateOldConfig(): MultiModelConfig | null {
|
|
||||||
if (typeof window === "undefined") return null
|
|
||||||
|
|
||||||
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
|
|
||||||
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
|
|
||||||
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
|
|
||||||
|
|
||||||
// No old config to migrate. A local Ollama needs no key.
|
|
||||||
if (!oldProvider || !oldModel) return null
|
|
||||||
if (!oldApiKey && oldProvider !== "ollama") return null
|
|
||||||
|
|
||||||
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
|
|
||||||
|
|
||||||
// Create new config from old format
|
|
||||||
const provider = createProviderConfig(oldProvider as ProviderName)
|
|
||||||
provider.apiKey = oldApiKey ?? ""
|
|
||||||
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
|
|
||||||
// Without one, the old version used the server's Ollama (OLLAMA_BASE_URL
|
|
||||||
// or this machine), not the cloud address new providers get
|
|
||||||
else if (oldProvider === "ollama") provider.baseUrl = ""
|
|
||||||
|
|
||||||
const model = createModelConfig(oldModel)
|
|
||||||
provider.models.push(model)
|
|
||||||
|
|
||||||
const config: MultiModelConfig = {
|
|
||||||
version: 1,
|
|
||||||
providers: [provider],
|
|
||||||
selectedModelId: model.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear old keys after migration
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiProvider)
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiApiKey)
|
|
||||||
localStorage.removeItem(OLD_KEYS.aiModel)
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
const isKnownProvider = (p: { provider: string }) =>
|
const isKnownProvider = (p: { provider: string }) =>
|
||||||
Object.hasOwn(PROVIDER_INFO, p.provider)
|
Object.hasOwn(PROVIDER_INFO, p.provider)
|
||||||
|
|
||||||
@@ -96,7 +46,6 @@ function parseStoredConfig(stored: string): MultiModelConfig {
|
|||||||
function loadConfig(): MultiModelConfig {
|
function loadConfig(): MultiModelConfig {
|
||||||
if (typeof window === "undefined") return createEmptyConfig()
|
if (typeof window === "undefined") return createEmptyConfig()
|
||||||
|
|
||||||
// First, check if new format exists
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
@@ -106,17 +55,6 @@ function loadConfig(): MultiModelConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try migration from old format
|
|
||||||
const migrated = migrateOldConfig()
|
|
||||||
if (migrated) {
|
|
||||||
// Save migrated config
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.modelConfigs,
|
|
||||||
JSON.stringify(migrated),
|
|
||||||
)
|
|
||||||
return migrated
|
|
||||||
}
|
|
||||||
|
|
||||||
return createEmptyConfig()
|
return createEmptyConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +101,6 @@ export interface UseModelConfigReturn {
|
|||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
models: FlattenedModel[]
|
models: FlattenedModel[]
|
||||||
selectedModel: FlattenedModel | undefined
|
|
||||||
selectedModelId: string | undefined
|
selectedModelId: string | undefined
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -181,7 +118,6 @@ export interface UseModelConfigReturn {
|
|||||||
updates: Partial<ModelConfig>,
|
updates: Partial<ModelConfig>,
|
||||||
) => void
|
) => void
|
||||||
deleteModel: (providerId: string, modelConfigId: string) => void
|
deleteModel: (providerId: string, modelConfigId: string) => void
|
||||||
resetConfig: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModelConfig(): UseModelConfigReturn {
|
export function useModelConfig(): UseModelConfigReturn {
|
||||||
@@ -281,7 +217,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
modelId: m.modelId,
|
modelId: m.modelId,
|
||||||
provider: m.provider,
|
provider: m.provider,
|
||||||
providerLabel: `Server · ${m.providerLabel}`,
|
providerLabel: m.providerLabel,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
baseUrl: undefined,
|
baseUrl: undefined,
|
||||||
awsAccessKeyId: undefined,
|
awsAccessKeyId: undefined,
|
||||||
@@ -291,17 +227,11 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
validated: true,
|
validated: true,
|
||||||
source: "server" as const,
|
source: "server" as const,
|
||||||
isDefault: m.isDefault,
|
isDefault: m.isDefault,
|
||||||
apiKeyEnv: m.apiKeyEnv,
|
|
||||||
baseUrlEnv: m.baseUrlEnv,
|
|
||||||
})),
|
})),
|
||||||
// User models from local configuration
|
// User models from local configuration
|
||||||
...userModels,
|
...userModels,
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedModel = config.selectedModelId
|
|
||||||
? models.find((m) => m.id === config.selectedModelId)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const setSelectedModelId = useCallback((modelId: string | undefined) => {
|
const setSelectedModelId = useCallback((modelId: string | undefined) => {
|
||||||
setConfig((prev) => ({
|
setConfig((prev) => ({
|
||||||
@@ -426,15 +356,10 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
[serverModels],
|
[serverModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetConfig = useCallback(() => {
|
|
||||||
setConfig(createEmptyConfig())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
isLoaded: isLoaded && serverLoaded,
|
isLoaded: isLoaded && serverLoaded,
|
||||||
models,
|
models,
|
||||||
selectedModel,
|
|
||||||
selectedModelId: config.selectedModelId,
|
selectedModelId: config.selectedModelId,
|
||||||
setSelectedModelId,
|
setSelectedModelId,
|
||||||
addProvider,
|
addProvider,
|
||||||
@@ -443,7 +368,6 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
addModel,
|
addModel,
|
||||||
updateModel,
|
updateModel,
|
||||||
deleteModel,
|
deleteModel,
|
||||||
resetConfig,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,24 +415,7 @@ export function getSelectedAIConfig(): {
|
|||||||
|
|
||||||
// Load multi-model config
|
// Load multi-model config
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||||
if (!stored) {
|
if (!stored) return { ...empty, accessCode }
|
||||||
// Fallback to old format for backward compatibility
|
|
||||||
return {
|
|
||||||
accessCode,
|
|
||||||
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
|
|
||||||
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
|
|
||||||
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
|
|
||||||
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
|
|
||||||
// Old format didn't support AWS
|
|
||||||
awsAccessKeyId: "",
|
|
||||||
awsSecretAccessKey: "",
|
|
||||||
awsRegion: "",
|
|
||||||
awsSessionToken: "",
|
|
||||||
selectedModelId: "",
|
|
||||||
vertexApiKey: "",
|
|
||||||
providerId: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let config: MultiModelConfig
|
let config: MultiModelConfig
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
getAllSessionMetadata,
|
getAllSessionMetadata,
|
||||||
getSession,
|
getSession,
|
||||||
isIndexedDBAvailable,
|
isIndexedDBAvailable,
|
||||||
migrateFromLocalStorage,
|
|
||||||
readSessionCount,
|
readSessionCount,
|
||||||
renameSession as renameSessionInDB,
|
renameSession as renameSessionInDB,
|
||||||
type SessionMetadata,
|
type SessionMetadata,
|
||||||
@@ -20,10 +19,7 @@ import {
|
|||||||
saveSession,
|
saveSession,
|
||||||
} from "@/lib/session-storage"
|
} from "@/lib/session-storage"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import {
|
import type { DiagramVersion } from "@/stores/versions-store"
|
||||||
type DiagramVersion,
|
|
||||||
versionsFromLegacyHistory,
|
|
||||||
} from "@/stores/versions-store"
|
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
messages: StoredMessage[]
|
messages: StoredMessage[]
|
||||||
@@ -35,13 +31,6 @@ export interface SessionData {
|
|||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Versions of a stored session, converting the old history format */
|
|
||||||
export function getSessionVersions(
|
|
||||||
session: Pick<ChatSession, "versions" | "diagramHistory">,
|
|
||||||
): DiagramVersion[] {
|
|
||||||
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taken right before a save's data is read: the chat on screen then, and
|
// Taken right before a save's data is read: the chat on screen then, and
|
||||||
// the order of the reads
|
// the order of the reads
|
||||||
export interface SaveTicket {
|
export interface SaveTicket {
|
||||||
@@ -192,9 +181,6 @@ export function useSessionManager(
|
|||||||
setIsAvailable(true)
|
setIsAvailable(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Run migration first (one-time conversion from localStorage)
|
|
||||||
await migrateFromLocalStorage()
|
|
||||||
|
|
||||||
// Load sessions list
|
// Load sessions list
|
||||||
const metadata = await getAllSessionMetadata()
|
const metadata = await getAllSessionMetadata()
|
||||||
setSessions(metadata)
|
setSessions(metadata)
|
||||||
@@ -334,7 +320,7 @@ export function useSessionManager(
|
|||||||
xmlSnapshots: session.xmlSnapshots,
|
xmlSnapshots: session.xmlSnapshots,
|
||||||
diagramXml: session.diagramXml,
|
diagramXml: session.diagramXml,
|
||||||
thumbnailDataUrl: session.thumbnailDataUrl,
|
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||||
versions: getSessionVersions(session),
|
versions: session.versions ?? [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentSessionId, changeChat],
|
[currentSessionId, changeChat],
|
||||||
@@ -417,9 +403,7 @@ export function useSessionManager(
|
|||||||
thumbnailDataUrl:
|
thumbnailDataUrl:
|
||||||
data.thumbnailDataUrl ??
|
data.thumbnailDataUrl ??
|
||||||
currentSession.thumbnailDataUrl,
|
currentSession.thumbnailDataUrl,
|
||||||
versions:
|
versions: data.versions ?? currentSession.versions ?? [],
|
||||||
data.versions ?? getSessionVersions(currentSession),
|
|
||||||
diagramHistory: undefined,
|
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
// Update title if it's still default and we have messages
|
// Update title if it's still default and we have messages
|
||||||
title:
|
title:
|
||||||
|
|||||||
+42
-112
@@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for VLM-based diagram validation using AI SDK's useObject.
|
* Hook for VLM-based diagram validation: one request, one JSON result.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { experimental_useObject as useObject } from "@ai-sdk/react"
|
|
||||||
import { useCallback, useRef } from "react"
|
import { useCallback, useRef } from "react"
|
||||||
import { getSelectedAIConfig } from "@/hooks/use-model-config"
|
import { getSelectedAIConfig } from "@/hooks/use-model-config"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
@@ -13,8 +12,6 @@ import {
|
|||||||
ValidationResultSchema,
|
ValidationResultSchema,
|
||||||
} from "@/lib/validation-schema"
|
} from "@/lib/validation-schema"
|
||||||
|
|
||||||
export type { ValidationResult }
|
|
||||||
|
|
||||||
// Default valid result for fallback cases
|
// Default valid result for fallback cases
|
||||||
const DEFAULT_VALID_RESULT: ValidationResult = {
|
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||||
valid: true,
|
valid: true,
|
||||||
@@ -22,111 +19,42 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
|||||||
suggestions: [],
|
suggestions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseValidateDiagramOptions {
|
export function useValidateDiagram() {
|
||||||
onSuccess?: (result: ValidationResult) => void
|
// The running check's controller; a new check or cancel() aborts it
|
||||||
onError?: (error: Error) => void
|
const controllerRef = useRef<AbortController | null>(null)
|
||||||
}
|
|
||||||
|
|
||||||
// Track pending validation promises for imperative API
|
|
||||||
type PendingValidation = {
|
|
||||||
resolve: (result: ValidationResult) => void
|
|
||||||
reject: (error: Error) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|
||||||
const { onSuccess, onError } = options
|
|
||||||
const pendingValidationRef = useRef<PendingValidation | null>(null)
|
|
||||||
|
|
||||||
const { object, submit, isLoading, error, stop } = useObject({
|
|
||||||
api: getApiEndpoint("/api/validate-diagram"),
|
|
||||||
schema: ValidationResultSchema,
|
|
||||||
// Resolved per request so a changed access code is picked up
|
|
||||||
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
|
|
||||||
onFinish: ({
|
|
||||||
object,
|
|
||||||
error: finishError,
|
|
||||||
}: {
|
|
||||||
object: ValidationResult | undefined
|
|
||||||
error: Error | undefined
|
|
||||||
}) => {
|
|
||||||
if (finishError) {
|
|
||||||
console.error(
|
|
||||||
"[useValidateDiagram] Validation error:",
|
|
||||||
finishError,
|
|
||||||
)
|
|
||||||
onError?.(finishError)
|
|
||||||
pendingValidationRef.current?.reject(finishError)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (object) {
|
|
||||||
const result = object as ValidationResult
|
|
||||||
onSuccess?.(result)
|
|
||||||
pendingValidationRef.current?.resolve(result)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
console.error("[useValidateDiagram] Stream error:", err)
|
|
||||||
onError?.(err)
|
|
||||||
pendingValidationRef.current?.reject(err)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a diagram image.
|
* Validate a diagram image, with fallback: a failed check gives the
|
||||||
* Returns a promise that resolves with the validation result.
|
* default valid result so it never blocks the user. A cancelled check
|
||||||
*/
|
* is passed on as its AbortError.
|
||||||
const validate = useCallback(
|
|
||||||
async (
|
|
||||||
imageData: string,
|
|
||||||
sessionId?: string,
|
|
||||||
): Promise<ValidationResult> => {
|
|
||||||
// Reject any pending validation to prevent promise leaks
|
|
||||||
if (pendingValidationRef.current) {
|
|
||||||
pendingValidationRef.current.reject(
|
|
||||||
new Error("Validation superseded by new request"),
|
|
||||||
)
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Store the promise handlers
|
|
||||||
pendingValidationRef.current = { resolve, reject }
|
|
||||||
|
|
||||||
// Submit the validation request
|
|
||||||
submit({ imageData, sessionId })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[submit],
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End a running check (the user pressed Stop): its promise rejects with
|
|
||||||
* an AbortError, so the tool handler can finish at once.
|
|
||||||
*/
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
const pending = pendingValidationRef.current
|
|
||||||
if (!pending) return
|
|
||||||
pendingValidationRef.current = null
|
|
||||||
stop()
|
|
||||||
pending.reject(new DOMException("Validation cancelled", "AbortError"))
|
|
||||||
}, [stop])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate with fallback - returns default valid result on error.
|
|
||||||
* Use this to avoid blocking the user on validation failures.
|
|
||||||
* A cancelled check is passed on as its AbortError.
|
|
||||||
*/
|
*/
|
||||||
const validateWithFallback = useCallback(
|
const validateWithFallback = useCallback(
|
||||||
async (
|
async (
|
||||||
imageData: string,
|
imageData: string,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
): Promise<ValidationResult> => {
|
): Promise<ValidationResult> => {
|
||||||
|
controllerRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllerRef.current = controller
|
||||||
try {
|
try {
|
||||||
return await validate(imageData, sessionId)
|
const res = await fetch(
|
||||||
|
getApiEndpoint("/api/validate-diagram"),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// Read per request so a changed access code is
|
||||||
|
// picked up
|
||||||
|
"x-access-code": getSelectedAIConfig().accessCode,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ imageData, sessionId }),
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Validation request failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
return ValidationResultSchema.parse(await res.json())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as Error)?.name === "AbortError") throw error
|
if ((error as Error)?.name === "AbortError") throw error
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -134,21 +62,23 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
|||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
return DEFAULT_VALID_RESULT
|
return DEFAULT_VALID_RESULT
|
||||||
|
} finally {
|
||||||
|
if (controllerRef.current === controller) {
|
||||||
|
controllerRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[validate],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
/**
|
||||||
// Validation functions
|
* End a running check (the user pressed Stop): its promise rejects with
|
||||||
validate,
|
* an AbortError, so the tool handler can finish at once.
|
||||||
validateWithFallback,
|
*/
|
||||||
stop,
|
const cancel = useCallback(() => {
|
||||||
cancel,
|
controllerRef.current?.abort()
|
||||||
|
controllerRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
// State
|
return { validateWithFallback, cancel }
|
||||||
isValidating: isLoading,
|
|
||||||
partialResult: object as ValidationResult | undefined,
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function loadAdminProviders(): StoredAdminProvider[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MaskedAdminProvider = Omit<
|
type MaskedAdminProvider = Omit<
|
||||||
StoredAdminProvider,
|
StoredAdminProvider,
|
||||||
(typeof SECRET_FIELDS)[number]
|
(typeof SECRET_FIELDS)[number]
|
||||||
> & {
|
> & {
|
||||||
@@ -278,15 +278,9 @@ export function deriveEnvUpdates(
|
|||||||
return updates
|
return updates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every settings key the panel may have written for a given list.
|
// Every settings key the panel may have written for a given list
|
||||||
// AI_MODELS_CONFIG is included to clean up values written by older
|
|
||||||
// versions of the panel (it is no longer written).
|
|
||||||
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
||||||
const keys = new Set<string>([
|
const keys = new Set<string>(["AI_PROVIDER", "AI_MODEL"])
|
||||||
"AI_MODELS_CONFIG",
|
|
||||||
"AI_PROVIDER",
|
|
||||||
"AI_MODEL",
|
|
||||||
])
|
|
||||||
const indexByProvider = new Map<ProviderName, number>()
|
const indexByProvider = new Map<ProviderName, number>()
|
||||||
for (const p of list) {
|
for (const p of list) {
|
||||||
const index = indexByProvider.get(p.provider) ?? 0
|
const index = indexByProvider.get(p.provider) ?? 0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
|
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
|
||||||
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
|
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
|
||||||
|
|
||||||
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
|
type SettingType = "string" | "secret" | "number" | "boolean"
|
||||||
|
|
||||||
export interface SettingDef {
|
export interface SettingDef {
|
||||||
key: string
|
key: string
|
||||||
@@ -18,7 +18,6 @@ export interface SettingDef {
|
|||||||
type: SettingType
|
type: SettingType
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
options?: string[]
|
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
@@ -123,13 +122,6 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
|
|||||||
label: "Validation Timeout (ms)",
|
label: "Validation Timeout (ms)",
|
||||||
min: 1000,
|
min: 1000,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "ENABLE_HISTORY_XML_REPLACE",
|
|
||||||
group: "features",
|
|
||||||
type: "boolean",
|
|
||||||
label: "History XML Compression",
|
|
||||||
description: "Replace old diagram XML in history with placeholders.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "ALLOW_PRIVATE_URLS",
|
key: "ALLOW_PRIVATE_URLS",
|
||||||
group: "features",
|
group: "features",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ globalState.__adminSettingsState ??= {
|
|||||||
}
|
}
|
||||||
const state = globalState.__adminSettingsState
|
const state = globalState.__adminSettingsState
|
||||||
|
|
||||||
export function getSettingsPath(): string {
|
function getSettingsPath(): string {
|
||||||
const custom = process.env.SETTINGS_FILE
|
const custom = process.env.SETTINGS_FILE
|
||||||
if (custom && custom.trim().length > 0) return custom
|
if (custom && custom.trim().length > 0) return custom
|
||||||
return path.join(process.cwd(), "data", "settings.json")
|
return path.join(process.cwd(), "data", "settings.json")
|
||||||
|
|||||||
+68
-118
@@ -30,9 +30,7 @@ import {
|
|||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "@/lib/types/model-config"
|
} from "@/lib/types/model-config"
|
||||||
|
|
||||||
export type { ProviderName }
|
const AIHUBMIX_APP_CODE = "MSBS9675"
|
||||||
|
|
||||||
export const AIHUBMIX_APP_CODE = "MSBS9675"
|
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
@@ -56,7 +54,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
|||||||
* Normalize MiniMax base URL for AI SDK compatibility.
|
* Normalize MiniMax base URL for AI SDK compatibility.
|
||||||
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
|
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
|
||||||
*/
|
*/
|
||||||
export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
function normalizeMiniMaxBaseURL(rawUrl: string): {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
isAnthropicCompatible: boolean
|
isAnthropicCompatible: boolean
|
||||||
} {
|
} {
|
||||||
@@ -129,21 +127,19 @@ const BEDROCK_ANTHROPIC_BETA = {
|
|||||||
* @param userApiKey - User-provided API key (if any)
|
* @param userApiKey - User-provided API key (if any)
|
||||||
* @param userBaseUrl - User-provided base URL (if any)
|
* @param userBaseUrl - User-provided base URL (if any)
|
||||||
* @param serverBaseUrl - Server's base URL from environment variable
|
* @param serverBaseUrl - Server's base URL from environment variable
|
||||||
* @param defaultBaseUrl - Provider's official/default base URL (optional)
|
|
||||||
* @returns The resolved base URL to use
|
* @returns The resolved base URL to use
|
||||||
*/
|
*/
|
||||||
export function resolveBaseURL(
|
export function resolveBaseURL(
|
||||||
userApiKey: string | null | undefined,
|
userApiKey: string | null | undefined,
|
||||||
userBaseUrl: string | null | undefined,
|
userBaseUrl: string | null | undefined,
|
||||||
serverBaseUrl: string | undefined,
|
serverBaseUrl: string | undefined,
|
||||||
defaultBaseUrl?: string,
|
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (userApiKey) {
|
if (userApiKey) {
|
||||||
// User provides their own API key - only use user's baseUrl or default
|
// User provides their own API key - only use user's baseUrl
|
||||||
return userBaseUrl || defaultBaseUrl || undefined
|
return userBaseUrl || undefined
|
||||||
}
|
}
|
||||||
// No user API key - fall back to server config
|
// No user API key - fall back to server config
|
||||||
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
|
return userBaseUrl || serverBaseUrl || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,6 +240,39 @@ function googleSamplingSettings(): { topK?: number; topP?: number } {
|
|||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini 2.5/3 models think by default, but need includeThoughts: true to
|
||||||
|
* return the reasoning. The budget applies to Gemini 2.5, the level to
|
||||||
|
* Gemini 3. Shared by Google AI and Vertex AI, which read different variables.
|
||||||
|
*/
|
||||||
|
function geminiThinkingOptions(
|
||||||
|
modelId: string | undefined,
|
||||||
|
budgetEnvName: string,
|
||||||
|
levelEnvName: string,
|
||||||
|
): { thinkingConfig: Record<string, any> } | undefined {
|
||||||
|
if (!modelId || !/gemini-?[23]/.test(modelId)) return undefined
|
||||||
|
|
||||||
|
const thinkingConfig: Record<string, any> = { includeThoughts: true }
|
||||||
|
const budget = parseIntSafe(
|
||||||
|
process.env[budgetEnvName],
|
||||||
|
budgetEnvName,
|
||||||
|
1024,
|
||||||
|
100000,
|
||||||
|
)
|
||||||
|
const level = process.env[levelEnvName] as
|
||||||
|
| "minimal"
|
||||||
|
| "low"
|
||||||
|
| "medium"
|
||||||
|
| "high"
|
||||||
|
| undefined
|
||||||
|
if (budget && (modelId.includes("2.5") || modelId.includes("2-5"))) {
|
||||||
|
thinkingConfig.thinkingBudget = budget
|
||||||
|
} else if (level && /gemini-?3/.test(modelId)) {
|
||||||
|
thinkingConfig.thinkingLevel = level
|
||||||
|
}
|
||||||
|
return { thinkingConfig }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build provider-specific options from environment variables
|
* Build provider-specific options from environment variables
|
||||||
* Supports various AI SDK providers with their unique configuration options
|
* Supports various AI SDK providers with their unique configuration options
|
||||||
@@ -271,41 +300,27 @@ function buildProviderOptions(
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "openai": {
|
case "openai": {
|
||||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT as
|
||||||
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
| "minimal"
|
||||||
|
| "low"
|
||||||
|
| "medium"
|
||||||
|
| "high"
|
||||||
|
| undefined
|
||||||
// Reasoning models (the o-series, gpt-5 and later) need
|
// Reasoning models (the o-series, gpt-5 and later) need
|
||||||
// reasoningSummary to return thoughts
|
// reasoningSummary to return thoughts; 'auto' as the default
|
||||||
if (modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)) {
|
// since not all models support 'detailed'
|
||||||
options.openai = {
|
const isReasoning =
|
||||||
// Auto-enable reasoning summary for reasoning models
|
!!modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)
|
||||||
// Use 'auto' as default since not all models support 'detailed'
|
const reasoningSummary =
|
||||||
reasoningSummary:
|
(process.env.OPENAI_REASONING_SUMMARY as
|
||||||
(reasoningSummary as "auto" | "detailed") || "auto",
|
| "auto"
|
||||||
}
|
| "detailed"
|
||||||
|
| undefined) || (isReasoning ? "auto" : undefined)
|
||||||
|
|
||||||
// Optionally configure reasoning effort
|
if (reasoningSummary || reasoningEffort) {
|
||||||
if (reasoningEffort) {
|
options.openai = {
|
||||||
options.openai.reasoningEffort = reasoningEffort as
|
...(reasoningSummary && { reasoningSummary }),
|
||||||
| "minimal"
|
...(reasoningEffort && { reasoningEffort }),
|
||||||
| "low"
|
|
||||||
| "medium"
|
|
||||||
| "high"
|
|
||||||
}
|
|
||||||
} else if (reasoningEffort || reasoningSummary) {
|
|
||||||
// Non-reasoning models: only apply if explicitly configured
|
|
||||||
options.openai = {}
|
|
||||||
if (reasoningEffort) {
|
|
||||||
options.openai.reasoningEffort = reasoningEffort as
|
|
||||||
| "minimal"
|
|
||||||
| "low"
|
|
||||||
| "medium"
|
|
||||||
| "high"
|
|
||||||
}
|
|
||||||
if (reasoningSummary) {
|
|
||||||
options.openai.reasoningSummary = reasoningSummary as
|
|
||||||
| "auto"
|
|
||||||
| "detailed"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -333,85 +348,21 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "google": {
|
case "google": {
|
||||||
const thinkingBudgetVal = parseIntSafe(
|
const google = geminiThinkingOptions(
|
||||||
process.env.GOOGLE_THINKING_BUDGET,
|
modelId,
|
||||||
"GOOGLE_THINKING_BUDGET",
|
"GOOGLE_THINKING_BUDGET",
|
||||||
1024,
|
"GOOGLE_THINKING_LEVEL",
|
||||||
100000,
|
|
||||||
)
|
)
|
||||||
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL
|
if (google) options.google = google
|
||||||
|
|
||||||
// Google Gemini 2.5/3 models think by default, but need includeThoughts: true
|
|
||||||
// to return the reasoning in the response
|
|
||||||
if (
|
|
||||||
modelId &&
|
|
||||||
(modelId.includes("gemini-2") ||
|
|
||||||
modelId.includes("gemini-3") ||
|
|
||||||
modelId.includes("gemini2") ||
|
|
||||||
modelId.includes("gemini3"))
|
|
||||||
) {
|
|
||||||
const thinkingConfig: Record<string, any> = {
|
|
||||||
includeThoughts: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally configure thinking budget or level
|
|
||||||
if (
|
|
||||||
thinkingBudgetVal &&
|
|
||||||
(modelId.includes("2.5") || modelId.includes("2-5"))
|
|
||||||
) {
|
|
||||||
thinkingConfig.thinkingBudget = thinkingBudgetVal
|
|
||||||
} else if (
|
|
||||||
thinkingLevel &&
|
|
||||||
(modelId.includes("gemini-3") ||
|
|
||||||
modelId.includes("gemini3"))
|
|
||||||
) {
|
|
||||||
thinkingConfig.thinkingLevel = thinkingLevel as
|
|
||||||
| "low"
|
|
||||||
| "high"
|
|
||||||
}
|
|
||||||
|
|
||||||
options.google = { thinkingConfig }
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vertexai": {
|
case "vertexai": {
|
||||||
const thinkingBudget = parseIntSafe(
|
const google = geminiThinkingOptions(
|
||||||
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
|
modelId,
|
||||||
"GOOGLE_VERTEX_THINKING_BUDGET",
|
"GOOGLE_VERTEX_THINKING_BUDGET",
|
||||||
1024,
|
"GOOGLE_VERTEX_THINKING_LEVEL",
|
||||||
100000,
|
|
||||||
)
|
)
|
||||||
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
|
if (google) options.google = google
|
||||||
|
|
||||||
if (
|
|
||||||
modelId &&
|
|
||||||
(modelId.includes("gemini-2") ||
|
|
||||||
modelId.includes("gemini-3") ||
|
|
||||||
modelId.includes("gemini2") ||
|
|
||||||
modelId.includes("gemini3"))
|
|
||||||
) {
|
|
||||||
const thinkingConfig: Record<string, any> = {
|
|
||||||
includeThoughts: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGemini3 =
|
|
||||||
modelId?.includes("gemini-3") ||
|
|
||||||
modelId?.includes("gemini3")
|
|
||||||
const isGemini25 =
|
|
||||||
modelId?.includes("2.5") || modelId?.includes("2-5")
|
|
||||||
|
|
||||||
if (isGemini3 && thinkingLevel) {
|
|
||||||
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
|
|
||||||
thinkingConfig.thinkingLevel = thinkingLevel as
|
|
||||||
| "minimal"
|
|
||||||
| "low"
|
|
||||||
| "medium"
|
|
||||||
| "high"
|
|
||||||
} else if (isGemini25 && thinkingBudget) {
|
|
||||||
thinkingConfig.thinkingBudget = thinkingBudget
|
|
||||||
}
|
|
||||||
options.google = { thinkingConfig }
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "azure": {
|
case "azure": {
|
||||||
@@ -498,7 +449,7 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map of provider to required environment variable
|
// Map of provider to required environment variable
|
||||||
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
@@ -1033,8 +984,7 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
|
|||||||
// Like other providers, a user's key never goes to the server's
|
// Like other providers, a user's key never goes to the server's
|
||||||
// base URL: without a URL of their own it goes to Ollama Cloud.
|
// base URL: without a URL of their own it goes to Ollama Cloud.
|
||||||
// The server's key goes to OLLAMA_BASE_URL (or a server model's
|
// The server's key goes to OLLAMA_BASE_URL (or a server model's
|
||||||
// own variable), else to the SDK's local default: the desktop
|
// own variable), else to the SDK's local default.
|
||||||
// app's "Ollama (Local)" preset puts its key field there too.
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
overrides?.baseUrl ||
|
overrides?.baseUrl ||
|
||||||
(overrides?.apiKey
|
(overrides?.apiKey
|
||||||
@@ -1203,7 +1153,7 @@ export function usesServerCredentials(
|
|||||||
provider: ProviderName,
|
provider: ProviderName,
|
||||||
overrides?: ClientOverrides,
|
overrides?: ClientOverrides,
|
||||||
): boolean {
|
): boolean {
|
||||||
// The desktop app's local server holds the user's own preset keys
|
// The desktop app's local server only has the user's own keys (its .env)
|
||||||
if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false
|
if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false
|
||||||
// Cleaned like getAIModel does: "/" means no base URL
|
// Cleaned like getAIModel does: "/" means no base URL
|
||||||
const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "")
|
const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "")
|
||||||
|
|||||||
@@ -892,9 +892,9 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// The chat panel answers a built-in example from here, without a request.
|
||||||
// Examples that come with a file only match when that exact example file is
|
// Examples that come with a file only match when that exact example file is
|
||||||
// attached, so a user's own file with the same prompt still goes to the model.
|
// attached, so a user's own file with the same prompt still goes to the model.
|
||||||
// Callers that can't tell file names (the server) only get text-only examples.
|
|
||||||
export function findCachedResponse(
|
export function findCachedResponse(
|
||||||
promptText: string,
|
promptText: string,
|
||||||
hasImage: boolean,
|
hasImage: boolean,
|
||||||
@@ -904,7 +904,6 @@ export function findCachedResponse(
|
|||||||
(c) =>
|
(c) =>
|
||||||
c.promptText === promptText &&
|
c.promptText === promptText &&
|
||||||
c.hasImage === hasImage &&
|
c.hasImage === hasImage &&
|
||||||
(!c.fileName || c.fileName === fileName) &&
|
(!c.fileName || c.fileName === fileName),
|
||||||
c.xml !== "",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,35 +58,6 @@ function isValidToolInput(input: unknown): boolean {
|
|||||||
return !!input && typeof input === "object" && Object.keys(input).length > 0
|
return !!input && typeof input === "object" && Object.keys(input).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to replace historical tool call XML with placeholders
|
|
||||||
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
|
||||||
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
|
|
||||||
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
|
||||||
return messages.map((msg) => {
|
|
||||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
const replacedContent = msg.content.map((part: any) => {
|
|
||||||
if (
|
|
||||||
part.type === "tool-call" &&
|
|
||||||
isValidToolInput(part.input) &&
|
|
||||||
(part.toolName === "display_diagram" ||
|
|
||||||
part.toolName === "edit_diagram")
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
input: {
|
|
||||||
placeholder:
|
|
||||||
"[XML content replaced - see current diagram XML in system context]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return part
|
|
||||||
})
|
|
||||||
return { ...msg, content: replacedContent }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
|
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
|
||||||
// together with their tool-results: providers reject a result whose call is missing.
|
// together with their tool-results: providers reject a result whose call is missing.
|
||||||
// Messages left empty are removed too (Bedrock rejects empty content arrays).
|
// Messages left empty are removed too (Bedrock rejects empty content arrays).
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { InferUITools, UIMessage } from "ai"
|
||||||
|
import type { chatTools } from "@/lib/chat-tools"
|
||||||
|
import type { SelectedCell } from "@/stores/canvas-store"
|
||||||
|
|
||||||
|
/** Shapes sent with a user message (also kept in its metadata) */
|
||||||
|
export type MessageSelection = Pick<SelectedCell, "id" | "label">[]
|
||||||
|
|
||||||
|
export interface ChatMessageMetadata {
|
||||||
|
selectedCells?: MessageSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed request or an answer that drew nothing, shown in the chat with
|
||||||
|
* what to do: an assistant message with this as its only part. The server
|
||||||
|
* ignores data parts, so it never reaches the model.
|
||||||
|
*/
|
||||||
|
export interface ChatNotice {
|
||||||
|
text: string
|
||||||
|
/** Show a button that opens model settings, on the provider that refused */
|
||||||
|
openModelConfig?: boolean
|
||||||
|
providerId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatToolName = keyof InferUITools<typeof chatTools>
|
||||||
|
|
||||||
|
export type ChatUIMessage = UIMessage<
|
||||||
|
ChatMessageMetadata,
|
||||||
|
{ notice: ChatNotice },
|
||||||
|
InferUITools<typeof chatTools>
|
||||||
|
>
|
||||||
|
|
||||||
|
export function noticeMessage(notice: ChatNotice): ChatUIMessage {
|
||||||
|
return {
|
||||||
|
id: `notice-${Date.now()}`,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "data-notice", data: notice }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notice a message carries, if it is one */
|
||||||
|
export function noticeOf(message: ChatUIMessage): ChatNotice | undefined {
|
||||||
|
const part = message.parts.find((p) => p.type === "data-notice")
|
||||||
|
return part?.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { type InferUITools, tool } from "ai"
|
||||||
|
import path from "path"
|
||||||
|
import { z } from "zod"
|
||||||
|
import {
|
||||||
|
getShapeLibrary,
|
||||||
|
SHAPE_LIBRARY_LIST,
|
||||||
|
} from "@/packages/mcp-server/src/shape-library.ts"
|
||||||
|
import {
|
||||||
|
STYLE_CLASS_EXAMPLE,
|
||||||
|
SWIMLANE_EXAMPLE,
|
||||||
|
} from "@/packages/mcp-server/src/xml-examples.ts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat's tools. display_diagram, edit_diagram and append_diagram run in
|
||||||
|
* the browser: the chat engine's tool handler puts their result on the
|
||||||
|
* canvas and reports it back as text, so they have an output schema and no
|
||||||
|
* execute. get_shape_library runs on the server.
|
||||||
|
*/
|
||||||
|
export const chatTools = {
|
||||||
|
display_diagram: tool({
|
||||||
|
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
|
||||||
|
|
||||||
|
VALIDATION RULES (XML will be rejected if violated):
|
||||||
|
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||||
|
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||||
|
3. All mxCell elements must be siblings - never nested
|
||||||
|
4. Every mxCell needs a unique id (start from "2")
|
||||||
|
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
|
||||||
|
6. Escape special chars in values: < > & "
|
||||||
|
|
||||||
|
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>
|
||||||
+10
-15
@@ -1,4 +1,9 @@
|
|||||||
import { modelOfPage, pageElement } from "@/lib/diagram-pages"
|
import {
|
||||||
|
fileVars,
|
||||||
|
modelOfPage,
|
||||||
|
pageElement,
|
||||||
|
parseXml,
|
||||||
|
} from "@/lib/diagram-pages"
|
||||||
|
|
||||||
/** What changed on one page between two versions of a diagram */
|
/** What changed on one page between two versions of a diagram */
|
||||||
export interface ChangeSummary {
|
export interface ChangeSummary {
|
||||||
@@ -32,18 +37,12 @@ interface CellInfo {
|
|||||||
signature: string
|
signature: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function parse(xml: string): Document | null {
|
|
||||||
if (!xml?.trim()) return null
|
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
|
||||||
return doc.querySelector("parsererror") ? null : doc
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A page's mxGraphModel element, whatever wrapper the XML has: the page
|
* A page's mxGraphModel element, whatever wrapper the XML has: the page
|
||||||
* with this id, else the first
|
* with this id, else the first
|
||||||
*/
|
*/
|
||||||
function pageModel(xml: string, pageId?: string | null): Element | null {
|
function pageModel(xml: string, pageId?: string | null): Element | null {
|
||||||
const doc = parse(xml)
|
const doc = parseXml(xml)
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
const diagram = pageElement(doc, pageId)
|
const diagram = pageElement(doc, pageId)
|
||||||
if (diagram) return modelOfPage(diagram)
|
if (diagram) return modelOfPage(diagram)
|
||||||
@@ -304,14 +303,10 @@ export function isSameDocument(a: string, b: string): boolean {
|
|||||||
model: modelOfPage(d),
|
model: modelOfPage(d),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
const docA = parse(a)
|
const docA = parseXml(a)
|
||||||
const docB = parse(b)
|
const docB = parseXml(b)
|
||||||
if (!docA || !docB) return a === b
|
if (!docA || !docB) return a === b
|
||||||
const varsOf = (doc: Document) =>
|
if (!sameFileVars(fileVars(docA), fileVars(docB))) return false
|
||||||
doc.documentElement.nodeName === "mxfile"
|
|
||||||
? doc.documentElement.getAttribute("vars")
|
|
||||||
: null
|
|
||||||
if (!sameFileVars(varsOf(docA), varsOf(docB))) return false
|
|
||||||
const pagesA = pagesOf(docA)
|
const pagesA = pagesOf(docA)
|
||||||
const pagesB = pagesOf(docB)
|
const pagesB = pagesOf(docB)
|
||||||
if (pagesA.length !== pagesB.length) return false
|
if (pagesA.length !== pagesB.length) return false
|
||||||
|
|||||||
+35
-17
@@ -12,12 +12,20 @@ import {
|
|||||||
type PageSelector,
|
type PageSelector,
|
||||||
} from "@/packages/mcp-server/src/pages.ts"
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
|
|
||||||
function parse(xml: string): Document | null {
|
/** The parsed XML, or null when it is empty or does not parse */
|
||||||
|
export function parseXml(xml: string): Document | null {
|
||||||
if (!xml?.trim()) return null
|
if (!xml?.trim()) return null
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||||
return doc.querySelector("parsererror") ? null : doc
|
return doc.querySelector("parsererror") ? null : doc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The file variables (%name% placeholders) an <mxfile> sets, else null */
|
||||||
|
export function fileVars(doc: Document): string | null {
|
||||||
|
return doc.documentElement.nodeName === "mxfile"
|
||||||
|
? doc.documentElement.getAttribute("vars")
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
/** The <diagram> the AI works on: the one with this id, else the first */
|
/** The <diagram> the AI works on: the one with this id, else the first */
|
||||||
export function pageElement(
|
export function pageElement(
|
||||||
doc: Document,
|
doc: Document,
|
||||||
@@ -40,7 +48,7 @@ export function modelOfPage(diagram: Element): Element | null {
|
|||||||
if (model) return model
|
if (model) return model
|
||||||
const inflated = decompressPageContent(diagram.textContent || "")
|
const inflated = decompressPageContent(diagram.textContent || "")
|
||||||
if (!inflated) return null
|
if (!inflated) return null
|
||||||
return parse(inflated)?.querySelector("mxGraphModel") ?? null
|
return parseXml(inflated)?.querySelector("mxGraphModel") ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +60,7 @@ export function pageSelectorFor(
|
|||||||
pageId: string | null | undefined,
|
pageId: string | null | undefined,
|
||||||
): PageSelector {
|
): PageSelector {
|
||||||
if (!pageId) return {}
|
if (!pageId) return {}
|
||||||
const doc = parse(xml)
|
const doc = parseXml(xml)
|
||||||
const found = doc
|
const found = doc
|
||||||
? Array.from(doc.getElementsByTagName("diagram")).some(
|
? Array.from(doc.getElementsByTagName("diagram")).some(
|
||||||
(diagram) => diagram.getAttribute("id") === pageId,
|
(diagram) => diagram.getAttribute("id") === pageId,
|
||||||
@@ -69,7 +77,7 @@ export function pageModelXml(
|
|||||||
xml: string,
|
xml: string,
|
||||||
pageId: string | null | undefined,
|
pageId: string | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
const doc = parse(xml)
|
const doc = parseXml(xml)
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
const root = doc.documentElement
|
const root = doc.documentElement
|
||||||
if (root.nodeName === "mxGraphModel") return xml
|
if (root.nodeName === "mxGraphModel") return xml
|
||||||
@@ -90,15 +98,15 @@ export function replacePageModel(
|
|||||||
pageId: string | null | undefined,
|
pageId: string | null | undefined,
|
||||||
modelXml: string,
|
modelXml: string,
|
||||||
): string {
|
): string {
|
||||||
const model = parse(modelXml)
|
const model = parseXml(modelXml)
|
||||||
if (model?.documentElement.nodeName !== "mxGraphModel") {
|
if (model?.documentElement.nodeName !== "mxGraphModel") {
|
||||||
throw new Error("replacePageModel needs an <mxGraphModel>")
|
throw new Error("replacePageModel needs an <mxGraphModel>")
|
||||||
}
|
}
|
||||||
let doc = parse(fileXml)
|
let doc = parseXml(fileXml)
|
||||||
if (doc?.documentElement.nodeName !== "mxfile") doc = parse(BLANK_MXFILE)
|
if (doc?.documentElement.nodeName !== "mxfile") doc = parseXml(BLANK_MXFILE)
|
||||||
let diagram = pageElement(doc as Document, pageId)
|
let diagram = pageElement(doc as Document, pageId)
|
||||||
if (!diagram) {
|
if (!diagram) {
|
||||||
doc = parse(BLANK_MXFILE)
|
doc = parseXml(BLANK_MXFILE)
|
||||||
diagram = pageElement(doc as Document, null)
|
diagram = pageElement(doc as Document, null)
|
||||||
}
|
}
|
||||||
const target = diagram as Element
|
const target = diagram as Element
|
||||||
@@ -112,27 +120,37 @@ export function replacePageModel(
|
|||||||
/**
|
/**
|
||||||
* Where a drawn diagram goes. One page's worth (bare cells wrapped by
|
* Where a drawn diagram goes. One page's worth (bare cells wrapped by
|
||||||
* prepareNewDiagram, or a one-page file) replaces the AI's page of the
|
* prepareNewDiagram, or a one-page file) replaces the AI's page of the
|
||||||
* canvas file; file variables the drawn file sets replace the canvas
|
* canvas file. A file with several pages replaces the whole document: the
|
||||||
* file's. A file with several pages replaces the whole document: the model
|
* model wrote the pages on purpose. Either way, file variables the drawn
|
||||||
* wrote the pages on purpose.
|
* file sets replace the canvas file's, and a drawn file without any keeps
|
||||||
|
* the canvas file's (as replacing the page in draw.io does).
|
||||||
*/
|
*/
|
||||||
export function placeOnPage(
|
export function placeOnPage(
|
||||||
drawnXml: string,
|
drawnXml: string,
|
||||||
canvasXml: string,
|
canvasXml: string,
|
||||||
pageId: string | null | undefined,
|
pageId: string | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
const doc = parse(drawnXml)
|
const doc = parseXml(drawnXml)
|
||||||
const drawnFile =
|
const drawnFile =
|
||||||
doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null
|
doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null
|
||||||
if (drawnFile && drawnFile.getElementsByTagName("diagram").length > 1) {
|
const severalPages =
|
||||||
return drawnXml
|
(drawnFile?.getElementsByTagName("diagram").length ?? 0) > 1
|
||||||
|
const model = severalPages ? null : pageModelXml(drawnXml, null)
|
||||||
|
if (!model) {
|
||||||
|
// The drawn document as it is, with the canvas file's variables
|
||||||
|
// when it sets none itself
|
||||||
|
const canvas = parseXml(canvasXml)
|
||||||
|
const canvasVars = canvas ? fileVars(canvas) : null
|
||||||
|
if (!doc || !drawnFile || fileVars(doc) !== null || !canvasVars) {
|
||||||
|
return drawnXml
|
||||||
|
}
|
||||||
|
drawnFile.setAttribute("vars", canvasVars)
|
||||||
|
return new XMLSerializer().serializeToString(doc)
|
||||||
}
|
}
|
||||||
const model = pageModelXml(drawnXml, null)
|
|
||||||
if (!model) return drawnXml
|
|
||||||
const placed = replacePageModel(canvasXml, pageId, model)
|
const placed = replacePageModel(canvasXml, pageId, model)
|
||||||
const vars = drawnFile?.getAttribute("vars")
|
const vars = drawnFile?.getAttribute("vars")
|
||||||
if (vars === null || vars === undefined) return placed
|
if (vars === null || vars === undefined) return placed
|
||||||
const result = parse(placed) as Document
|
const result = parseXml(placed) as Document
|
||||||
result.documentElement.setAttribute("vars", vars)
|
result.documentElement.setAttribute("vars", vars)
|
||||||
return new XMLSerializer().serializeToString(result)
|
return new XMLSerializer().serializeToString(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Types and utilities for VLM-based diagram validation.
|
* Types and utilities for VLM-based diagram validation.
|
||||||
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
|
* The validation itself runs in the useValidateDiagram hook (a JSON request to /api/validate-diagram).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Re-export types from the schema file (single source of truth)
|
// Re-export types from the schema file (single source of truth)
|
||||||
export type { ValidationIssue, ValidationResult } from "./validation-schema"
|
export type { ValidationResult } from "./validation-schema"
|
||||||
|
|
||||||
import type { ValidationResult } from "./validation-schema"
|
import type { ValidationResult } from "./validation-schema"
|
||||||
|
|
||||||
|
|||||||
@@ -666,18 +666,6 @@ function readSelectionRect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pages
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readPages() {
|
|
||||||
const pages = (ui?.pages ?? []) as any[]
|
|
||||||
return pages.map((page, index) => ({
|
|
||||||
id: String(page.getId?.() ?? index),
|
|
||||||
name: String(page.getName?.() ?? `Page-${index + 1}`),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Keeping the store in sync
|
// Keeping the store in sync
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -690,7 +678,6 @@ function syncAll() {
|
|||||||
if (!ui) return
|
if (!ui) return
|
||||||
const g = graph()
|
const g = graph()
|
||||||
useCanvasStore.getState().set({
|
useCanvasStore.getState().set({
|
||||||
pages: readPages(),
|
|
||||||
currentPageId: ui.currentPage?.getId
|
currentPageId: ui.currentPage?.getId
|
||||||
? String(ui.currentPage.getId())
|
? String(ui.currentPage.getId())
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
+45
-61
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ConditionalCheckFailedException,
|
ConditionalCheckFailedException,
|
||||||
DynamoDBClient,
|
DynamoDBClient,
|
||||||
GetItemCommand,
|
|
||||||
UpdateItemCommand,
|
UpdateItemCommand,
|
||||||
} from "@aws-sdk/client-dynamodb"
|
} from "@aws-sdk/client-dynamodb"
|
||||||
|
|
||||||
@@ -108,76 +107,61 @@ export async function checkAndIncrementRequest(
|
|||||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||||
":tpmLimit": { N: String(limits.tpm || 999999) },
|
":tpmLimit": { N: String(limits.tpm || 999999) },
|
||||||
},
|
},
|
||||||
|
// On a failed condition the exception carries the item as it
|
||||||
|
// was, so no second read is needed to tell which limit was hit
|
||||||
|
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return { allowed: true }
|
return { allowed: true }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Condition failed - need to determine which limit was exceeded
|
// Condition failed - the exception's Item holds the current counts
|
||||||
if (e instanceof ConditionalCheckFailedException) {
|
if (e instanceof ConditionalCheckFailedException) {
|
||||||
// Get current counts to determine which limit was hit
|
const item = e.Item
|
||||||
try {
|
const storedMinute = item?.lastMinute?.S
|
||||||
const getResult = await client.send(
|
|
||||||
new GetItemCommand({
|
|
||||||
TableName: TABLE,
|
|
||||||
Key: {
|
|
||||||
PK: { S: pk },
|
|
||||||
SK: { S: sk },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const item = getResult.Item
|
const reqCount = Number(item?.reqCount?.N || 0)
|
||||||
const storedMinute = item?.lastMinute?.S
|
const tokenCount = Number(item?.tokenCount?.N || 0)
|
||||||
|
const tpmCount =
|
||||||
|
storedMinute !== currentMinute
|
||||||
|
? 0
|
||||||
|
: Number(item?.tpmCount?.N || 0)
|
||||||
|
|
||||||
const reqCount = Number(item?.reqCount?.N || 0)
|
// Determine which limit was exceeded
|
||||||
const tokenCount = Number(item?.tokenCount?.N || 0)
|
if (limits.requests > 0 && reqCount >= limits.requests) {
|
||||||
const tpmCount =
|
return {
|
||||||
storedMinute !== currentMinute
|
allowed: false,
|
||||||
? 0
|
type: "request",
|
||||||
: Number(item?.tpmCount?.N || 0)
|
error: "Daily request limit exceeded",
|
||||||
|
used: reqCount,
|
||||||
// Determine which limit was exceeded
|
limit: limits.requests,
|
||||||
if (limits.requests > 0 && reqCount >= limits.requests) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
type: "request",
|
|
||||||
error: "Daily request limit exceeded",
|
|
||||||
used: reqCount,
|
|
||||||
limit: limits.requests,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
type: "token",
|
|
||||||
error: "Daily token limit exceeded",
|
|
||||||
used: tokenCount,
|
|
||||||
limit: limits.tokens,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
type: "tpm",
|
|
||||||
error: "Rate limit exceeded (tokens per minute)",
|
|
||||||
used: tpmCount,
|
|
||||||
limit: limits.tpm,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Condition failed but no limit clearly exceeded - race condition edge case
|
|
||||||
// Fail safe by allowing (could be a TPM reset race)
|
|
||||||
console.warn(
|
|
||||||
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
|
||||||
)
|
|
||||||
return { allowed: true }
|
|
||||||
} catch (getError: any) {
|
|
||||||
console.error(
|
|
||||||
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
|
|
||||||
)
|
|
||||||
return { allowed: true } // Fail open
|
|
||||||
}
|
}
|
||||||
|
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
type: "token",
|
||||||
|
error: "Daily token limit exceeded",
|
||||||
|
used: tokenCount,
|
||||||
|
limit: limits.tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
type: "tpm",
|
||||||
|
error: "Rate limit exceeded (tokens per minute)",
|
||||||
|
used: tpmCount,
|
||||||
|
limit: limits.tpm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition failed but no limit clearly exceeded - race condition edge case
|
||||||
|
// Fail safe by allowing (could be a TPM reset race)
|
||||||
|
console.warn(
|
||||||
|
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
||||||
|
)
|
||||||
|
return { allowed: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other DynamoDB errors - fail open
|
// Other DynamoDB errors - fail open
|
||||||
|
|||||||
@@ -3,48 +3,25 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"clear": "Clear",
|
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"loading": "Loading.."
|
"loading": "Loading.."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"editor": "Editor",
|
|
||||||
"newChat": "New diagram",
|
"newChat": "New diagram",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"hidePanel": "Hide chat panel",
|
"hidePanel": "Hide chat panel",
|
||||||
"showPanel": "Show chat panel",
|
"showPanel": "Show chat panel",
|
||||||
"aiChat": "AI chat"
|
"aiChat": "AI chat"
|
||||||
},
|
},
|
||||||
"providers": {
|
|
||||||
"useServerDefault": "Use Server Default",
|
|
||||||
"openai": "OpenAI",
|
|
||||||
"anthropic": "Anthropic",
|
|
||||||
"google": "Google",
|
|
||||||
"azure": "Azure OpenAI",
|
|
||||||
"openrouter": "OpenRouter",
|
|
||||||
"deepseek": "DeepSeek",
|
|
||||||
"siliconflow": "SiliconFlow",
|
|
||||||
"modelscope": "ModelScope",
|
|
||||||
"minimax": "MiniMax",
|
|
||||||
"glm": "GLM",
|
|
||||||
"qwen": "Qwen",
|
|
||||||
"kimi": "Kimi",
|
|
||||||
"qiniu": "Qiniu",
|
|
||||||
"mimo": "MiMo (Xiaomi)"
|
|
||||||
},
|
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
"inputLabel": "Chat input",
|
"inputLabel": "Chat input",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"stopGeneration": "Stop generation",
|
"stopGeneration": "Stop generation",
|
||||||
"sendMessage": "Send message",
|
|
||||||
"clearConversation": "Clear conversation",
|
|
||||||
"uploadFile": "Upload file (image, PDF, text)",
|
"uploadFile": "Upload file (image, PDF, text)",
|
||||||
"minimalStyle": "Minimal",
|
"minimalStyle": "Minimal",
|
||||||
"styledMode": "Styled",
|
"styledMode": "Styled",
|
||||||
"minimalTooltip": "Use minimal for faster generation (no colors)",
|
|
||||||
"regenerate": "Regenerate response",
|
"regenerate": "Regenerate response",
|
||||||
"copyResponse": "Copy response",
|
"copyResponse": "Copy response",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
@@ -82,17 +59,6 @@
|
|||||||
"accessCode": "Access code",
|
"accessCode": "Access code",
|
||||||
"accessCodePlaceholder": "Enter access code",
|
"accessCodePlaceholder": "Enter access code",
|
||||||
"accessCodeDescription": "Required to use this application.",
|
"accessCodeDescription": "Required to use this application.",
|
||||||
"aiProvider": "AI Provider Settings",
|
|
||||||
"aiProviderDescription": "Use your own API key to bypass usage limits. Your key is stored locally in your browser and is never stored on the server.",
|
|
||||||
"provider": "Provider",
|
|
||||||
"modelId": "Model ID",
|
|
||||||
"apiKey": "API Key",
|
|
||||||
"apiKeyPlaceholder": "Your API key",
|
|
||||||
"baseUrl": "Base URL (optional)",
|
|
||||||
"customEndpoint": "Custom endpoint URL",
|
|
||||||
"overrides": "Overrides",
|
|
||||||
"clearSettings": "Clear Settings",
|
|
||||||
"useServerDefault": "Use Server Default",
|
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageDescription": "Choose your interface language.",
|
"languageDescription": "Choose your interface language.",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
@@ -103,10 +69,6 @@
|
|||||||
"sendShortcutDescription": "Choose how to send messages.",
|
"sendShortcutDescription": "Choose how to send messages.",
|
||||||
"enterToSend": "Enter to send",
|
"enterToSend": "Enter to send",
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
|
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
|
||||||
"diagramActions": "Diagram Actions",
|
|
||||||
"diagramActionsDescription": "Manage diagram history and exports",
|
|
||||||
"history": "History",
|
|
||||||
"download": "Download",
|
|
||||||
"proxy": "Proxy settings",
|
"proxy": "Proxy settings",
|
||||||
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
|
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
|
||||||
"httpProxy": "HTTP proxy",
|
"httpProxy": "HTTP proxy",
|
||||||
@@ -170,13 +132,9 @@
|
|||||||
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
|
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
|
||||||
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
|
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
|
||||||
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
|
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
|
||||||
"failedToSave": "Failed to save messages to localStorage",
|
|
||||||
"failedToRestore": "Failed to restore from localStorage",
|
|
||||||
"failedToPersist": "Failed to persist state before unload",
|
|
||||||
"failedToExport": "Error fetching chart data",
|
"failedToExport": "Error fetching chart data",
|
||||||
"failedToLoadExample": "Error loading example image",
|
"failedToLoadExample": "Error loading example image",
|
||||||
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
|
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
|
||||||
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
|
|
||||||
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
|
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
|
||||||
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
|
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
|
||||||
"continueWithoutSaving": "Continue without saving",
|
"continueWithoutSaving": "Continue without saving",
|
||||||
@@ -208,8 +166,6 @@
|
|||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "Daily Quota Reached",
|
"dailyLimit": "Daily Quota Reached",
|
||||||
"tokenLimit": "Daily Token Limit Reached",
|
"tokenLimit": "Daily Token Limit Reached",
|
||||||
"tpmLimit": "Rate Limit",
|
|
||||||
"tpmMessage": "Too many requests. Please wait a moment.",
|
|
||||||
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
||||||
"messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
|
"messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
@@ -221,7 +177,6 @@
|
|||||||
"configModel": "Use Your API Key",
|
"configModel": "Use Your API Key",
|
||||||
"selfHost": "Self-host",
|
"selfHost": "Self-host",
|
||||||
"sponsor": "Sponsor",
|
"sponsor": "Sponsor",
|
||||||
"learnMore": "Learn more →",
|
|
||||||
"usedOf": "{used}/{limit}"
|
"usedOf": "{used}/{limit}"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -248,37 +203,8 @@
|
|||||||
"thoughtForOne": "Thought for 1 second",
|
"thoughtForOne": "Thought for 1 second",
|
||||||
"thoughtBrief": "Thought for a few seconds"
|
"thoughtBrief": "Thought for a few seconds"
|
||||||
},
|
},
|
||||||
"dev": {
|
|
||||||
"title": "Dev: XML Streaming Simulator",
|
|
||||||
"preset": "Preset:",
|
|
||||||
"selectPreset": "Select a preset...",
|
|
||||||
"clear": "Clear",
|
|
||||||
"placeholder": "Paste mxCell XML here or select a preset...",
|
|
||||||
"interval": "Interval:",
|
|
||||||
"chars": "Chars:",
|
|
||||||
"streaming": "Streaming...",
|
|
||||||
"simulate": "Simulate",
|
|
||||||
"stop": "Stop",
|
|
||||||
"testQuotaToast": "Test Quota Toast",
|
|
||||||
"simulatingMessage": "[Dev] Simulating XML streaming",
|
|
||||||
"successMessage": "Successfully displayed the diagram."
|
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"modelChange": "Model Change & Usage Limits",
|
|
||||||
"walletCrying": "(Or: Why My Wallet is Crying)",
|
|
||||||
"seekingSponsorship": "Call for Sponsorship",
|
|
||||||
"contactMe": "Contact Me",
|
|
||||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
|
||||||
},
|
|
||||||
"sessionHistory": {
|
"sessionHistory": {
|
||||||
"tooltip": "Chat History",
|
|
||||||
"newChat": "New Chat",
|
|
||||||
"empty": "No diagrams yet",
|
"empty": "No diagrams yet",
|
||||||
"emptyHint": "Start a conversation to begin",
|
|
||||||
"today": "Today",
|
|
||||||
"yesterday": "Yesterday",
|
|
||||||
"thisWeek": "This Week",
|
|
||||||
"earlier": "Earlier",
|
|
||||||
"deleteTitle": "Delete this diagram?",
|
"deleteTitle": "Delete this diagram?",
|
||||||
"deleteDescription": "The diagram and its conversation are deleted for good.",
|
"deleteDescription": "The diagram and its conversation are deleted for good.",
|
||||||
"justNow": "Just now",
|
"justNow": "Just now",
|
||||||
@@ -351,20 +277,13 @@
|
|||||||
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
|
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
|
||||||
},
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"configure": "Configure",
|
|
||||||
"addProvider": "Add provider",
|
"addProvider": "Add provider",
|
||||||
"addModel": "Add Model",
|
|
||||||
"modelId": "Model ID",
|
"modelId": "Model ID",
|
||||||
"modelLabel": "Display Label",
|
|
||||||
"streaming": "Enable Streaming",
|
|
||||||
"deleteProvider": "Delete provider",
|
"deleteProvider": "Delete provider",
|
||||||
"deleteModel": "Delete Model",
|
|
||||||
"noModels": "No models configured. Add a model to get started.",
|
|
||||||
"validationError": "The test failed",
|
"validationError": "The test failed",
|
||||||
"addModelFirst": "Add a model first.",
|
"addModelFirst": "Add a model first.",
|
||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
"bedrockApiKey": "Bedrock API key",
|
"bedrockApiKey": "Bedrock API key",
|
||||||
"bedrockApiKeyHint": "With an API key, the access keys below are not needed.",
|
|
||||||
"awsAccessKeyId": "AWS Access Key ID",
|
"awsAccessKeyId": "AWS Access Key ID",
|
||||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||||
"awsRegion": "AWS Region",
|
"awsRegion": "AWS Region",
|
||||||
@@ -372,7 +291,6 @@
|
|||||||
"apiKey": "API key",
|
"apiKey": "API key",
|
||||||
"enterApiKey": "Enter your API key",
|
"enterApiKey": "Enter your API key",
|
||||||
"enterSecretKey": "Enter your secret access key",
|
"enterSecretKey": "Enter your secret access key",
|
||||||
"baseUrl": "Base URL",
|
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
"getApiKey": "Get API key",
|
"getApiKey": "Get API key",
|
||||||
"noTools": "no tool calls",
|
"noTools": "no tool calls",
|
||||||
@@ -388,8 +306,6 @@
|
|||||||
"modelIdEmpty": "Model ID cannot be empty",
|
"modelIdEmpty": "Model ID cannot be empty",
|
||||||
"modelIdExists": "This model ID already exists",
|
"modelIdExists": "This model ID already exists",
|
||||||
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
|
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
|
||||||
"typeToConfirm": "Type \"{name}\" to confirm",
|
|
||||||
"typeProviderName": "Type the provider name...",
|
|
||||||
"requestFailed": "Request failed ({status})",
|
"requestFailed": "Request failed ({status})",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -583,10 +499,6 @@
|
|||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "Check Timeout (ms)"
|
"label": "Check Timeout (ms)"
|
||||||
},
|
},
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
|
||||||
"label": "Trim Old Diagrams",
|
|
||||||
"description": "Old diagrams in the chat history are kept as a short marker"
|
|
||||||
},
|
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "Allow Private URLs",
|
"label": "Allow Private URLs",
|
||||||
"description": "When off, custom model URLs can't point to private networks; safer"
|
"description": "When off, custom model URLs can't point to private networks; safer"
|
||||||
@@ -697,8 +609,7 @@
|
|||||||
"lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.",
|
"lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.",
|
||||||
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
|
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
|
||||||
"recent": "Recent diagrams",
|
"recent": "Recent diagrams",
|
||||||
"drawYourself": "Start with a blank canvas",
|
"drawYourself": "Start with a blank canvas"
|
||||||
"openFileHint": "Keep working on a diagram you have"
|
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"waiting": "Waiting for the MCP server…",
|
"waiting": "Waiting for the MCP server…",
|
||||||
|
|||||||
@@ -3,48 +3,25 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"clear": "クリア",
|
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"loading": "読み込み中.."
|
"loading": "読み込み中.."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"editor": "エディタ",
|
|
||||||
"newChat": "新しい図",
|
"newChat": "新しい図",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"hidePanel": "チャットパネルを非表示",
|
"hidePanel": "チャットパネルを非表示",
|
||||||
"showPanel": "チャットパネルを表示",
|
"showPanel": "チャットパネルを表示",
|
||||||
"aiChat": "AI チャット"
|
"aiChat": "AI チャット"
|
||||||
},
|
},
|
||||||
"providers": {
|
|
||||||
"useServerDefault": "サーバーデフォルトを使用",
|
|
||||||
"openai": "OpenAI",
|
|
||||||
"anthropic": "Anthropic",
|
|
||||||
"google": "Google",
|
|
||||||
"azure": "Azure OpenAI",
|
|
||||||
"openrouter": "OpenRouter",
|
|
||||||
"deepseek": "DeepSeek",
|
|
||||||
"siliconflow": "SiliconFlow",
|
|
||||||
"modelscope": "ModelScope",
|
|
||||||
"minimax": "MiniMax",
|
|
||||||
"glm": "GLM",
|
|
||||||
"qwen": "Qwen",
|
|
||||||
"kimi": "Kimi",
|
|
||||||
"qiniu": "Qiniu",
|
|
||||||
"mimo": "MiMo (Xiaomi)"
|
|
||||||
},
|
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
"inputLabel": "チャット入力",
|
"inputLabel": "チャット入力",
|
||||||
"send": "送信",
|
"send": "送信",
|
||||||
"stopGeneration": "生成を停止",
|
"stopGeneration": "生成を停止",
|
||||||
"sendMessage": "メッセージを送信",
|
|
||||||
"clearConversation": "会話をクリア",
|
|
||||||
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
|
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
|
||||||
"minimalStyle": "ミニマル",
|
"minimalStyle": "ミニマル",
|
||||||
"styledMode": "スタイル付き",
|
"styledMode": "スタイル付き",
|
||||||
"minimalTooltip": "高速生成のためミニマルを使用(色なし)",
|
|
||||||
"regenerate": "応答を再生成",
|
"regenerate": "応答を再生成",
|
||||||
"copyResponse": "応答をコピー",
|
"copyResponse": "応答をコピー",
|
||||||
"copied": "コピーしました!",
|
"copied": "コピーしました!",
|
||||||
@@ -82,17 +59,6 @@
|
|||||||
"accessCode": "アクセスコード",
|
"accessCode": "アクセスコード",
|
||||||
"accessCodePlaceholder": "アクセスコードを入力",
|
"accessCodePlaceholder": "アクセスコードを入力",
|
||||||
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
|
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
|
||||||
"aiProvider": "AI プロバイダー設定",
|
|
||||||
"aiProviderDescription": "独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。",
|
|
||||||
"provider": "プロバイダー",
|
|
||||||
"modelId": "モデル ID",
|
|
||||||
"apiKey": "API キー",
|
|
||||||
"apiKeyPlaceholder": "あなたの API キー",
|
|
||||||
"baseUrl": "ベース URL(オプション)",
|
|
||||||
"customEndpoint": "カスタムエンドポイント URL",
|
|
||||||
"overrides": "上書き",
|
|
||||||
"clearSettings": "設定をクリア",
|
|
||||||
"useServerDefault": "サーバーデフォルトを使用",
|
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"languageDescription": "インターフェース言語を選択します。",
|
"languageDescription": "インターフェース言語を選択します。",
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
@@ -103,10 +69,6 @@
|
|||||||
"sendShortcutDescription": "メッセージの送信方法を選択します。",
|
"sendShortcutDescription": "メッセージの送信方法を選択します。",
|
||||||
"enterToSend": "Enterで送信",
|
"enterToSend": "Enterで送信",
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
|
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
|
||||||
"diagramActions": "ダイアグラム操作",
|
|
||||||
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
|
|
||||||
"history": "履歴",
|
|
||||||
"download": "ダウンロード",
|
|
||||||
"proxy": "プロキシ設定",
|
"proxy": "プロキシ設定",
|
||||||
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
|
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
|
||||||
"httpProxy": "HTTP プロキシ",
|
"httpProxy": "HTTP プロキシ",
|
||||||
@@ -170,13 +132,9 @@
|
|||||||
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
|
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
|
||||||
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
|
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
|
||||||
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
|
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
|
||||||
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
|
|
||||||
"failedToRestore": "localStorage からの復元に失敗しました",
|
|
||||||
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
|
|
||||||
"failedToExport": "チャートデータの取得エラー",
|
"failedToExport": "チャートデータの取得エラー",
|
||||||
"failedToLoadExample": "例の画像の読み込みエラー",
|
"failedToLoadExample": "例の画像の読み込みエラー",
|
||||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
||||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
|
|
||||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
|
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
|
||||||
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
|
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
|
||||||
"continueWithoutSaving": "保存せずに続ける",
|
"continueWithoutSaving": "保存せずに続ける",
|
||||||
@@ -208,8 +166,6 @@
|
|||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "1日の割当量に達しました",
|
"dailyLimit": "1日の割当量に達しました",
|
||||||
"tokenLimit": "1日のトークン制限に達しました",
|
"tokenLimit": "1日のトークン制限に達しました",
|
||||||
"tpmLimit": "レート制限",
|
|
||||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
|
||||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||||
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
@@ -221,7 +177,6 @@
|
|||||||
"configModel": "APIキーを使用",
|
"configModel": "APIキーを使用",
|
||||||
"selfHost": "セルフホスト",
|
"selfHost": "セルフホスト",
|
||||||
"sponsor": "スポンサー",
|
"sponsor": "スポンサー",
|
||||||
"learnMore": "詳細 →",
|
|
||||||
"usedOf": "{used}/{limit}"
|
"usedOf": "{used}/{limit}"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -248,37 +203,8 @@
|
|||||||
"thoughtForOne": "1 秒考えました",
|
"thoughtForOne": "1 秒考えました",
|
||||||
"thoughtBrief": "数秒考えました"
|
"thoughtBrief": "数秒考えました"
|
||||||
},
|
},
|
||||||
"dev": {
|
|
||||||
"title": "開発:XMLストリーミングシミュレーター",
|
|
||||||
"preset": "プリセット:",
|
|
||||||
"selectPreset": "プリセットを選択...",
|
|
||||||
"clear": "クリア",
|
|
||||||
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
|
|
||||||
"interval": "間隔:",
|
|
||||||
"chars": "文字:",
|
|
||||||
"streaming": "ストリーミング中...",
|
|
||||||
"simulate": "シミュレート",
|
|
||||||
"stop": "停止",
|
|
||||||
"testQuotaToast": "クォータトーストをテスト",
|
|
||||||
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
|
|
||||||
"successMessage": "ダイアグラムの表示に成功しました。"
|
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"modelChange": "モデル変更と利用制限について",
|
|
||||||
"walletCrying": "(別名:お財布が悲鳴を上げています)",
|
|
||||||
"seekingSponsorship": "スポンサー募集",
|
|
||||||
"contactMe": "お問い合わせ",
|
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
|
||||||
},
|
|
||||||
"sessionHistory": {
|
"sessionHistory": {
|
||||||
"tooltip": "チャット履歴",
|
|
||||||
"newChat": "新しいチャット",
|
|
||||||
"empty": "まだ図はありません",
|
"empty": "まだ図はありません",
|
||||||
"emptyHint": "会話を始めてください",
|
|
||||||
"today": "今日",
|
|
||||||
"yesterday": "昨日",
|
|
||||||
"thisWeek": "今週",
|
|
||||||
"earlier": "それ以前",
|
|
||||||
"deleteTitle": "この図を削除しますか?",
|
"deleteTitle": "この図を削除しますか?",
|
||||||
"deleteDescription": "図とその会話は完全に削除されます。",
|
"deleteDescription": "図とその会話は完全に削除されます。",
|
||||||
"justNow": "たった今",
|
"justNow": "たった今",
|
||||||
@@ -306,20 +232,13 @@
|
|||||||
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
|
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
|
||||||
},
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"configure": "設定",
|
|
||||||
"addProvider": "プロバイダーを追加",
|
"addProvider": "プロバイダーを追加",
|
||||||
"addModel": "モデルを追加",
|
|
||||||
"modelId": "モデルID",
|
"modelId": "モデルID",
|
||||||
"modelLabel": "表示名",
|
|
||||||
"streaming": "ストリーミングを有効",
|
|
||||||
"deleteProvider": "プロバイダーを削除",
|
"deleteProvider": "プロバイダーを削除",
|
||||||
"deleteModel": "モデルを削除",
|
|
||||||
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
|
||||||
"validationError": "テストに失敗しました",
|
"validationError": "テストに失敗しました",
|
||||||
"addModelFirst": "先にモデルを追加してください。",
|
"addModelFirst": "先にモデルを追加してください。",
|
||||||
"displayName": "表示名",
|
"displayName": "表示名",
|
||||||
"bedrockApiKey": "Bedrock API キー",
|
"bedrockApiKey": "Bedrock API キー",
|
||||||
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
|
|
||||||
"awsAccessKeyId": "AWS アクセスキー ID",
|
"awsAccessKeyId": "AWS アクセスキー ID",
|
||||||
"awsSecretAccessKey": "AWS シークレットアクセスキー",
|
"awsSecretAccessKey": "AWS シークレットアクセスキー",
|
||||||
"awsRegion": "AWS リージョン",
|
"awsRegion": "AWS リージョン",
|
||||||
@@ -327,7 +246,6 @@
|
|||||||
"apiKey": "API キー",
|
"apiKey": "API キー",
|
||||||
"enterApiKey": "API キーを入力",
|
"enterApiKey": "API キーを入力",
|
||||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||||
"baseUrl": "ベース URL",
|
|
||||||
"optional": "(オプション)",
|
"optional": "(オプション)",
|
||||||
"getApiKey": "API キーを取得",
|
"getApiKey": "API キーを取得",
|
||||||
"noTools": "ツール呼び出し非対応",
|
"noTools": "ツール呼び出し非対応",
|
||||||
@@ -343,8 +261,6 @@
|
|||||||
"modelIdEmpty": "モデル ID は空にできません",
|
"modelIdEmpty": "モデル ID は空にできません",
|
||||||
"modelIdExists": "このモデル ID は既に存在します",
|
"modelIdExists": "このモデル ID は既に存在します",
|
||||||
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
||||||
"typeToConfirm": "確認のため「{name}」と入力",
|
|
||||||
"typeProviderName": "プロバイダー名を入力...",
|
|
||||||
"requestFailed": "リクエストに失敗しました({status})",
|
"requestFailed": "リクエストに失敗しました({status})",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
@@ -583,10 +499,6 @@
|
|||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "チェックのタイムアウト(ms)"
|
"label": "チェックのタイムアウト(ms)"
|
||||||
},
|
},
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
|
||||||
"label": "古い図を省略",
|
|
||||||
"description": "会話履歴の古い図は短い印だけ残します"
|
|
||||||
},
|
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "内部ネットワークへのアクセス",
|
"label": "内部ネットワークへのアクセス",
|
||||||
"description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です"
|
"description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です"
|
||||||
@@ -697,8 +609,7 @@
|
|||||||
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
|
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
|
||||||
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
|
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
|
||||||
"recent": "最近の図",
|
"recent": "最近の図",
|
||||||
"drawYourself": "白紙のキャンバスから始める",
|
"drawYourself": "白紙のキャンバスから始める"
|
||||||
"openFileHint": "手元の図の続きを編集"
|
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"waiting": "MCP サーバーを待っています…",
|
"waiting": "MCP サーバーを待っています…",
|
||||||
|
|||||||
@@ -3,48 +3,25 @@
|
|||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"close": "關閉",
|
"close": "關閉",
|
||||||
"clear": "清除",
|
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"loading": "載入中..."
|
"loading": "載入中..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"editor": "編輯器",
|
|
||||||
"newChat": "新增圖紙",
|
"newChat": "新增圖紙",
|
||||||
"github": "GitHub",
|
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"hidePanel": "隱藏聊天面板",
|
"hidePanel": "隱藏聊天面板",
|
||||||
"showPanel": "顯示聊天面板",
|
"showPanel": "顯示聊天面板",
|
||||||
"aiChat": "AI 對話"
|
"aiChat": "AI 對話"
|
||||||
},
|
},
|
||||||
"providers": {
|
|
||||||
"useServerDefault": "使用伺服器預設值",
|
|
||||||
"openai": "OpenAI",
|
|
||||||
"anthropic": "Anthropic",
|
|
||||||
"google": "Google",
|
|
||||||
"azure": "Azure OpenAI",
|
|
||||||
"openrouter": "OpenRouter",
|
|
||||||
"deepseek": "DeepSeek",
|
|
||||||
"siliconflow": "SiliconFlow",
|
|
||||||
"modelscope": "ModelScope",
|
|
||||||
"minimax": "MiniMax",
|
|
||||||
"glm": "GLM",
|
|
||||||
"qwen": "Qwen",
|
|
||||||
"kimi": "Kimi",
|
|
||||||
"qiniu": "Qiniu",
|
|
||||||
"mimo": "MiMo (小米)"
|
|
||||||
},
|
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的圖表或上傳檔案...",
|
"placeholder": "描述您的圖表或上傳檔案...",
|
||||||
"inputLabel": "聊天輸入框",
|
"inputLabel": "聊天輸入框",
|
||||||
"send": "傳送",
|
"send": "傳送",
|
||||||
"stopGeneration": "停止產生",
|
"stopGeneration": "停止產生",
|
||||||
"sendMessage": "傳送訊息",
|
|
||||||
"clearConversation": "清除對話",
|
|
||||||
"uploadFile": "上傳檔案(圖片、PDF、文字)",
|
"uploadFile": "上傳檔案(圖片、PDF、文字)",
|
||||||
"minimalStyle": "簡約",
|
"minimalStyle": "簡約",
|
||||||
"styledMode": "精緻",
|
"styledMode": "精緻",
|
||||||
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
|
|
||||||
"regenerate": "重新產生回應",
|
"regenerate": "重新產生回應",
|
||||||
"copyResponse": "複製回應",
|
"copyResponse": "複製回應",
|
||||||
"copied": "已複製!",
|
"copied": "已複製!",
|
||||||
@@ -82,17 +59,6 @@
|
|||||||
"accessCode": "存取碼",
|
"accessCode": "存取碼",
|
||||||
"accessCodePlaceholder": "輸入存取碼",
|
"accessCodePlaceholder": "輸入存取碼",
|
||||||
"accessCodeDescription": "使用此應用程式需要存取碼。",
|
"accessCodeDescription": "使用此應用程式需要存取碼。",
|
||||||
"aiProvider": "AI 服務商設定",
|
|
||||||
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
|
|
||||||
"provider": "服務商",
|
|
||||||
"modelId": "模型 ID",
|
|
||||||
"apiKey": "API 金鑰",
|
|
||||||
"apiKeyPlaceholder": "您的 API 金鑰",
|
|
||||||
"baseUrl": "基礎 URL(可選)",
|
|
||||||
"customEndpoint": "自訂端點 URL",
|
|
||||||
"overrides": "覆寫",
|
|
||||||
"clearSettings": "清除設定",
|
|
||||||
"useServerDefault": "使用伺服器預設值",
|
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
"languageDescription": "選擇介面語言。",
|
"languageDescription": "選擇介面語言。",
|
||||||
"theme": "主題",
|
"theme": "主題",
|
||||||
@@ -103,10 +69,6 @@
|
|||||||
"sendShortcutDescription": "選擇傳送訊息的方式。",
|
"sendShortcutDescription": "選擇傳送訊息的方式。",
|
||||||
"enterToSend": "Enter 傳送",
|
"enterToSend": "Enter 傳送",
|
||||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
|
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
|
||||||
"diagramActions": "圖表操作",
|
|
||||||
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
|
|
||||||
"history": "歷史紀錄",
|
|
||||||
"download": "下載",
|
|
||||||
"proxy": "代理設定",
|
"proxy": "代理設定",
|
||||||
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
|
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
|
||||||
"httpProxy": "HTTP 代理",
|
"httpProxy": "HTTP 代理",
|
||||||
@@ -170,13 +132,9 @@
|
|||||||
"retryLimit": "已達自動重試限制({max})。請手動重試。",
|
"retryLimit": "已達自動重試限制({max})。請手動重試。",
|
||||||
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
|
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
|
||||||
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
|
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
|
||||||
"failedToSave": "無法儲存訊息到 localStorage",
|
|
||||||
"failedToRestore": "無法從 localStorage 還原",
|
|
||||||
"failedToPersist": "卸載前無法持久化狀態",
|
|
||||||
"failedToExport": "取得圖表資料時出錯",
|
"failedToExport": "取得圖表資料時出錯",
|
||||||
"failedToLoadExample": "載入範例圖片時出錯",
|
"failedToLoadExample": "載入範例圖片時出錯",
|
||||||
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
|
|
||||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
|
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
|
||||||
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
|
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
|
||||||
"continueWithoutSaving": "不儲存,繼續",
|
"continueWithoutSaving": "不儲存,繼續",
|
||||||
@@ -208,8 +166,6 @@
|
|||||||
"quota": {
|
"quota": {
|
||||||
"dailyLimit": "已達每日配額",
|
"dailyLimit": "已達每日配額",
|
||||||
"tokenLimit": "已達每日 token 上限",
|
"tokenLimit": "已達每日 token 上限",
|
||||||
"tpmLimit": "速率限制",
|
|
||||||
"tpmMessage": "請求過多。請稍等片刻。",
|
|
||||||
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
|
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
|
||||||
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
|
||||||
"messageApiSelfHosted": null,
|
"messageApiSelfHosted": null,
|
||||||
@@ -221,7 +177,6 @@
|
|||||||
"configModel": "使用自己的 API Key",
|
"configModel": "使用自己的 API Key",
|
||||||
"selfHost": "自行託管",
|
"selfHost": "自行託管",
|
||||||
"sponsor": "贊助",
|
"sponsor": "贊助",
|
||||||
"learnMore": "了解更多 →",
|
|
||||||
"usedOf": "{used}/{limit}"
|
"usedOf": "{used}/{limit}"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -248,37 +203,8 @@
|
|||||||
"thoughtForOne": "思考了 1 秒",
|
"thoughtForOne": "思考了 1 秒",
|
||||||
"thoughtBrief": "思考了幾秒鐘"
|
"thoughtBrief": "思考了幾秒鐘"
|
||||||
},
|
},
|
||||||
"dev": {
|
|
||||||
"title": "開發:XML 串流模擬器",
|
|
||||||
"preset": "預設:",
|
|
||||||
"selectPreset": "選擇預設...",
|
|
||||||
"clear": "清除",
|
|
||||||
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
|
|
||||||
"interval": "間隔:",
|
|
||||||
"chars": "字元:",
|
|
||||||
"streaming": "串流傳輸中...",
|
|
||||||
"simulate": "模擬",
|
|
||||||
"stop": "停止",
|
|
||||||
"testQuotaToast": "測試配額提示",
|
|
||||||
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
|
|
||||||
"successMessage": "成功顯示圖表。"
|
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"modelChange": "模型變更與用量限制",
|
|
||||||
"walletCrying": "(別名:我的錢包頂不住了)",
|
|
||||||
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
|
|
||||||
"contactMe": "聯絡我",
|
|
||||||
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
|
|
||||||
},
|
|
||||||
"sessionHistory": {
|
"sessionHistory": {
|
||||||
"tooltip": "聊天歷史",
|
|
||||||
"newChat": "新對話",
|
|
||||||
"empty": "還沒有圖紙",
|
"empty": "還沒有圖紙",
|
||||||
"emptyHint": "開始對話吧",
|
|
||||||
"today": "今天",
|
|
||||||
"yesterday": "昨天",
|
|
||||||
"thisWeek": "本週",
|
|
||||||
"earlier": "更早",
|
|
||||||
"deleteTitle": "刪除這張圖紙?",
|
"deleteTitle": "刪除這張圖紙?",
|
||||||
"deleteDescription": "圖紙和它的對話會被永久刪除。",
|
"deleteDescription": "圖紙和它的對話會被永久刪除。",
|
||||||
"justNow": "剛剛",
|
"justNow": "剛剛",
|
||||||
@@ -351,20 +277,13 @@
|
|||||||
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
|
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
|
||||||
},
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"configure": "配置",
|
|
||||||
"addProvider": "新增服務商",
|
"addProvider": "新增服務商",
|
||||||
"addModel": "新增模型",
|
|
||||||
"modelId": "模型 ID",
|
"modelId": "模型 ID",
|
||||||
"modelLabel": "顯示名稱",
|
|
||||||
"streaming": "啟用串流輸出",
|
|
||||||
"deleteProvider": "刪除服務商",
|
"deleteProvider": "刪除服務商",
|
||||||
"deleteModel": "刪除模型",
|
|
||||||
"noModels": "尚未配置模型。新增模型以開始使用。",
|
|
||||||
"validationError": "測試沒通過",
|
"validationError": "測試沒通過",
|
||||||
"addModelFirst": "先新增一個模型。",
|
"addModelFirst": "先新增一個模型。",
|
||||||
"displayName": "顯示名稱",
|
"displayName": "顯示名稱",
|
||||||
"bedrockApiKey": "Bedrock API Key",
|
"bedrockApiKey": "Bedrock API Key",
|
||||||
"bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。",
|
|
||||||
"awsAccessKeyId": "AWS 存取金鑰 ID",
|
"awsAccessKeyId": "AWS 存取金鑰 ID",
|
||||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||||
"awsRegion": "AWS 區域",
|
"awsRegion": "AWS 區域",
|
||||||
@@ -372,7 +291,6 @@
|
|||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"enterApiKey": "輸入 API Key",
|
"enterApiKey": "輸入 API Key",
|
||||||
"enterSecretKey": "輸入 Secret Access Key",
|
"enterSecretKey": "輸入 Secret Access Key",
|
||||||
"baseUrl": "基礎 URL",
|
|
||||||
"optional": "(可選)",
|
"optional": "(可選)",
|
||||||
"getApiKey": "取得 API Key",
|
"getApiKey": "取得 API Key",
|
||||||
"noTools": "不支援工具呼叫",
|
"noTools": "不支援工具呼叫",
|
||||||
@@ -388,8 +306,6 @@
|
|||||||
"modelIdEmpty": "模型 ID 不能為空",
|
"modelIdEmpty": "模型 ID 不能為空",
|
||||||
"modelIdExists": "此模型 ID 已存在",
|
"modelIdExists": "此模型 ID 已存在",
|
||||||
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
|
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
|
||||||
"typeToConfirm": "輸入「{name}」以確認",
|
|
||||||
"typeProviderName": "輸入服務商名稱...",
|
|
||||||
"requestFailed": "請求失敗({status})",
|
"requestFailed": "請求失敗({status})",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
@@ -583,10 +499,6 @@
|
|||||||
"VALIDATION_TIMEOUT": {
|
"VALIDATION_TIMEOUT": {
|
||||||
"label": "檢查逾時(毫秒)"
|
"label": "檢查逾時(毫秒)"
|
||||||
},
|
},
|
||||||
"ENABLE_HISTORY_XML_REPLACE": {
|
|
||||||
"label": "精簡舊圖",
|
|
||||||
"description": "對話歷史裡的舊圖表只留簡短標記"
|
|
||||||
},
|
|
||||||
"ALLOW_PRIVATE_URLS": {
|
"ALLOW_PRIVATE_URLS": {
|
||||||
"label": "存取內網",
|
"label": "存取內網",
|
||||||
"description": "關閉後,自訂模型位址不能指向內網,更安全"
|
"description": "關閉後,自訂模型位址不能指向內網,更安全"
|
||||||
@@ -697,8 +609,7 @@
|
|||||||
"lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。",
|
"lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。",
|
||||||
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
|
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
|
||||||
"recent": "最近的圖紙",
|
"recent": "最近的圖紙",
|
||||||
"drawYourself": "從空白畫布開始",
|
"drawYourself": "從空白畫布開始"
|
||||||
"openFileHint": "接著編輯已有的圖"
|
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"waiting": "正在等待 MCP 伺服器…",
|
"waiting": "正在等待 MCP 伺服器…",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user