mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Compare commits
28 Commits
feat/enhan
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e905b1fc6 | ||
|
|
8c431ee6ed | ||
|
|
86420a42c6 | ||
|
|
0baf21fadb | ||
|
|
a54068fec2 | ||
|
|
e25fd367d5 | ||
|
|
3264244fe9 | ||
|
|
d8cdd049d1 | ||
|
|
b1bc1a6dc6 | ||
|
|
8b578a456e | ||
|
|
05d58025c4 | ||
|
|
4be64317b3 | ||
|
|
2fac6323f0 | ||
|
|
a415c46b66 | ||
|
|
3894abd9ed | ||
|
|
6965a54f48 | ||
|
|
46567cb0b8 | ||
|
|
9f77199272 | ||
|
|
77f2569a3b | ||
|
|
cbb92bd636 | ||
|
|
8d898d8adc | ||
|
|
1e0b1ed970 | ||
|
|
1d03d10ba8 | ||
|
|
e893bd60f9 | ||
|
|
9aaf9bf31f | ||
|
|
150eb1ff63 | ||
|
|
215a101f54 | ||
|
|
e00938d9d3 |
@@ -1,6 +1,3 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
|
||||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug to help us improve
|
||||
title: '[Bug] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your thoughts.
|
||||
|
||||
## Bug Description
|
||||
A brief description of the issue.
|
||||
|
||||
## Steps to Reproduce
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll to '...'
|
||||
4. See error
|
||||
|
||||
## Expected Behavior
|
||||
What you expected to happen.
|
||||
|
||||
## Actual Behavior
|
||||
What actually happened.
|
||||
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain the problem.
|
||||
|
||||
## Environment
|
||||
- OS: [e.g. Windows 11, macOS 14]
|
||||
- Browser: [e.g. Chrome 120, Safari 17]
|
||||
- Version: [e.g. 1.0.0]
|
||||
|
||||
## Additional Context
|
||||
Any other information about the problem.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/DayuanJiang/next-ai-draw-io/discussions
|
||||
about: Have questions or ideas? Feel free to start a discussion
|
||||
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature for this project
|
||||
title: '[Feature] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
|
||||
|
||||
## Feature Description
|
||||
A brief description of the feature you'd like.
|
||||
|
||||
## Problem Context
|
||||
Is this related to a problem? Please describe.
|
||||
e.g. I'm always frustrated when [...]
|
||||
|
||||
## Proposed Solution
|
||||
How you'd like this feature to work.
|
||||
|
||||
## Alternatives Considered
|
||||
Any alternative solutions or features you've considered.
|
||||
|
||||
## Additional Context
|
||||
Any other information or screenshots about the feature request.
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
23
.vscode/settings.json
vendored
Normal file
23
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
35
CONTRIBUTING.md
Normal file
35
CONTRIBUTING.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Contributing
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/next-ai-draw-io.git
|
||||
cd next-ai-draw-io
|
||||
npm install
|
||||
cp env.example .env.local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
We use [Biome](https://biomejs.dev/) for linting and formatting:
|
||||
|
||||
```bash
|
||||
npm run format # Format code
|
||||
npm run lint # Check lint errors
|
||||
npm run check # Run all checks (CI)
|
||||
```
|
||||
|
||||
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
||||
|
||||
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Create a feature branch
|
||||
2. Make changes and ensure `npm run check` passes
|
||||
3. Submit PR against `main` with a clear description
|
||||
|
||||
## Issues
|
||||
|
||||
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
||||
14
README.md
14
README.md
@@ -88,6 +88,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
|
||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
|
||||
@@ -115,6 +116,14 @@ docker run -d -p 3000:3000 \
|
||||
ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
Or use an env file (create one from `env.example`):
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
# Edit .env with your configuration
|
||||
docker run -d -p 3000:3000 --env-file .env ghcr.io/dayuanjiang/next-ai-draw-io:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
Replace the environment variables with your preferred AI provider configuration. See [Multi-Provider Support](#multi-provider-support) for available options.
|
||||
@@ -132,8 +141,6 @@ cd next-ai-draw-io
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
3. Configure your AI provider:
|
||||
@@ -146,9 +153,10 @@ cp env.example .env.local
|
||||
|
||||
Edit `.env.local` and configure your chosen provider:
|
||||
|
||||
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- Set `AI_MODEL` to the specific model you want to use
|
||||
- Add the required API keys for your provider
|
||||
- `TEMPERATURE`: Optional temperature setting (e.g., `0` for deterministic output). Leave unset for models that don't support it (e.g., reasoning models).
|
||||
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
|
||||
|
||||
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.
|
||||
|
||||
@@ -88,6 +88,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
|
||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
||||
|
||||
@@ -146,9 +147,10 @@ cp env.example .env.local
|
||||
|
||||
编辑 `.env.local` 并配置您选择的提供商:
|
||||
|
||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||
- 添加您的提供商所需的API密钥
|
||||
- `TEMPERATURE`:可选的温度设置(例如 `0` 表示确定性输出)。对于不支持此参数的模型(如推理模型),请不要设置。
|
||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
||||
|
||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||
|
||||
@@ -88,6 +88,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
|
||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
||||
|
||||
@@ -146,9 +147,10 @@ cp env.example .env.local
|
||||
|
||||
`.env.local`を編集して選択したプロバイダーを設定:
|
||||
|
||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
- `AI_MODEL`を使用する特定のモデルに設定
|
||||
- プロバイダーに必要なAPIキーを追加
|
||||
- `TEMPERATURE`:オプションの温度設定(例:`0`で決定論的な出力)。温度をサポートしないモデル(推論モデルなど)では設定しないでください。
|
||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
||||
|
||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 - Next AI Draw.io",
|
||||
description: "AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||
description:
|
||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||
};
|
||||
}
|
||||
|
||||
export default function AboutCN() {
|
||||
return (
|
||||
@@ -16,14 +17,23 @@ export default function AboutCN() {
|
||||
<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">
|
||||
<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
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
编辑器
|
||||
</Link>
|
||||
<Link href="/about/cn" className="text-blue-600 font-semibold">
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
关于
|
||||
</Link>
|
||||
<a
|
||||
@@ -45,22 +55,41 @@ export default function AboutCN() {
|
||||
<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>
|
||||
<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 className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link href="/about/cn" className="text-blue-600 font-semibold">中文</Link>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600">日本語</Link>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-amber-800">
|
||||
本应用设计运行于 Claude Opus 4.5 以获得最佳性能。但由于流量超出预期,运行顶级模型的成本变得难以承受。为避免服务中断并控制成本,我已将后端切换至 Claude Haiku 4.5。
|
||||
本应用设计运行于 Claude Opus 4.5
|
||||
以获得最佳性能。但由于流量超出预期,运行顶级模型的成本变得难以承受。为避免服务中断并控制成本,我已将后端切换至
|
||||
Claude Haiku 4.5。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,80 +98,167 @@ export default function AboutCN() {
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">功能特性</h2>
|
||||
<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编辑前的图表版本</li>
|
||||
<li><strong>交互式聊天界面</strong>:与AI实时对话来完善您的图表</li>
|
||||
<li><strong>AWS架构图支持</strong>:专门支持生成AWS架构图</li>
|
||||
<li><strong>动画连接器</strong>:在图表元素之间创建动态动画连接器,实现更好的可视化效果</li>
|
||||
<li>
|
||||
<strong>LLM驱动的图表创建</strong>
|
||||
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
||||
</li>
|
||||
<li>
|
||||
<strong>基于图像的图表复制</strong>
|
||||
:上传现有图表或图像,让AI自动复制和增强
|
||||
</li>
|
||||
<li>
|
||||
<strong>图表历史记录</strong>
|
||||
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
</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>
|
||||
<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">
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">动画Transformer连接器</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
动画Transformer连接器
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>提示词:</strong> 给我一个带有<strong>动画连接器</strong>的Transformer架构图。
|
||||
<strong>提示词:</strong> 给我一个带有
|
||||
<strong>动画连接器</strong>的Transformer架构图。
|
||||
</p>
|
||||
<Image src="/animated_connectors.svg" alt="带动画连接器的Transformer架构" width={480} height={360} className="mx-auto" />
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="带动画连接器的Transformer架构"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture 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">GCP架构图</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCP架构图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用<strong>GCP图标</strong>生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>GCP图标</strong>
|
||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<Image src="/gcp_demo.svg" alt="GCP架构图" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS架构图</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS架构图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用<strong>AWS图标</strong>生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>AWS图标</strong>
|
||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<Image src="/aws_demo.svg" alt="AWS架构图" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure架构图</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure架构图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 使用<strong>Azure图标</strong>生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||
<strong>提示词:</strong> 使用
|
||||
<strong>Azure图标</strong>
|
||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||
</p>
|
||||
<Image src="/azure_demo.svg" alt="Azure架构图" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure架构图"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">猫咪素描</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
猫咪素描
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>提示词:</strong> 给我画一只可爱的猫。
|
||||
<strong>提示词:</strong>{" "}
|
||||
给我画一只可爱的猫。
|
||||
</p>
|
||||
<Image src="/cat_demo.svg" alt="猫咪绘图" width={240} height={240} className="mx-auto" />
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫咪绘图"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">工作原理</h2>
|
||||
<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>react-drawio</strong>:用于图表表示和操作</li>
|
||||
<li>
|
||||
<strong>Next.js</strong>:用于前端框架和路由
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):用于流式AI响应和多提供商支持
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</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>
|
||||
<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>AWS Bedrock(默认)</li>
|
||||
<li>OpenAI / OpenAI兼容API(通过 <code>OPENAI_BASE_URL</code>)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI兼容API(通过{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
@@ -151,12 +267,15 @@ export default function AboutCN() {
|
||||
<li>DeepSeek</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注意:<code>claude-sonnet-4-5</code> 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">支持与联系</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
支持与联系
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
@@ -167,14 +286,24 @@ export default function AboutCN() {
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
如果您觉得这个项目有用,请考虑{" "}
|
||||
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
<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">
|
||||
<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
|
||||
@@ -201,5 +330,5 @@ export default function AboutCN() {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "概要 - Next AI Draw.io",
|
||||
description: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||
keywords: ["AIダイアグラム", "draw.io", "AWSアーキテクチャ", "GCPダイアグラム", "Azureダイアグラム", "LLM"],
|
||||
};
|
||||
description:
|
||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||
keywords: [
|
||||
"AIダイアグラム",
|
||||
"draw.io",
|
||||
"AWSアーキテクチャ",
|
||||
"GCPダイアグラム",
|
||||
"Azureダイアグラム",
|
||||
"LLM",
|
||||
],
|
||||
}
|
||||
|
||||
export default function AboutJA() {
|
||||
return (
|
||||
@@ -16,14 +24,23 @@ export default function AboutJA() {
|
||||
<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">
|
||||
<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
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
エディタ
|
||||
</Link>
|
||||
<Link href="/about/ja" className="text-blue-600 font-semibold">
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
概要
|
||||
</Link>
|
||||
<a
|
||||
@@ -45,22 +62,43 @@ export default function AboutJA() {
|
||||
<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>
|
||||
<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搭載のダイアグラム作成ツール - チャット、描画、可視化
|
||||
AI搭載のダイアグラム作成ツール -
|
||||
チャット、描画、可視化
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600">中文</Link>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link href="/about/ja" className="text-blue-600 font-semibold">日本語</Link>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-amber-800">
|
||||
本アプリは最高のパフォーマンスを発揮するため、Claude Opus 4.5 で動作するよう設計されています。しかし、予想以上のトラフィックにより、最上位モデルの運用コストが負担となっています。サービスの中断を避け、コストを管理するため、バックエンドを Claude Haiku 4.5 に切り替えました。
|
||||
本アプリは最高のパフォーマンスを発揮するため、Claude
|
||||
Opus 4.5
|
||||
で動作するよう設計されています。しかし、予想以上のトラフィックにより、最上位モデルの運用コストが負担となっています。サービスの中断を避け、コストを管理するため、バックエンドを
|
||||
Claude Haiku 4.5 に切り替えました。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,80 +107,176 @@ export default function AboutJA() {
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">機能</h2>
|
||||
<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編集前のダイアグラムの以前のバージョンを表示・復元可能</li>
|
||||
<li><strong>インタラクティブなチャットインターフェース</strong>:AIとリアルタイムでコミュニケーションしてダイアグラムを改善</li>
|
||||
<li><strong>AWSアーキテクチャダイアグラムサポート</strong>:AWSアーキテクチャダイアグラムの生成を専門的にサポート</li>
|
||||
<li><strong>アニメーションコネクタ</strong>:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成</li>
|
||||
<li>
|
||||
<strong>LLM搭載のダイアグラム作成</strong>
|
||||
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
||||
</li>
|
||||
<li>
|
||||
<strong>画像ベースのダイアグラム複製</strong>
|
||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
</li>
|
||||
<li>
|
||||
<strong>ダイアグラム履歴</strong>
|
||||
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
</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>
|
||||
<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">
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">アニメーションTransformerコネクタ</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
アニメーションTransformerコネクタ
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>プロンプト:</strong> <strong>アニメーションコネクタ</strong>付きのTransformerアーキテクチャ図を作成してください。
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
付きのTransformerアーキテクチャ図を作成してください。
|
||||
</p>
|
||||
<Image src="/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width={480} height={360} className="mx-auto" />
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture 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">GCPアーキテクチャ図</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCPアーキテクチャ図
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong> <strong>GCPアイコン</strong>を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>GCPアイコン</strong>
|
||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<Image src="/gcp_demo.svg" alt="GCPアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCPアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWSアーキテクチャ図</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWSアーキテクチャ図
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong> <strong>AWSアイコン</strong>を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>AWSアイコン</strong>
|
||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<Image src="/aws_demo.svg" alt="AWSアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWSアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azureアーキテクチャ図</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azureアーキテクチャ図
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong> <strong>Azureアイコン</strong>を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
<strong>Azureアイコン</strong>
|
||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||
</p>
|
||||
<Image src="/azure_demo.svg" alt="Azureアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azureアーキテクチャ図"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">猫のスケッチ</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
猫のスケッチ
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>プロンプト:</strong> かわいい猫を描いてください。
|
||||
<strong>プロンプト:</strong>{" "}
|
||||
かわいい猫を描いてください。
|
||||
</p>
|
||||
<Image src="/cat_demo.svg" alt="猫の絵" width={240} height={240} className="mx-auto" />
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="猫の絵"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</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>
|
||||
<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>react-drawio</strong>:ダイアグラムの表現と操作</li>
|
||||
<li>
|
||||
<strong>Next.js</strong>
|
||||
:フロントエンドフレームワークとルーティング
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</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>
|
||||
<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>AWS Bedrock(デフォルト)</li>
|
||||
<li>OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>経由)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||
経由)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
@@ -151,12 +285,15 @@ export default function AboutJA() {
|
||||
<li>DeepSeek</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注:<code>claude-sonnet-4-5</code>はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
注:<code>claude-sonnet-4-5</code>
|
||||
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">サポート&お問い合わせ</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
@@ -167,14 +304,24 @@ export default function AboutJA() {
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
<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">
|
||||
<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
|
||||
@@ -196,10 +343,11 @@ export default function AboutJA() {
|
||||
<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搭載ダイアグラムジェネレーター
|
||||
Next AI Draw.io -
|
||||
オープンソースAI搭載ダイアグラムジェネレーター
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About - Next AI Draw.io",
|
||||
description: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||
keywords: ["AI diagram", "draw.io", "AWS architecture", "GCP diagram", "Azure diagram", "LLM"],
|
||||
};
|
||||
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",
|
||||
],
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
@@ -16,14 +24,23 @@ export default function About() {
|
||||
<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">
|
||||
<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
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Editor
|
||||
</Link>
|
||||
<Link href="/about" className="text-blue-600 font-semibold">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<a
|
||||
@@ -45,105 +62,236 @@ export default function About() {
|
||||
<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>
|
||||
<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-Powered Diagram Creation Tool - Chat, Draw, Visualize
|
||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||
Visualize
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||
<Link href="/about" className="text-blue-600 font-semibold">English</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600">中文</Link>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
中文
|
||||
</Link>
|
||||
<span className="text-gray-400">|</span>
|
||||
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600">日本語</Link>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
日本語
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-amber-800">
|
||||
This app is designed to run on Claude Opus 4.5 for best performance. However, due to higher-than-expected traffic, running the top-tier model has become cost-prohibitive. To avoid service interruptions and manage costs, I have switched the backend to Claude Haiku 4.5.
|
||||
This app is designed to run on Claude Opus 4.5 for
|
||||
best performance. However, due to
|
||||
higher-than-expected traffic, running the top-tier
|
||||
model has become cost-prohibitive. To avoid service
|
||||
interruptions and manage costs, I have switched the
|
||||
backend to Claude Haiku 4.5.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
A Next.js web application that integrates AI capabilities with draw.io diagrams.
|
||||
Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||
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 */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Features</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Features
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li><strong>LLM-Powered Diagram Creation</strong>: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands</li>
|
||||
<li><strong>Image-Based Diagram Replication</strong>: Upload existing diagrams or images and have the AI replicate and enhance them automatically</li>
|
||||
<li><strong>Diagram History</strong>: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing</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>
|
||||
<li>
|
||||
<strong>LLM-Powered Diagram Creation</strong>:
|
||||
Leverage Large Language Models to create and
|
||||
manipulate draw.io diagrams directly through natural
|
||||
language commands
|
||||
</li>
|
||||
<li>
|
||||
<strong>Image-Based Diagram Replication</strong>:
|
||||
Upload existing diagrams or images and have the AI
|
||||
replicate and enhance them automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Diagram History</strong>: Comprehensive
|
||||
version control that tracks all changes, allowing
|
||||
you to view and restore previous versions of your
|
||||
diagrams before the AI editing
|
||||
</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>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Examples</h2>
|
||||
<p className="text-gray-700 mb-6">Here are some example prompts and their generated diagrams:</p>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Examples
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Here are some example prompts and their generated
|
||||
diagrams:
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Animated Transformer */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Animated Transformer Connectors</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Animated Transformer Connectors
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an <strong>animated connector</strong> diagram of transformer's architecture.
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated connector</strong> diagram of
|
||||
transformer's architecture.
|
||||
</p>
|
||||
<Image src="/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width={480} height={360} className="mx-auto" />
|
||||
<Image
|
||||
src="/animated_connectors.svg"
|
||||
alt="Transformer Architecture with Animated Connectors"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cloud Architecture 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">GCP Architecture Diagram</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
GCP Architecture Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a GCP architecture diagram with <strong>GCP icons</strong>. Users connect to a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate a GCP
|
||||
architecture diagram with{" "}
|
||||
<strong>GCP icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
</p>
|
||||
<Image src="/gcp_demo.svg" alt="GCP Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/gcp_demo.svg"
|
||||
alt="GCP Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS Architecture Diagram</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
AWS Architecture Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an AWS architecture diagram with <strong>AWS icons</strong>. Users connect to a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate an AWS
|
||||
architecture diagram with{" "}
|
||||
<strong>AWS icons</strong>. Users connect to
|
||||
a frontend hosted on an instance.
|
||||
</p>
|
||||
<Image src="/aws_demo.svg" alt="AWS Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/aws_demo.svg"
|
||||
alt="AWS Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure Architecture Diagram</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Azure Architecture Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate an Azure architecture diagram with <strong>Azure icons</strong>. Users connect to a frontend hosted on an instance.
|
||||
<strong>Prompt:</strong> Generate an Azure
|
||||
architecture diagram with{" "}
|
||||
<strong>Azure icons</strong>. Users connect
|
||||
to a frontend hosted on an instance.
|
||||
</p>
|
||||
<Image src="/azure_demo.svg" alt="Azure Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||
<Image
|
||||
src="/azure_demo.svg"
|
||||
alt="Azure Architecture Diagram"
|
||||
width={400}
|
||||
height={300}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Cat Sketch</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Cat Sketch
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Draw a cute cat for me.
|
||||
<strong>Prompt:</strong> Draw a cute cat for
|
||||
me.
|
||||
</p>
|
||||
<Image src="/cat_demo.svg" alt="Cat Drawing" width={240} height={240} className="mx-auto" />
|
||||
<Image
|
||||
src="/cat_demo.svg"
|
||||
alt="Cat Drawing"
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">How It Works</h2>
|
||||
<p className="text-gray-700 mb-4">The application uses the following technologies:</p>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
The application uses the following technologies:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li><strong>Next.js</strong>: For the frontend framework and routing</li>
|
||||
<li><strong>Vercel AI SDK</strong> (<code>ai</code> + <code>@ai-sdk/*</code>): For streaming AI responses and multi-provider support</li>
|
||||
<li><strong>react-drawio</strong>: For diagram representation and manipulation</li>
|
||||
<li>
|
||||
<strong>Next.js</strong>: For the frontend framework
|
||||
and routing
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>): For streaming AI responses
|
||||
and multi-provider support
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>: For diagram
|
||||
representation and manipulation
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||
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 */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Multi-Provider Support</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Multi-Provider Support
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>AWS Bedrock (default)</li>
|
||||
<li>OpenAI / OpenAI-compatible APIs (via <code>OPENAI_BASE_URL</code>)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
@@ -152,12 +300,17 @@ export default function About() {
|
||||
<li>DeepSeek</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||
draw.io diagrams with AWS logos, so if you want to
|
||||
create AWS architecture diagrams, this is the best
|
||||
choice.
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Support & Contact</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
Support & Contact
|
||||
</h2>
|
||||
<iframe
|
||||
src="https://github.com/sponsors/DayuanJiang/button"
|
||||
title="Sponsor DayuanJiang"
|
||||
@@ -168,14 +321,24 @@ export default function About() {
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
If you find this project useful, please consider{" "}
|
||||
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
sponsoring
|
||||
</a>{" "}
|
||||
to help host the live demo site!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
For support or inquiries, please open an issue on the{" "}
|
||||
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
</a>{" "}
|
||||
or contact: me[at]jiang.jp
|
||||
@@ -197,10 +360,11 @@ export default function About() {
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io - Open Source AI-Powered Diagram Generator
|
||||
Next AI Draw.io - Open Source AI-Powered Diagram
|
||||
Generator
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,247 +1,405 @@
|
||||
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
||||
import { getAIModel } from '@/lib/ai-providers';
|
||||
import { findCachedResponse } from '@/lib/cached-responses';
|
||||
import { setTraceInput, setTraceOutput, getTelemetryConfig, wrapWithObserve } from '@/lib/langfuse';
|
||||
import { getSystemPrompt } from '@/lib/system-prompts';
|
||||
import { z } from "zod";
|
||||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import { z } from "zod"
|
||||
import { getAIModel } from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
getTelemetryConfig,
|
||||
setTraceInput,
|
||||
setTraceOutput,
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
|
||||
export const maxDuration = 300;
|
||||
export const maxDuration = 60
|
||||
|
||||
// File upload limits (must match client-side)
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_FILES = 5
|
||||
|
||||
// Helper function to validate file parts in messages
|
||||
function validateFileParts(messages: any[]): { valid: boolean; error?: string } {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const fileParts = lastMessage?.parts?.filter((p: any) => p.type === 'file') || [];
|
||||
function validateFileParts(messages: any[]): {
|
||||
valid: boolean
|
||||
error?: string
|
||||
} {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const fileParts =
|
||||
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
||||
|
||||
if (fileParts.length > MAX_FILES) {
|
||||
return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.` };
|
||||
}
|
||||
|
||||
for (const filePart of fileParts) {
|
||||
// Data URLs format: data:image/png;base64,<data>
|
||||
// Base64 increases size by ~33%, so we check the decoded size
|
||||
if (filePart.url && filePart.url.startsWith('data:')) {
|
||||
const base64Data = filePart.url.split(',')[1];
|
||||
if (base64Data) {
|
||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4);
|
||||
if (sizeInBytes > MAX_FILE_SIZE) {
|
||||
return { valid: false, error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.` };
|
||||
if (fileParts.length > MAX_FILES) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
for (const filePart of fileParts) {
|
||||
// Data URLs format: data:image/png;base64,<data>
|
||||
// Base64 increases size by ~33%, so we check the decoded size
|
||||
if (filePart.url?.startsWith("data:")) {
|
||||
const base64Data = filePart.url.split(",")[1]
|
||||
if (base64Data) {
|
||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||
if (sizeInBytes > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Helper function to check if diagram is minimal/empty
|
||||
function isMinimalDiagram(xml: string): boolean {
|
||||
const stripped = xml.replace(/\s/g, '');
|
||||
return !stripped.includes('id="2"');
|
||||
const stripped = xml.replace(/\s/g, "")
|
||||
return !stripped.includes('id="2"')
|
||||
}
|
||||
|
||||
// Helper function to fix tool call inputs for Bedrock API
|
||||
// Bedrock requires toolUse.input to be a JSON object, not a string
|
||||
function fixToolCallInputs(messages: any[]): any[] {
|
||||
return messages.map((msg) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||
return msg
|
||||
}
|
||||
const fixedContent = msg.content.map((part: any) => {
|
||||
if (part.type === "tool-call") {
|
||||
if (typeof part.input === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(part.input)
|
||||
return { ...part, input: parsed }
|
||||
} catch {
|
||||
// If parsing fails, wrap the string in an object
|
||||
return { ...part, input: { rawInput: part.input } }
|
||||
}
|
||||
}
|
||||
// Input is already an object, but verify it's not null/undefined
|
||||
if (part.input === null || part.input === undefined) {
|
||||
return { ...part, input: {} }
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
return { ...msg, content: fixedContent }
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
const toolCallId = `cached-${Date.now()}`;
|
||||
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' });
|
||||
},
|
||||
});
|
||||
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 });
|
||||
return createUIMessageStreamResponse({ stream })
|
||||
}
|
||||
|
||||
// Inner handler function
|
||||
async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// Check for access code
|
||||
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
|
||||
if (accessCodes.length > 0) {
|
||||
const accessCodeHeader = req.headers.get('x-access-code');
|
||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{ error: 'Invalid or missing access code. Please configure it in Settings.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
// Check for access code
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length > 0) {
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { messages, xml, sessionId } = await req.json();
|
||||
const { messages, xml, sessionId } = await req.json()
|
||||
|
||||
// Get user IP for Langfuse tracking
|
||||
const forwardedFor = req.headers.get('x-forwarded-for');
|
||||
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous';
|
||||
// Get user IP for Langfuse tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
|
||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||
const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200
|
||||
? sessionId
|
||||
: undefined;
|
||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||
const validSessionId =
|
||||
sessionId && typeof sessionId === "string" && sessionId.length <= 200
|
||||
? sessionId
|
||||
: undefined
|
||||
|
||||
// Extract user input text for Langfuse trace
|
||||
const currentMessage = messages[messages.length - 1];
|
||||
const userInputText = currentMessage?.parts?.find((p: any) => p.type === 'text')?.text || '';
|
||||
// Extract user input text for Langfuse trace
|
||||
const currentMessage = messages[messages.length - 1]
|
||||
const userInputText =
|
||||
currentMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
|
||||
// Update Langfuse trace with input, session, and user
|
||||
setTraceInput({
|
||||
input: userInputText,
|
||||
sessionId: validSessionId,
|
||||
userId: userId,
|
||||
});
|
||||
// Update Langfuse trace with input, session, and user
|
||||
setTraceInput({
|
||||
input: userInputText,
|
||||
sessionId: validSessionId,
|
||||
userId: userId,
|
||||
})
|
||||
|
||||
// === FILE VALIDATION START ===
|
||||
const fileValidation = validateFileParts(messages);
|
||||
if (!fileValidation.valid) {
|
||||
return Response.json({ error: fileValidation.error }, { status: 400 });
|
||||
}
|
||||
// === FILE VALIDATION END ===
|
||||
|
||||
// === CACHE CHECK START ===
|
||||
const isFirstMessage = messages.length === 1;
|
||||
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(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) {
|
||||
console.log('[Cache] Returning cached response for:', textPart?.text);
|
||||
return createCachedStreamResponse(cached.xml);
|
||||
// === FILE VALIDATION START ===
|
||||
const fileValidation = validateFileParts(messages)
|
||||
if (!fileValidation.valid) {
|
||||
return Response.json({ error: fileValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
// === CACHE CHECK END ===
|
||||
// === FILE VALIDATION END ===
|
||||
|
||||
// Get AI model from environment configuration
|
||||
const { model, providerOptions, headers, modelId } = getAIModel();
|
||||
// === CACHE CHECK START ===
|
||||
const isFirstMessage = messages.length === 1
|
||||
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
|
||||
|
||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||
const systemMessage = getSystemPrompt(modelId);
|
||||
// DEBUG: Log cache check conditions
|
||||
console.log("[Cache DEBUG] messages.length:", messages.length)
|
||||
console.log("[Cache DEBUG] isFirstMessage:", isFirstMessage)
|
||||
console.log("[Cache DEBUG] xml length:", xml?.length || 0)
|
||||
console.log("[Cache DEBUG] xml preview:", xml?.substring(0, 200))
|
||||
console.log("[Cache DEBUG] isEmptyDiagram:", isEmptyDiagram)
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
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")
|
||||
|
||||
// Extract text from the last message parts
|
||||
const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || '';
|
||||
console.log("[Cache DEBUG] textPart?.text:", textPart?.text)
|
||||
console.log("[Cache DEBUG] hasFilePart:", !!filePart)
|
||||
|
||||
// Extract file parts (images) from the last message
|
||||
const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || [];
|
||||
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
console.log("[Cache DEBUG] cached found:", !!cached)
|
||||
|
||||
if (cached) {
|
||||
console.log(
|
||||
"[Cache] Returning cached response for:",
|
||||
textPart?.text,
|
||||
)
|
||||
return createCachedStreamResponse(cached.xml)
|
||||
} else {
|
||||
console.log("[Cache DEBUG] No cache match - checking why...")
|
||||
console.log(
|
||||
"[Cache DEBUG] Exact promptText:",
|
||||
JSON.stringify(textPart?.text),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.log("[Cache DEBUG] Skipping cache check - conditions not met")
|
||||
if (!isFirstMessage)
|
||||
console.log("[Cache DEBUG] Reason: not first message")
|
||||
if (!isEmptyDiagram)
|
||||
console.log("[Cache DEBUG] Reason: diagram not empty")
|
||||
}
|
||||
// === CACHE CHECK END ===
|
||||
|
||||
// Get AI model from environment configuration
|
||||
const { model, providerOptions, headers, modelId } = getAIModel()
|
||||
|
||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||
const systemMessage = getSystemPrompt(modelId)
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Extract text from the last message parts
|
||||
const lastMessageText =
|
||||
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
|
||||
|
||||
// Extract file parts (images) from the last message
|
||||
const fileParts =
|
||||
lastMessage.parts?.filter((part: any) => part.type === "file") || []
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
"""md
|
||||
${lastMessageText}
|
||||
"""`;
|
||||
"""`
|
||||
|
||||
// Convert UIMessages to ModelMessages and add system message
|
||||
const modelMessages = convertToModelMessages(messages);
|
||||
// Convert UIMessages to ModelMessages and add system message
|
||||
const modelMessages = convertToModelMessages(messages)
|
||||
|
||||
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
||||
let enhancedMessages = modelMessages.filter((msg: any) =>
|
||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0
|
||||
);
|
||||
// Debug: log raw messages to see what's coming in
|
||||
console.log(
|
||||
"[DEBUG] Raw UI messages:",
|
||||
JSON.stringify(
|
||||
messages.map((m: any, i: number) => ({
|
||||
index: i,
|
||||
role: m.role,
|
||||
partsCount: m.parts?.length,
|
||||
parts: m.parts?.map((p: any) => ({
|
||||
type: p.type,
|
||||
toolName: p.toolName,
|
||||
toolCallId: p.toolCallId,
|
||||
state: p.state,
|
||||
inputType: p.input ? typeof p.input : undefined,
|
||||
input: p.input,
|
||||
})),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
// Update the last message with user input only (XML moved to separate cached system message)
|
||||
if (enhancedMessages.length >= 1) {
|
||||
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1];
|
||||
if (lastModelMessage.role === 'user') {
|
||||
// Build content array with user input text and file parts
|
||||
const contentParts: any[] = [
|
||||
{ type: 'text', text: formattedUserInput }
|
||||
];
|
||||
// Fix tool call inputs for Bedrock API (requires JSON objects, not strings)
|
||||
const fixedMessages = fixToolCallInputs(modelMessages)
|
||||
|
||||
// Add image parts back
|
||||
for (const filePart of fileParts) {
|
||||
contentParts.push({
|
||||
type: 'image',
|
||||
image: filePart.url,
|
||||
mimeType: filePart.mediaType
|
||||
});
|
||||
}
|
||||
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
||||
let enhancedMessages = fixedMessages.filter(
|
||||
(msg: any) =>
|
||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
||||
)
|
||||
|
||||
enhancedMessages = [
|
||||
...enhancedMessages.slice(0, -1),
|
||||
{ ...lastModelMessage, content: contentParts }
|
||||
];
|
||||
// Update the last message with user input only (XML moved to separate cached system message)
|
||||
if (enhancedMessages.length >= 1) {
|
||||
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
|
||||
if (lastModelMessage.role === "user") {
|
||||
// Build content array with user input text and file parts
|
||||
const contentParts: any[] = [
|
||||
{ type: "text", text: formattedUserInput },
|
||||
]
|
||||
|
||||
// Add image parts back
|
||||
for (const filePart of fileParts) {
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
image: filePart.url,
|
||||
mimeType: filePart.mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
enhancedMessages = [
|
||||
...enhancedMessages.slice(0, -1),
|
||||
{ ...lastModelMessage, content: contentParts },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache point to the last assistant message in conversation history
|
||||
// This caches the entire conversation prefix for subsequent requests
|
||||
// Strategy: system (cached) + history with last assistant (cached) + new user message
|
||||
if (enhancedMessages.length >= 2) {
|
||||
// Find the last assistant message (should be second-to-last, before current user message)
|
||||
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
|
||||
if (enhancedMessages[i].role === 'assistant') {
|
||||
enhancedMessages[i] = {
|
||||
...enhancedMessages[i],
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: 'default' } },
|
||||
},
|
||||
};
|
||||
break; // Only cache the last assistant message
|
||||
}
|
||||
// Add cache point to the last assistant message in conversation history
|
||||
// This caches the entire conversation prefix for subsequent requests
|
||||
// Strategy: system (cached) + history with last assistant (cached) + new user message
|
||||
if (enhancedMessages.length >= 2) {
|
||||
// Find the last assistant message (should be second-to-last, before current user message)
|
||||
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
|
||||
if (enhancedMessages[i].role === "assistant") {
|
||||
enhancedMessages[i] = {
|
||||
...enhancedMessages[i],
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}
|
||||
break // Only cache the last assistant message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System messages with multiple cache breakpoints for optimal caching:
|
||||
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
|
||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
||||
// This allows: if only user message changes, both system caches are reused
|
||||
// if XML changes, instruction cache is still reused
|
||||
const systemMessages = [
|
||||
// Cache breakpoint 1: Instructions (rarely change)
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: systemMessage,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: 'default' } },
|
||||
},
|
||||
},
|
||||
// Cache breakpoint 2: Current diagram XML context
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: `Current diagram XML:\n"""xml\n${xml || ''}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: 'default' } },
|
||||
},
|
||||
},
|
||||
];
|
||||
// System messages with multiple cache breakpoints for optimal caching:
|
||||
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
|
||||
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
|
||||
// This allows: if only user message changes, both system caches are reused
|
||||
// if XML changes, instruction cache is still reused
|
||||
const systemMessages = [
|
||||
// Cache breakpoint 1: Instructions (rarely change)
|
||||
{
|
||||
role: "system" as const,
|
||||
content: systemMessage,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
},
|
||||
// Cache breakpoint 2: Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
content: `Current diagram XML:\n"""xml\n${xml || ""}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`,
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const allMessages = [...systemMessages, ...enhancedMessages];
|
||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }),
|
||||
...(headers && { headers }),
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId, userId }),
|
||||
}),
|
||||
onFinish: ({ text, usage, providerMetadata }) => {
|
||||
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
|
||||
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
|
||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
|
||||
setTraceOutput(text, {
|
||||
promptTokens: usage?.inputTokens,
|
||||
completionTokens: usage?.outputTokens,
|
||||
});
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
|
||||
const result = streamText({
|
||||
model,
|
||||
stopWhen: stepCountIs(5),
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }),
|
||||
...(headers && { headers }),
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
sessionId: validSessionId,
|
||||
userId,
|
||||
}),
|
||||
}),
|
||||
// Repair malformed tool calls (model sometimes generates invalid JSON with unescaped quotes)
|
||||
experimental_repairToolCall: async ({ toolCall }) => {
|
||||
// The toolCall.input contains the raw JSON string that failed to parse
|
||||
const rawJson =
|
||||
typeof toolCall.input === "string" ? toolCall.input : null
|
||||
|
||||
if (rawJson) {
|
||||
try {
|
||||
// Fix unescaped quotes: x="520" should be x=\"520\"
|
||||
const fixed = rawJson.replace(
|
||||
/([a-zA-Z])="(\d+)"/g,
|
||||
'$1=\\"$2\\"',
|
||||
)
|
||||
const parsed = JSON.parse(fixed)
|
||||
return {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
input: JSON.stringify(parsed),
|
||||
}
|
||||
} catch {
|
||||
// Repair failed, return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
onFinish: ({ text, usage, providerMetadata }) => {
|
||||
console.log(
|
||||
"[Cache] Full providerMetadata:",
|
||||
JSON.stringify(providerMetadata, null, 2),
|
||||
)
|
||||
console.log("[Cache] Usage:", JSON.stringify(usage, null, 2))
|
||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
|
||||
setTraceOutput(text, {
|
||||
promptTokens: usage?.inputTokens,
|
||||
completionTokens: usage?.outputTokens,
|
||||
})
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
|
||||
|
||||
VALIDATION RULES (XML will be rejected if violated):
|
||||
1. All mxCell elements must be DIRECT children of <root> - never nested
|
||||
@@ -276,64 +434,67 @@ 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 specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe("XML string to be displayed on draw.io"),
|
||||
}),
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
|
||||
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
|
||||
IMPORTANT: Keep edits concise:
|
||||
- COPY the exact mxCell line from the current XML (attribute order matters!)
|
||||
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
|
||||
- Break large changes into multiple smaller edits
|
||||
- Each search must contain complete lines (never truncate mid-line)
|
||||
- First match only - be specific enough to target the right element
|
||||
|
||||
WHEN TO USE:
|
||||
- Changing text labels or values
|
||||
- Modifying colors, styles, or visual properties
|
||||
- Adding or removing individual elements (1-3 elements)
|
||||
- Repositioning specific elements
|
||||
- Any small, targeted modification
|
||||
⚠️ JSON ESCAPING: Every " inside string values MUST be escaped as \\". Example: x=\\"100\\" y=\\"200\\" - BOTH quotes need backslashes!`,
|
||||
inputSchema: z.object({
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
search: z
|
||||
.string()
|
||||
.describe(
|
||||
"EXACT lines copied from current XML (preserve attribute order!)",
|
||||
),
|
||||
replace: z
|
||||
.string()
|
||||
.describe("Replacement lines"),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Array of search/replace pairs to apply sequentially",
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
...(process.env.TEMPERATURE !== undefined && {
|
||||
temperature: parseFloat(process.env.TEMPERATURE),
|
||||
}),
|
||||
})
|
||||
|
||||
WHEN TO USE display_diagram INSTEAD:
|
||||
- Creating a new diagram from scratch
|
||||
- Major restructuring (reorganizing layout, changing diagram type)
|
||||
- Adding many new elements (more than 3)
|
||||
- After 3 failed edit_diagram attempts
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
|
||||
2. Do NOT reorder attributes - attribute order in draw.io XML varies, you MUST match exactly
|
||||
3. Always include the element's id attribute for unique targeting
|
||||
4. Include complete lines (never truncate mid-line)
|
||||
5. For multiple changes, use separate edits in the array
|
||||
|
||||
ERROR RECOVERY:
|
||||
- If pattern not found, check attribute order matches current XML exactly
|
||||
- Retry up to 3 times with adjusted patterns
|
||||
- After 3 failures, use display_diagram instead`,
|
||||
inputSchema: z.object({
|
||||
edits: z.array(z.object({
|
||||
search: z.string().describe("EXACT lines copied from current XML (preserve attribute order!)"),
|
||||
replace: z.string().describe("Replacement lines")
|
||||
})).describe("Array of search/replace pairs to apply sequentially")
|
||||
})
|
||||
},
|
||||
},
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
return result.toUIMessageStreamResponse()
|
||||
}
|
||||
|
||||
// Wrap handler with error handling
|
||||
async function safeHandler(req: Request): Promise<Response> {
|
||||
try {
|
||||
return await handleChatRequest(req);
|
||||
} catch (error) {
|
||||
console.error('Error in chat route:', error);
|
||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
try {
|
||||
return await handleChatRequest(req)
|
||||
} catch (error) {
|
||||
console.error("Error in chat route:", error)
|
||||
return Response.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap with Langfuse observe (if configured)
|
||||
const observedHandler = wrapWithObserve(safeHandler);
|
||||
const observedHandler = wrapWithObserve(safeHandler)
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return observedHandler(req);
|
||||
return observedHandler(req)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export async function GET() {
|
||||
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
|
||||
return NextResponse.json({
|
||||
accessCodeRequired: accessCodes.length > 0,
|
||||
});
|
||||
return NextResponse.json({
|
||||
accessCodeRequired: accessCodes.length > 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,103 +1,112 @@
|
||||
import { getLangfuseClient } from '@/lib/langfuse';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
messageId: z.string().min(1).max(200),
|
||||
feedback: z.enum(['good', 'bad']),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
messageId: z.string().min(1).max(200),
|
||||
feedback: z.enum(["good", "bad"]),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const langfuse = getLangfuseClient();
|
||||
if (!langfuse) {
|
||||
return Response.json({ success: true, logged: false });
|
||||
}
|
||||
|
||||
// Validate input
|
||||
let data;
|
||||
try {
|
||||
data = feedbackSchema.parse(await req.json());
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { messageId, feedback, sessionId } = data;
|
||||
|
||||
// Get user IP for tracking
|
||||
const forwardedFor = req.headers.get('x-forwarded-for');
|
||||
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous';
|
||||
|
||||
try {
|
||||
// Find the most recent chat trace for this session to attach the score to
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
sessionId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
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`,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const langfuse = getLangfuseClient()
|
||||
if (!langfuse) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
return Response.json({ success: true, logged: true });
|
||||
} catch (error) {
|
||||
console.error('Langfuse feedback error:', error);
|
||||
return Response.json({ success: false, error: 'Failed to log feedback' }, { status: 500 });
|
||||
}
|
||||
// Validate input
|
||||
let data
|
||||
try {
|
||||
data = feedbackSchema.parse(await req.json())
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ success: false, error: "Invalid input" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { messageId, feedback, sessionId } = data
|
||||
|
||||
// Get user IP for tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
|
||||
try {
|
||||
// Find the most recent chat trace for this session to attach the score to
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
sessionId,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
const traces = tracesResponse.data || []
|
||||
const latestTrace = traces[0]
|
||||
|
||||
if (!latestTrace) {
|
||||
// No trace found for this session - create a standalone feedback trace
|
||||
const traceId = randomUUID()
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "trace-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "user-feedback",
|
||||
sessionId,
|
||||
userId,
|
||||
input: { messageId, feedback },
|
||||
metadata: {
|
||||
source: "feedback-button",
|
||||
note: "standalone - no chat trace found",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId,
|
||||
name: "user-feedback",
|
||||
value: feedback === "good" ? 1 : 0,
|
||||
comment: `User gave ${feedback} feedback`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// Attach score to the existing chat trace
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: latestTrace.id,
|
||||
name: "user-feedback",
|
||||
value: feedback === "good" ? 1 : 0,
|
||||
comment: `User gave ${feedback} feedback`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json({ success: true, logged: true })
|
||||
} catch (error) {
|
||||
console.error("Langfuse feedback error:", error)
|
||||
return Response.json(
|
||||
{ success: false, error: "Failed to log feedback" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,71 @@
|
||||
import { getLangfuseClient } from '@/lib/langfuse';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
|
||||
const saveSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
format: z.enum(['drawio', 'png', 'svg']),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
filename: z.string().min(1).max(255),
|
||||
format: z.enum(["drawio", "png", "svg"]),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const langfuse = getLangfuseClient();
|
||||
if (!langfuse) {
|
||||
return Response.json({ success: true, logged: false });
|
||||
}
|
||||
|
||||
// Validate input
|
||||
let data;
|
||||
try {
|
||||
data = saveSchema.parse(await req.json());
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { filename, format, sessionId } = data;
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Find the most recent chat trace for this session to attach the save flag
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
sessionId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const traces = tracesResponse.data || [];
|
||||
const latestTrace = traces[0];
|
||||
|
||||
if (latestTrace) {
|
||||
// Add a score to the existing trace to flag that user saved
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: 'score-create',
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: latestTrace.id,
|
||||
name: 'diagram-saved',
|
||||
value: 1,
|
||||
comment: `User saved diagram as ${filename}.${format}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const langfuse = getLangfuseClient()
|
||||
if (!langfuse) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
// If no trace found, skip logging (user hasn't chatted yet)
|
||||
|
||||
return Response.json({ success: true, logged: !!latestTrace });
|
||||
} catch (error) {
|
||||
console.error('Langfuse save error:', error);
|
||||
return Response.json({ success: false, error: 'Failed to log save' }, { status: 500 });
|
||||
}
|
||||
// Validate input
|
||||
let data
|
||||
try {
|
||||
data = saveSchema.parse(await req.json())
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ success: false, error: "Invalid input" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { filename, format, sessionId } = data
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
// Find the most recent chat trace for this session to attach the save flag
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
sessionId,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
const traces = tracesResponse.data || []
|
||||
const latestTrace = traces[0]
|
||||
|
||||
if (latestTrace) {
|
||||
// Add a score to the existing trace to flag that user saved
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: latestTrace.id,
|
||||
name: "diagram-saved",
|
||||
value: 1,
|
||||
comment: `User saved diagram as ${filename}.${format}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
// If no trace found, skip logging (user hasn't chatted yet)
|
||||
|
||||
return Response.json({ success: true, logged: !!latestTrace })
|
||||
} catch (error) {
|
||||
console.error("Langfuse save error:", error)
|
||||
return Response.json(
|
||||
{ success: false, error: "Failed to log save" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
32
app/api/verify-access-code/route.ts
Normal file
32
app/api/verify-access-code/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export async function POST(req: Request) {
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
|
||||
// If no access codes configured, verification always passes
|
||||
if (accessCodes.length === 0) {
|
||||
return Response.json({
|
||||
valid: true,
|
||||
message: "No access code required",
|
||||
})
|
||||
}
|
||||
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
|
||||
if (!accessCodeHeader) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Access code is required" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Invalid access code" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json({ valid: true, message: "Access code is valid" })
|
||||
}
|
||||
350
app/globals.css
350
app/globals.css
@@ -6,250 +6,254 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Clean Light Modern Palette */
|
||||
--background: oklch(0.985 0.002 240);
|
||||
--foreground: oklch(0.23 0.02 260);
|
||||
/* Clean Light Modern Palette */
|
||||
--background: oklch(0.985 0.002 240);
|
||||
--foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.23 0.02 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
/* Dark primary - slightly lighter */
|
||||
--primary: oklch(0.35 0.01 260);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
/* Dark primary - slightly lighter */
|
||||
--primary: oklch(0.35 0.01 260);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
|
||||
/* Warm gray secondary */
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.35 0.02 260);
|
||||
/* Warm gray secondary */
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.35 0.02 260);
|
||||
|
||||
/* Light muted tones */
|
||||
--muted: oklch(0.965 0.005 260);
|
||||
--muted-foreground: oklch(0.50 0.02 260);
|
||||
/* Light muted tones */
|
||||
--muted: oklch(0.965 0.005 260);
|
||||
--muted-foreground: oklch(0.5 0.02 260);
|
||||
|
||||
/* Soft lavender accent */
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
/* Soft lavender accent */
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
|
||||
/* Coral destructive */
|
||||
--destructive: oklch(0.60 0.20 25);
|
||||
/* Coral destructive */
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
--input: oklch(0.94 0.01 260);
|
||||
--ring: oklch(0.25 0.01 260);
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
--input: oklch(0.94 0.01 260);
|
||||
--ring: oklch(0.25 0.01 260);
|
||||
|
||||
/* Chart colors - harmonious palette */
|
||||
--chart-1: oklch(0.55 0.18 265);
|
||||
--chart-2: oklch(0.65 0.15 170);
|
||||
--chart-3: oklch(0.70 0.18 45);
|
||||
--chart-4: oklch(0.60 0.20 330);
|
||||
--chart-5: oklch(0.50 0.15 200);
|
||||
/* Chart colors - harmonious palette */
|
||||
--chart-1: oklch(0.55 0.18 265);
|
||||
--chart-2: oklch(0.65 0.15 170);
|
||||
--chart-3: oklch(0.7 0.18 45);
|
||||
--chart-4: oklch(0.6 0.2 330);
|
||||
--chart-5: oklch(0.5 0.15 200);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.99 0.002 260);
|
||||
--sidebar-foreground: oklch(0.23 0.02 260);
|
||||
--sidebar-primary: oklch(0.55 0.18 265);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.02 270);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.05 265);
|
||||
--sidebar-border: oklch(0.93 0.01 260);
|
||||
--sidebar-ring: oklch(0.55 0.18 265);
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.99 0.002 260);
|
||||
--sidebar-foreground: oklch(0.23 0.02 260);
|
||||
--sidebar-primary: oklch(0.55 0.18 265);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.02 270);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.05 265);
|
||||
--sidebar-border: oklch(0.93 0.01 260);
|
||||
--sidebar-ring: oklch(0.55 0.18 265);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.15 0.015 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
--background: oklch(0.15 0.015 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--card: oklch(0.20 0.015 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
--card: oklch(0.2 0.015 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--popover: oklch(0.20 0.015 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
--popover: oklch(0.2 0.015 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--primary: oklch(0.70 0.16 265);
|
||||
--primary-foreground: oklch(0.15 0.02 260);
|
||||
--primary: oklch(0.7 0.16 265);
|
||||
--primary-foreground: oklch(0.15 0.02 260);
|
||||
|
||||
--secondary: oklch(0.25 0.015 260);
|
||||
--secondary-foreground: oklch(0.90 0.01 260);
|
||||
--secondary: oklch(0.25 0.015 260);
|
||||
--secondary-foreground: oklch(0.9 0.01 260);
|
||||
|
||||
--muted: oklch(0.25 0.015 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
--muted: oklch(0.25 0.015 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
|
||||
--accent: oklch(0.30 0.04 280);
|
||||
--accent-foreground: oklch(0.90 0.03 270);
|
||||
--accent: oklch(0.3 0.04 280);
|
||||
--accent-foreground: oklch(0.9 0.03 270);
|
||||
|
||||
--destructive: oklch(0.65 0.22 25);
|
||||
--destructive: oklch(0.65 0.22 25);
|
||||
|
||||
--border: oklch(0.28 0.015 260);
|
||||
--input: oklch(0.25 0.015 260);
|
||||
--ring: oklch(0.70 0.16 265);
|
||||
--border: oklch(0.28 0.015 260);
|
||||
--input: oklch(0.25 0.015 260);
|
||||
--ring: oklch(0.7 0.16 265);
|
||||
|
||||
--chart-1: oklch(0.70 0.16 265);
|
||||
--chart-2: oklch(0.70 0.13 170);
|
||||
--chart-3: oklch(0.75 0.16 45);
|
||||
--chart-4: oklch(0.70 0.18 330);
|
||||
--chart-5: oklch(0.60 0.13 200);
|
||||
--chart-1: oklch(0.7 0.16 265);
|
||||
--chart-2: oklch(0.7 0.13 170);
|
||||
--chart-3: oklch(0.75 0.16 45);
|
||||
--chart-4: oklch(0.7 0.18 330);
|
||||
--chart-5: oklch(0.6 0.13 200);
|
||||
|
||||
--sidebar: oklch(0.18 0.015 260);
|
||||
--sidebar-foreground: oklch(0.95 0.01 260);
|
||||
--sidebar-primary: oklch(0.70 0.16 265);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
||||
--sidebar-accent: oklch(0.25 0.03 270);
|
||||
--sidebar-accent-foreground: oklch(0.90 0.02 265);
|
||||
--sidebar-border: oklch(0.28 0.015 260);
|
||||
--sidebar-ring: oklch(0.70 0.16 265);
|
||||
--sidebar: oklch(0.18 0.015 260);
|
||||
--sidebar-foreground: oklch(0.95 0.01 260);
|
||||
--sidebar-primary: oklch(0.7 0.16 265);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
||||
--sidebar-accent: oklch(0.25 0.03 270);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.02 265);
|
||||
--sidebar-border: oklch(0.28 0.015 260);
|
||||
--sidebar-ring: oklch(0.7 0.16 265);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for Radix ScrollArea viewport horizontal overflow */
|
||||
[data-slot="scroll-area-viewport"] > div {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.85 0.01 260) transparent;
|
||||
}
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.85 0.01 260) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.85 0.01 260);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.85 0.01 260);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Message bubble animations */
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-message-in {
|
||||
animation: messageIn 0.25s ease-out forwards;
|
||||
animation: messageIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Subtle floating shadow for cards */
|
||||
.shadow-soft {
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0.23 0.02 260 / 0.04),
|
||||
0 4px 12px oklch(0.23 0.02 260 / 0.06),
|
||||
0 8px 24px oklch(0.23 0.02 260 / 0.04);
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0.23 0.02 260 / 0.04),
|
||||
0 4px 12px oklch(0.23 0.02 260 / 0.06),
|
||||
0 8px 24px oklch(0.23 0.02 260 / 0.04);
|
||||
}
|
||||
|
||||
.shadow-soft-lg {
|
||||
box-shadow:
|
||||
0 2px 4px oklch(0.23 0.02 260 / 0.04),
|
||||
0 8px 20px oklch(0.23 0.02 260 / 0.08),
|
||||
0 16px 40px oklch(0.23 0.02 260 / 0.06);
|
||||
box-shadow:
|
||||
0 2px 4px oklch(0.23 0.02 260 / 0.04),
|
||||
0 8px 20px oklch(0.23 0.02 260 / 0.08),
|
||||
0 16px 40px oklch(0.23 0.02 260 / 0.06);
|
||||
}
|
||||
|
||||
/* Gradient text utility */
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(135deg, oklch(0.55 0.18 265), oklch(0.60 0.20 290));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.55 0.18 265),
|
||||
oklch(0.6 0.2 290)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,53 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { GoogleAnalytics } from "@next/third-parties/google";
|
||||
import { DiagramProvider } from "@/contexts/diagram-context";
|
||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||
import { Analytics } from "@vercel/analytics/react"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
|
||||
import "./globals.css";
|
||||
import "./globals.css"
|
||||
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
})
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
});
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
keywords: ["AI diagram generator", "AWS architecture", "flowchart creator", "draw.io", "AI drawing tool", "technical diagrams", "diagram automation", "free diagram generator", "online diagram maker"],
|
||||
description:
|
||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
keywords: [
|
||||
"AI diagram generator",
|
||||
"AWS architecture",
|
||||
"flowchart creator",
|
||||
"draw.io",
|
||||
"AI drawing tool",
|
||||
"technical diagrams",
|
||||
"diagram automation",
|
||||
"free diagram generator",
|
||||
"online diagram maker",
|
||||
],
|
||||
authors: [{ name: "Next AI Draw.io" }],
|
||||
creator: "Next AI Draw.io",
|
||||
publisher: "Next AI Draw.io",
|
||||
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
||||
openGraph: {
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description: "Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
||||
description:
|
||||
"Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
@@ -52,7 +64,8 @@ export const metadata: Metadata = {
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description: "Create professional diagrams with AI assistance. Free, no login required.",
|
||||
description:
|
||||
"Create professional diagrams with AI assistance. Free, no login required.",
|
||||
images: ["/architecture.png"],
|
||||
},
|
||||
robots: {
|
||||
@@ -69,27 +82,28 @@ export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Next AI Draw.io',
|
||||
applicationCategory: 'DesignApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
description: 'AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.',
|
||||
url: 'https://next-ai-drawio.jiang.jp',
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Next AI Draw.io",
|
||||
applicationCategory: "DesignApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
description:
|
||||
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
@@ -109,5 +123,5 @@ export default function RootLayout({
|
||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||
)}
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
152
app/page.tsx
152
app/page.tsx
@@ -1,75 +1,92 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { DrawIoEmbed } from "react-drawio";
|
||||
import ChatPanel from "@/components/chat-panel";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import ChatPanel from "@/components/chat-panel"
|
||||
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels";
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
|
||||
export default function Home() {
|
||||
const { drawioRef, handleDiagramExport } = useDiagram();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isChatVisible, setIsChatVisible] = useState(true);
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("drawio-theme");
|
||||
if (saved === "min" || saved === "sketch") return saved;
|
||||
const { drawioRef, handleDiagramExport, onDrawioLoad } = useDiagram()
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||
const [isThemeLoaded, setIsThemeLoaded] = useState(false)
|
||||
|
||||
// Load theme from localStorage after mount to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("drawio-theme")
|
||||
if (saved === "min" || saved === "sketch") {
|
||||
setDrawioUi(saved)
|
||||
}
|
||||
return "min";
|
||||
});
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
setIsThemeLoaded(true)
|
||||
}, [])
|
||||
const [closeProtection, setCloseProtection] = useState(false)
|
||||
|
||||
// Load close protection setting from localStorage after mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_CLOSE_PROTECTION_KEY)
|
||||
// Default to false since auto-save handles persistence
|
||||
if (saved === "true") {
|
||||
setCloseProtection(true)
|
||||
}
|
||||
}, [])
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
const toggleChatPanel = () => {
|
||||
const panel = chatPanelRef.current;
|
||||
const panel = chatPanelRef.current
|
||||
if (panel) {
|
||||
if (panel.isCollapsed()) {
|
||||
panel.expand();
|
||||
setIsChatVisible(true);
|
||||
panel.expand()
|
||||
setIsChatVisible(true)
|
||||
} else {
|
||||
panel.collapse();
|
||||
setIsChatVisible(false);
|
||||
panel.collapse()
|
||||
setIsChatVisible(false)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
|
||||
event.preventDefault();
|
||||
toggleChatPanel();
|
||||
event.preventDefault()
|
||||
toggleChatPanel()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Show confirmation dialog when user tries to leave the page
|
||||
// This helps prevent accidental navigation from browser back gestures
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
return "";
|
||||
};
|
||||
if (!closeProtection) return
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
return ""
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [closeProtection])
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
@@ -80,20 +97,31 @@ export default function Home() {
|
||||
>
|
||||
{/* Draw.io Canvas */}
|
||||
<ResizablePanel defaultSize={isMobile ? 50 : 67} minSize={20}>
|
||||
<div className={`h-full relative ${isMobile ? "p-1" : "p-2"}`}>
|
||||
<div
|
||||
className={`h-full relative ${
|
||||
isMobile ? "p-1" : "p-2"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
||||
<DrawIoEmbed
|
||||
key={drawioUi}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
}}
|
||||
/>
|
||||
{isThemeLoaded ? (
|
||||
<DrawIoEmbed
|
||||
key={drawioUi}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={onDrawioLoad}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -117,15 +145,17 @@ export default function Home() {
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={() => {
|
||||
const newTheme = drawioUi === "min" ? "sketch" : "min";
|
||||
localStorage.setItem("drawio-theme", newTheme);
|
||||
setDrawioUi(newTheme);
|
||||
const newTheme =
|
||||
drawioUi === "min" ? "sketch" : "min"
|
||||
localStorage.setItem("drawio-theme", newTheme)
|
||||
setDrawioUi(newTheme)
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
onCloseProtectionChange={setCloseProtection}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/api/',
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: "/api/",
|
||||
},
|
||||
sitemap: 'https://next-ai-drawio.jiang.jp/sitemap.xml',
|
||||
sitemap: "https://next-ai-drawio.jiang.jp/sitemap.xml",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: 'https://next-ai-drawio.jiang.jp',
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: 'https://next-ai-drawio.jiang.jp/about',
|
||||
url: "https://next-ai-drawio.jiang.jp/about",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
]
|
||||
|
||||
83
biome.json
Normal file
83
biome.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noAssignInExpressions": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
"noAutofocus": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"noLabelWithoutControl": "off",
|
||||
"noNoninteractiveTabindex": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useTemplate": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["components/ui/**"],
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
},
|
||||
"assist": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React from "react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
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";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface ButtonWithTooltipProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
tooltipContent: string;
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
tooltipContent: string
|
||||
children: React.ReactNode
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function ButtonWithTooltip({
|
||||
@@ -27,8 +27,10 @@ export function ButtonWithTooltip({
|
||||
<TooltipTrigger asChild>
|
||||
<Button {...buttonProps}>{children}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-wrap">{tooltipContent}</TooltipContent>
|
||||
<TooltipContent className="max-w-xs text-wrap">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Zap, Cloud, GitBranch, Palette } from "lucide-react";
|
||||
import { Cloud, GitBranch, Palette, Zap } from "lucide-react"
|
||||
|
||||
interface ExampleCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
|
||||
@@ -29,43 +29,43 @@ function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default function ExamplePanel({
|
||||
setInput,
|
||||
setFiles,
|
||||
}: {
|
||||
setInput: (input: string) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
}) {
|
||||
const handleReplicateFlowchart = async () => {
|
||||
setInput("Replicate this flowchart.");
|
||||
setInput("Replicate this flowchart.")
|
||||
|
||||
try {
|
||||
const response = await fetch("/example.png");
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "example.png", { type: "image/png" });
|
||||
setFiles([file]);
|
||||
const response = await fetch("/example.png")
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "example.png", { type: "image/png" })
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error("Error loading example image:", error);
|
||||
console.error("Error loading example image:", error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleReplicateArchitecture = async () => {
|
||||
setInput("Replicate this in aws style");
|
||||
setInput("Replicate this in aws style")
|
||||
|
||||
try {
|
||||
const response = await fetch("/architecture.png");
|
||||
const blob = await response.blob();
|
||||
const response = await fetch("/architecture.png")
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "architecture.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
setFiles([file]);
|
||||
})
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error("Error loading architecture image:", error);
|
||||
console.error("Error loading architecture image:", error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
@@ -75,7 +75,8 @@ export default function ExamplePanel({
|
||||
Create diagrams with AI
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
Describe what you want to create or upload an image to replicate
|
||||
Describe what you want to create or upload an image to
|
||||
replicate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +92,9 @@ export default function ExamplePanel({
|
||||
title="Animated Diagram"
|
||||
description="Draw a transformer architecture with animated connectors"
|
||||
onClick={() => {
|
||||
setInput("Give me a **animated connector** diagram of transformer's architecture")
|
||||
setInput(
|
||||
"Give me a **animated connector** diagram of transformer's architecture",
|
||||
)
|
||||
setFiles([])
|
||||
}}
|
||||
/>
|
||||
@@ -126,5 +129,5 @@ export default function ExamplePanel({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
||||
import { SaveDialog } from "@/components/save-dialog";
|
||||
import {
|
||||
Download,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
PenTool,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,103 +26,105 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
Trash2,
|
||||
Image as ImageIcon,
|
||||
History,
|
||||
Download,
|
||||
PenTool,
|
||||
LayoutGrid,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
import { FilePreviewList } from "./file-preview-list";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { HistoryDialog } from "@/components/history-dialog";
|
||||
import { ErrorToast } from "@/components/error-toast";
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_FILES = 5
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
return `${mb.toFixed(2)}MB`;
|
||||
const mb = bytes / 1024 / 1024
|
||||
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
return `${mb.toFixed(2)}MB`
|
||||
}
|
||||
|
||||
function showErrorToast(message: React.ReactNode) {
|
||||
toast.custom(
|
||||
(t) => <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
(t) => (
|
||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
||||
),
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
validFiles: File[];
|
||||
errors: string[];
|
||||
validFiles: File[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
function validateFiles(newFiles: File[], existingCount: number): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const validFiles: File[] = [];
|
||||
function validateFiles(
|
||||
newFiles: File[],
|
||||
existingCount: number,
|
||||
): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const validFiles: File[] = []
|
||||
|
||||
const availableSlots = MAX_FILES - existingCount;
|
||||
const availableSlots = MAX_FILES - existingCount
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
errors.push(`Maximum ${MAX_FILES} files allowed`);
|
||||
return { validFiles, errors };
|
||||
errors.push(`Maximum ${MAX_FILES} files allowed`)
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (validFiles.length >= availableSlots) {
|
||||
errors.push(`Only ${availableSlots} more file(s) allowed`);
|
||||
break;
|
||||
errors.push(`Only ${availableSlots} more file(s) allowed`)
|
||||
break
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`);
|
||||
errors.push(
|
||||
`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`,
|
||||
)
|
||||
} else {
|
||||
validFiles.push(file);
|
||||
validFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return { validFiles, errors };
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
function showValidationErrors(errors: string[]) {
|
||||
if (errors.length === 0) return;
|
||||
if (errors.length === 0) return
|
||||
|
||||
if (errors.length === 1) {
|
||||
showErrorToast(<span className="text-muted-foreground">{errors[0]}</span>);
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||
)
|
||||
} else {
|
||||
showErrorToast(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{errors.length} files rejected:</span>
|
||||
<span className="font-medium">
|
||||
{errors.length} files rejected:
|
||||
</span>
|
||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
||||
{errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)}
|
||||
{errors.length > 3 && <li>...and {errors.length - 3} more</li>}
|
||||
{errors.slice(0, 3).map((err) => (
|
||||
<li key={err}>{err}</li>
|
||||
))}
|
||||
{errors.length > 3 && (
|
||||
<li>...and {errors.length - 3} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string;
|
||||
status: "submitted" | "streaming" | "ready" | "error";
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onClearChat: () => void;
|
||||
files?: File[];
|
||||
onFileChange?: (files: File[]) => void;
|
||||
showHistory?: boolean;
|
||||
onToggleHistory?: (show: boolean) => void;
|
||||
sessionId?: string;
|
||||
error?: Error | null;
|
||||
drawioUi?: "min" | "sketch";
|
||||
onToggleDrawioUi?: () => void;
|
||||
input: string
|
||||
status: "submitted" | "streaming" | "ready" | "error"
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onClearChat: () => void
|
||||
files?: File[]
|
||||
onFileChange?: (files: File[]) => void
|
||||
showHistory?: boolean
|
||||
onToggleHistory?: (show: boolean) => void
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
drawioUi?: "min" | "sketch"
|
||||
onToggleDrawioUi?: () => void
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -126,128 +142,139 @@ export function ChatInput({
|
||||
drawioUi = "min",
|
||||
onToggleDrawioUi = () => {},
|
||||
}: ChatInputProps) {
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [showThemeWarning, setShowThemeWarning] = useState(false);
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [showThemeWarning, setShowThemeWarning] = useState(false)
|
||||
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error;
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [input, adjustTextareaHeight]);
|
||||
adjustTextareaHeight()
|
||||
}, [input, adjustTextareaHeight])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget.closest("form");
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
form.requestSubmit();
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return;
|
||||
if (isDisabled) return
|
||||
|
||||
const items = e.clipboardData.items;
|
||||
const items = e.clipboardData.items
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/")
|
||||
);
|
||||
item.type.startsWith("image/"),
|
||||
)
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = (await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return null;
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type }
|
||||
);
|
||||
})
|
||||
)).filter((f): f is File => f !== null);
|
||||
const imageFiles = (
|
||||
await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile()
|
||||
if (!file) return null
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type },
|
||||
)
|
||||
}),
|
||||
)
|
||||
).filter((f): f is File => f !== null)
|
||||
|
||||
const { validFiles, errors } = validateFiles(imageFiles, files.length);
|
||||
showValidationErrors(errors);
|
||||
const { validFiles, errors } = validateFiles(
|
||||
imageFiles,
|
||||
files.length,
|
||||
)
|
||||
showValidationErrors(errors)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles]);
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || []);
|
||||
const { validFiles, errors } = validateFiles(newFiles, files.length);
|
||||
showValidationErrors(errors);
|
||||
const newFiles = Array.from(e.target.files || [])
|
||||
const { validFiles, errors } = validateFiles(newFiles, files.length)
|
||||
showValidationErrors(errors)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles]);
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
onFileChange(files.filter((file) => file !== fileToRemove));
|
||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (isDisabled) return;
|
||||
if (isDisabled) return
|
||||
|
||||
const droppedFiles = e.dataTransfer.files;
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
const imageFiles = Array.from(droppedFiles).filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
file.type.startsWith("image/"),
|
||||
)
|
||||
|
||||
const { validFiles, errors } = validateFiles(imageFiles, files.length);
|
||||
showValidationErrors(errors);
|
||||
const { validFiles, errors } = validateFiles(imageFiles, files.length)
|
||||
showValidationErrors(errors)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles]);
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
onClearChat();
|
||||
setShowClearDialog(false);
|
||||
};
|
||||
onClearChat()
|
||||
setShowClearDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -276,7 +303,7 @@ export function ChatInput({
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Describe your diagram or paste an image..."
|
||||
@@ -316,7 +343,11 @@ export function ChatInput({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowThemeWarning(true)}
|
||||
tooltipContent={drawioUi === "min" ? "Switch to Sketch theme" : "Switch to Minimal theme"}
|
||||
tooltipContent={
|
||||
drawioUi === "min"
|
||||
? "Switch to Sketch theme"
|
||||
: "Switch to Minimal theme"
|
||||
}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{drawioUi === "min" ? (
|
||||
@@ -326,27 +357,33 @@ export function ChatInput({
|
||||
)}
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<Dialog open={showThemeWarning} onOpenChange={setShowThemeWarning}>
|
||||
<Dialog
|
||||
open={showThemeWarning}
|
||||
onOpenChange={setShowThemeWarning}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Switch Theme?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Switching themes will reload the diagram editor and clear any unsaved changes.
|
||||
Switching themes will reload the diagram
|
||||
editor and clear any unsaved changes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowThemeWarning(false)}
|
||||
onClick={() =>
|
||||
setShowThemeWarning(false)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
onClearChat();
|
||||
onToggleDrawioUi();
|
||||
setShowThemeWarning(false);
|
||||
onClearChat()
|
||||
onToggleDrawioUi()
|
||||
setShowThemeWarning(false)
|
||||
}}
|
||||
>
|
||||
Switch Theme
|
||||
@@ -439,5 +476,5 @@ export function ChatInput({
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import ExamplePanel from "./chat-example-panel";
|
||||
import { UIMessage } from "ai";
|
||||
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
|
||||
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Cpu,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
convertToLegalXml,
|
||||
replaceNodes,
|
||||
validateMxCellStructure,
|
||||
} from "@/lib/utils"
|
||||
import ExamplePanel from "./chat-example-panel"
|
||||
import { CodeBlock } from "./code-block"
|
||||
|
||||
interface EditPair {
|
||||
search: string;
|
||||
replace: string;
|
||||
search: string
|
||||
replace: string
|
||||
}
|
||||
|
||||
// Tool part interface for type safety
|
||||
interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
|
||||
output?: string
|
||||
}
|
||||
|
||||
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{edits.map((edit, index) => (
|
||||
<div key={index} className="rounded-lg border border-border/50 overflow-hidden bg-background/50">
|
||||
<div
|
||||
key={`${(edit.search || "").slice(0, 50)}-${(edit.replace || "").slice(0, 50)}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Change {index + 1}
|
||||
@@ -30,7 +60,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Minus className="w-3 h-3 text-red-500" />
|
||||
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">Remove</span>
|
||||
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
|
||||
Remove
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{edit.search}
|
||||
@@ -40,7 +72,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Plus className="w-3 h-3 text-green-500" />
|
||||
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">Add</span>
|
||||
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
|
||||
Add
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{edit.replace}
|
||||
@@ -50,26 +84,26 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
|
||||
const getMessageTextContent = (message: UIMessage): string => {
|
||||
if (!message.parts) return "";
|
||||
if (!message.parts) return ""
|
||||
return message.parts
|
||||
.filter((part: any) => part.type === "text")
|
||||
.map((part: any) => part.text)
|
||||
.join("\n");
|
||||
};
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => (part as { text: string }).text)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
interface ChatMessageDisplayProps {
|
||||
messages: UIMessage[];
|
||||
setInput: (input: string) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
sessionId?: string;
|
||||
onRegenerate?: (messageIndex: number) => void;
|
||||
onEditMessage?: (messageIndex: number, newText: string) => void;
|
||||
messages: UIMessage[]
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
sessionId?: string
|
||||
onRegenerate?: (messageIndex: number) => void
|
||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||
}
|
||||
|
||||
export function ChatMessageDisplay({
|
||||
@@ -80,43 +114,48 @@ export function ChatMessageDisplay({
|
||||
onRegenerate,
|
||||
onEditMessage,
|
||||
}: ChatMessageDisplayProps) {
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const previousXML = useRef<string>("");
|
||||
const processedToolCalls = useRef<Set<string>>(new Set());
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const previousXML = useRef<string>("")
|
||||
const processedToolCalls = useRef<Set<string>>(new Set())
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({});
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editText, setEditText] = useState<string>("");
|
||||
{},
|
||||
)
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({})
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [editText, setEditText] = useState<string>("")
|
||||
|
||||
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedMessageId(messageId);
|
||||
setTimeout(() => setCopiedMessageId(null), 2000);
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedMessageId(messageId)
|
||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||
} catch (err) {
|
||||
console.error("Failed to copy message:", err);
|
||||
setCopyFailedMessageId(messageId);
|
||||
setTimeout(() => setCopyFailedMessageId(null), 2000);
|
||||
console.error("Failed to copy message:", err)
|
||||
setCopyFailedMessageId(messageId)
|
||||
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const submitFeedback = async (messageId: string, value: "good" | "bad") => {
|
||||
// Toggle off if already selected
|
||||
if (feedback[messageId] === value) {
|
||||
setFeedback((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[messageId];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setFeedback((prev) => ({ ...prev, [messageId]: value }));
|
||||
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
||||
|
||||
try {
|
||||
await fetch("/api/log-feedback", {
|
||||
@@ -127,97 +166,112 @@ export function ChatMessageDisplay({
|
||||
feedback: value,
|
||||
sessionId,
|
||||
}),
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Failed to log feedback:", error);
|
||||
console.warn("Failed to log feedback:", error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDisplayChart = useCallback(
|
||||
(xml: string) => {
|
||||
const currentXml = xml || "";
|
||||
const convertedXml = convertToLegalXml(currentXml);
|
||||
const currentXml = xml || ""
|
||||
const convertedXml = convertToLegalXml(currentXml)
|
||||
if (convertedXml !== previousXML.current) {
|
||||
const replacedXML = replaceNodes(chartXML, convertedXml);
|
||||
// If chartXML is empty, use the converted XML directly
|
||||
const replacedXML = chartXML
|
||||
? replaceNodes(chartXML, convertedXml)
|
||||
: convertedXml
|
||||
|
||||
const validationError = validateMxCellStructure(replacedXML);
|
||||
const validationError = validateMxCellStructure(replacedXML)
|
||||
if (!validationError) {
|
||||
previousXML.current = convertedXml;
|
||||
onDisplayChart(replacedXML);
|
||||
previousXML.current = convertedXml
|
||||
// Skip validation in loadDiagram since we already validated above
|
||||
onDisplayChart(replacedXML, true)
|
||||
} else {
|
||||
console.log("[ChatMessageDisplay] XML validation failed:", validationError);
|
||||
console.log(
|
||||
"[ChatMessageDisplay] XML validation failed:",
|
||||
validationError,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[chartXML, onDisplayChart]
|
||||
);
|
||||
[chartXML, onDisplayChart],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingMessageId && editTextareaRef.current) {
|
||||
editTextareaRef.current.focus()
|
||||
}
|
||||
}, [editingMessageId])
|
||||
|
||||
useEffect(() => {
|
||||
messages.forEach((message) => {
|
||||
if (message.parts) {
|
||||
message.parts.forEach((part: any) => {
|
||||
message.parts.forEach((part) => {
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
const { toolCallId, state } = part;
|
||||
const toolPart = part as ToolPartLike
|
||||
const { toolCallId, state, input } = toolPart
|
||||
|
||||
if (state === "output-available") {
|
||||
setExpandedTools((prev) => ({
|
||||
...prev,
|
||||
[toolCallId]: false,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "tool-display_diagram" &&
|
||||
part.input?.xml
|
||||
input?.xml
|
||||
) {
|
||||
const xml = input.xml as string
|
||||
if (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available"
|
||||
) {
|
||||
handleDisplayChart(part.input.xml);
|
||||
handleDisplayChart(xml)
|
||||
} else if (
|
||||
state === "output-available" &&
|
||||
!processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
handleDisplayChart(part.input.xml);
|
||||
processedToolCalls.current.add(toolCallId);
|
||||
handleDisplayChart(xml)
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
}, [messages, handleDisplayChart]);
|
||||
})
|
||||
}, [messages, handleDisplayChart])
|
||||
|
||||
const renderToolPart = (part: any) => {
|
||||
const callId = part.toolCallId;
|
||||
const { state, input, output } = part;
|
||||
const isExpanded = expandedTools[callId] ?? true;
|
||||
const toolName = part.type?.replace("tool-", "");
|
||||
const renderToolPart = (part: ToolPartLike) => {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output } = part
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedTools((prev) => ({
|
||||
...prev,
|
||||
[callId]: !isExpanded,
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "display_diagram":
|
||||
return "Generate Diagram";
|
||||
return "Generate Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram";
|
||||
return "Edit Diagram"
|
||||
default:
|
||||
return name;
|
||||
return name
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -249,6 +303,7 @@ export function ChatMessageDisplay({
|
||||
)}
|
||||
{input && Object.keys(input).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
@@ -265,10 +320,16 @@ export function ChatMessageDisplay({
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||
{typeof input === "object" && input.xml ? (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? (
|
||||
) : typeof input === "object" &&
|
||||
input.edits &&
|
||||
Array.isArray(input.edits) ? (
|
||||
<EditDiffDisplay edits={input.edits} />
|
||||
) : typeof input === "object" && Object.keys(input).length > 0 ? (
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
code={JSON.stringify(input, null, 2)}
|
||||
language="json"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -278,8 +339,8 @@ export function ChatMessageDisplay({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||
@@ -288,92 +349,156 @@ export function ChatMessageDisplay({
|
||||
) : (
|
||||
<div className="py-4 px-4 space-y-4">
|
||||
{messages.map((message, messageIndex) => {
|
||||
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
|
||||
const isLastAssistantMessage = message.role === "assistant" && (
|
||||
messageIndex === messages.length - 1 ||
|
||||
messages.slice(messageIndex + 1).every(m => m.role !== "assistant")
|
||||
);
|
||||
const isLastUserMessage = message.role === "user" && (
|
||||
messageIndex === messages.length - 1 ||
|
||||
messages.slice(messageIndex + 1).every(m => m.role !== "user")
|
||||
);
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const userMessageText =
|
||||
message.role === "user"
|
||||
? getMessageTextContent(message)
|
||||
: ""
|
||||
const isLastAssistantMessage =
|
||||
message.role === "assistant" &&
|
||||
(messageIndex === messages.length - 1 ||
|
||||
messages
|
||||
.slice(messageIndex + 1)
|
||||
.every((m) => m.role !== "assistant"))
|
||||
const isLastUserMessage =
|
||||
message.role === "user" &&
|
||||
(messageIndex === messages.length - 1 ||
|
||||
messages
|
||||
.slice(messageIndex + 1)
|
||||
.every((m) => m.role !== "user"))
|
||||
const isEditing = editingMessageId === message.id
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||
style={{ animationDelay: `${messageIndex * 50}ms` }}
|
||||
style={{
|
||||
animationDelay: `${messageIndex * 50}ms`,
|
||||
}}
|
||||
>
|
||||
{message.role === "user" && userMessageText && !isEditing && (
|
||||
<div className="flex items-center gap-1 self-center mr-2">
|
||||
{/* Edit button - only on last user message */}
|
||||
{onEditMessage && isLastUserMessage && (
|
||||
{message.role === "user" &&
|
||||
userMessageText &&
|
||||
!isEditing && (
|
||||
<div className="flex items-center gap-1 self-center mr-2">
|
||||
{/* Edit button - only on last user message */}
|
||||
{onEditMessage &&
|
||||
isLastUserMessage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingMessageId(
|
||||
message.id,
|
||||
)
|
||||
setEditText(
|
||||
userMessageText,
|
||||
)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||
title="Edit message"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingMessageId(message.id);
|
||||
setEditText(userMessageText);
|
||||
}}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyMessageToClipboard(
|
||||
message.id,
|
||||
userMessageText,
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||
title="Edit message"
|
||||
title={
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "Copied!"
|
||||
: copyFailedMessageId ===
|
||||
message.id
|
||||
? "Failed to copy"
|
||||
: "Copy message"
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{copiedMessageId ===
|
||||
message.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : copyFailedMessageId ===
|
||||
message.id ? (
|
||||
<X className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||
title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
|
||||
>
|
||||
{copiedMessageId === message.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : copyFailedMessageId === message.id ? (
|
||||
<X className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
{/* Edit mode for user messages */}
|
||||
{isEditing && message.role === "user" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
ref={editTextareaRef}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setEditText(e.target.value)
|
||||
}
|
||||
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
rows={Math.min(editText.split('\n').length + 1, 6)}
|
||||
autoFocus
|
||||
rows={Math.min(
|
||||
editText.split("\n")
|
||||
.length + 1,
|
||||
6,
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setEditingMessageId(null);
|
||||
setEditText("");
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (editText.trim() && onEditMessage) {
|
||||
onEditMessage(messageIndex, editText.trim());
|
||||
setEditingMessageId(null);
|
||||
setEditText("");
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
} else if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey)
|
||||
) {
|
||||
e.preventDefault()
|
||||
if (
|
||||
editText.trim() &&
|
||||
onEditMessage
|
||||
) {
|
||||
onEditMessage(
|
||||
messageIndex,
|
||||
editText.trim(),
|
||||
)
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingMessageId(null);
|
||||
setEditText("");
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (editText.trim() && onEditMessage) {
|
||||
onEditMessage(messageIndex, editText.trim());
|
||||
setEditingMessageId(null);
|
||||
setEditText("");
|
||||
if (
|
||||
editText.trim() &&
|
||||
onEditMessage
|
||||
) {
|
||||
onEditMessage(
|
||||
messageIndex,
|
||||
editText.trim(),
|
||||
)
|
||||
setEditingMessageId(
|
||||
null,
|
||||
)
|
||||
setEditText("")
|
||||
}
|
||||
}}
|
||||
disabled={!editText.trim()}
|
||||
@@ -384,101 +509,278 @@ export function ChatMessageDisplay({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Text content in bubble */
|
||||
message.parts?.some((part: any) => part.type === "text" || part.type === "file") && (
|
||||
<div
|
||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
||||
: message.role === "system"
|
||||
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
||||
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
||||
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
||||
onClick={() => {
|
||||
if (message.role === "user" && isLastUserMessage && onEditMessage) {
|
||||
setEditingMessageId(message.id);
|
||||
setEditText(userMessageText);
|
||||
/* Render parts in order, grouping consecutive text/file parts into bubbles */
|
||||
(() => {
|
||||
const parts = message.parts || []
|
||||
const groups: {
|
||||
type: "content" | "tool"
|
||||
parts: typeof parts
|
||||
startIndex: number
|
||||
}[] = []
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isToolPart =
|
||||
part.type?.startsWith(
|
||||
"tool-",
|
||||
)
|
||||
const isContentPart =
|
||||
part.type === "text" ||
|
||||
part.type === "file"
|
||||
|
||||
if (isToolPart) {
|
||||
groups.push({
|
||||
type: "tool",
|
||||
parts: [part],
|
||||
startIndex: index,
|
||||
})
|
||||
} else if (isContentPart) {
|
||||
const lastGroup =
|
||||
groups[
|
||||
groups.length - 1
|
||||
]
|
||||
if (
|
||||
lastGroup?.type ===
|
||||
"content"
|
||||
) {
|
||||
lastGroup.parts.push(
|
||||
part,
|
||||
)
|
||||
} else {
|
||||
groups.push({
|
||||
type: "content",
|
||||
parts: [part],
|
||||
startIndex: index,
|
||||
})
|
||||
}
|
||||
}}
|
||||
title={message.role === "user" && isLastUserMessage && onEditMessage ? "Click to edit" : undefined}
|
||||
>
|
||||
{message.parts?.map((part: any, index: number) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div key={index} className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
||||
message.role === "user"
|
||||
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
|
||||
: "dark:prose-invert"
|
||||
}`}>
|
||||
<ReactMarkdown>{part.text}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
case "file":
|
||||
return (
|
||||
<div key={index} className="mt-2">
|
||||
<Image
|
||||
src={part.url}
|
||||
width={200}
|
||||
height={200}
|
||||
alt={`Uploaded diagram or image for AI analysis`}
|
||||
className="rounded-lg border border-white/20"
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
|
||||
return groups.map(
|
||||
(group, groupIndex) => {
|
||||
if (group.type === "tool") {
|
||||
return renderToolPart(
|
||||
group
|
||||
.parts[0] as ToolPartLike,
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Content bubble
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-content-${group.startIndex}`}
|
||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||
message.role ===
|
||||
"user"
|
||||
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
||||
: message.role ===
|
||||
"system"
|
||||
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
||||
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
||||
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""} ${groupIndex > 0 ? "mt-3" : ""}`}
|
||||
role={
|
||||
message.role ===
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
? "button"
|
||||
: undefined
|
||||
}
|
||||
tabIndex={
|
||||
message.role ===
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
? 0
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
message.role ===
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
) {
|
||||
setEditingMessageId(
|
||||
message.id,
|
||||
)
|
||||
setEditText(
|
||||
userMessageText,
|
||||
)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key ===
|
||||
"Enter" ||
|
||||
e.key ===
|
||||
" ") &&
|
||||
message.role ===
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
) {
|
||||
e.preventDefault()
|
||||
setEditingMessageId(
|
||||
message.id,
|
||||
)
|
||||
setEditText(
|
||||
userMessageText,
|
||||
)
|
||||
}
|
||||
}}
|
||||
title={
|
||||
message.role ===
|
||||
"user" &&
|
||||
isLastUserMessage &&
|
||||
onEditMessage
|
||||
? "Click to edit"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{group.parts.map(
|
||||
(
|
||||
part,
|
||||
partIndex,
|
||||
) => {
|
||||
if (
|
||||
part.type ===
|
||||
"text"
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-text-${group.startIndex}-${partIndex}`}
|
||||
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
||||
message.role ===
|
||||
"user"
|
||||
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
|
||||
: "dark:prose-invert"
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown>
|
||||
{
|
||||
(
|
||||
part as {
|
||||
text: string
|
||||
}
|
||||
)
|
||||
.text
|
||||
}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (
|
||||
part.type ===
|
||||
"file"
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-file-${group.startIndex}-${partIndex}`}
|
||||
className="mt-2"
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
(
|
||||
part as {
|
||||
url: string
|
||||
}
|
||||
)
|
||||
.url
|
||||
}
|
||||
width={
|
||||
200
|
||||
}
|
||||
height={
|
||||
200
|
||||
}
|
||||
alt={`Uploaded diagram or image for AI analysis`}
|
||||
className="rounded-lg border border-white/20"
|
||||
style={{
|
||||
objectFit:
|
||||
"contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
})()
|
||||
)}
|
||||
{/* Tool calls outside bubble */}
|
||||
{message.parts?.map((part: any) => {
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
return renderToolPart(part);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{/* Action buttons for assistant messages */}
|
||||
{message.role === "assistant" && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={() => copyMessageToClipboard(message.id, getMessageTextContent(message))}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyMessageToClipboard(
|
||||
message.id,
|
||||
getMessageTextContent(
|
||||
message,
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
copiedMessageId === message.id
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "text-green-600 bg-green-100"
|
||||
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
title={copiedMessageId === message.id ? "Copied!" : "Copy response"}
|
||||
title={
|
||||
copiedMessageId ===
|
||||
message.id
|
||||
? "Copied!"
|
||||
: "Copy response"
|
||||
}
|
||||
>
|
||||
{copiedMessageId === message.id ? (
|
||||
{copiedMessageId ===
|
||||
message.id ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{/* Regenerate button - only on last assistant message */}
|
||||
{onRegenerate && isLastAssistantMessage && (
|
||||
<button
|
||||
onClick={() => onRegenerate(messageIndex)}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Regenerate response"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Regenerate button - only on last assistant message, not for cached examples */}
|
||||
{onRegenerate &&
|
||||
isLastAssistantMessage &&
|
||||
!message.parts?.some((p: any) =>
|
||||
p.toolCallId?.startsWith(
|
||||
"cached-",
|
||||
),
|
||||
) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onRegenerate(
|
||||
messageIndex,
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Regenerate response"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
{/* Thumbs up */}
|
||||
<button
|
||||
onClick={() => submitFeedback(message.id, "good")}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
submitFeedback(
|
||||
message.id,
|
||||
"good",
|
||||
)
|
||||
}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
feedback[message.id] === "good"
|
||||
feedback[message.id] ===
|
||||
"good"
|
||||
? "text-green-600 bg-green-100"
|
||||
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
||||
}`}
|
||||
@@ -488,9 +790,16 @@ export function ChatMessageDisplay({
|
||||
</button>
|
||||
{/* Thumbs down */}
|
||||
<button
|
||||
onClick={() => submitFeedback(message.id, "bad")}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
submitFeedback(
|
||||
message.id,
|
||||
"bad",
|
||||
)
|
||||
}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
feedback[message.id] === "bad"
|
||||
feedback[message.id] ===
|
||||
"bad"
|
||||
? "text-red-600 bg-red-100"
|
||||
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
||||
}`}
|
||||
@@ -502,11 +811,11 @@ export function ChatMessageDisplay({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</ScrollArea>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import type React from "react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import {
|
||||
DefaultChatTransport,
|
||||
lastAssistantMessageIsCompleteWithToolCalls,
|
||||
} from "ai"
|
||||
import {
|
||||
CheckCircle,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { ChatInput } from "@/components/chat-input";
|
||||
import { ChatMessageDisplay } from "./chat-message-display";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import { Toaster } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ChatInput } from "@/components/chat-input"
|
||||
import {
|
||||
SettingsDialog,
|
||||
STORAGE_ACCESS_CODE_KEY,
|
||||
} from "@/components/settings-dialog";
|
||||
} from "@/components/settings-dialog"
|
||||
|
||||
// localStorage keys for persistence
|
||||
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
||||
const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
|
||||
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
|
||||
const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
|
||||
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { formatXML } from "@/lib/utils"
|
||||
import { ChatMessageDisplay } from "./chat-message-display"
|
||||
|
||||
interface ChatPanelProps {
|
||||
isVisible: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
drawioUi: "min" | "sketch";
|
||||
onToggleDrawioUi: () => void;
|
||||
isMobile?: boolean;
|
||||
isVisible: boolean
|
||||
onToggleVisibility: () => void
|
||||
drawioUi: "min" | "sketch"
|
||||
onToggleDrawioUi: () => void
|
||||
isMobile?: boolean
|
||||
onCloseProtectionChange?: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export default function ChatPanel({
|
||||
@@ -40,6 +51,7 @@ export default function ChatPanel({
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
isMobile = false,
|
||||
onCloseProtectionChange,
|
||||
}: ChatPanelProps) {
|
||||
const {
|
||||
loadDiagram: onDisplayChart,
|
||||
@@ -48,137 +60,194 @@ export default function ChatPanel({
|
||||
resolverRef,
|
||||
chartXML,
|
||||
clearDiagram,
|
||||
} = useDiagram();
|
||||
isDrawioReady,
|
||||
} = useDiagram()
|
||||
|
||||
const onFetchChart = (saveToHistory = true) => {
|
||||
return Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
if (resolverRef && "current" in resolverRef) {
|
||||
resolverRef.current = resolve;
|
||||
resolverRef.current = resolve
|
||||
}
|
||||
if (saveToHistory) {
|
||||
onExport();
|
||||
onExport()
|
||||
} else {
|
||||
handleExportWithoutHistory();
|
||||
handleExportWithoutHistory()
|
||||
}
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Chart export timed out after 10 seconds")
|
||||
new Error(
|
||||
"Chart export timed out after 10 seconds",
|
||||
),
|
||||
),
|
||||
10000
|
||||
)
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
};
|
||||
])
|
||||
}
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||
const [, setAccessCodeRequired] = useState(false)
|
||||
const [input, setInput] = useState("")
|
||||
|
||||
// Check if access code is required on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/config")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
|
||||
.catch(() => setAccessCodeRequired(false));
|
||||
}, []);
|
||||
.catch(() => setAccessCodeRequired(false))
|
||||
}, [])
|
||||
|
||||
// Generate a unique session ID for Langfuse tracing
|
||||
const [sessionId, setSessionId] = useState(
|
||||
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
);
|
||||
// Generate a unique session ID for Langfuse tracing (restore from localStorage if available)
|
||||
const [sessionId, setSessionId] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(STORAGE_SESSION_ID_KEY)
|
||||
if (saved) return saved
|
||||
}
|
||||
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
})
|
||||
|
||||
// Store XML snapshots for each user message (keyed by message index)
|
||||
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map());
|
||||
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
|
||||
|
||||
// Flag to track if we've restored from localStorage
|
||||
const hasRestoredRef = useRef(false)
|
||||
|
||||
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
||||
const chartXMLRef = useRef(chartXML);
|
||||
const chartXMLRef = useRef(chartXML)
|
||||
useEffect(() => {
|
||||
chartXMLRef.current = chartXML;
|
||||
}, [chartXML]);
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
|
||||
const { messages, sendMessage, addToolResult, status, error, setMessages } =
|
||||
useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/chat",
|
||||
}),
|
||||
async onToolCall({ toolCall }) {
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
const { xml } = toolCall.input as { xml: string };
|
||||
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const validationError = validateMxCellStructure(xml);
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
addToolOutput,
|
||||
stop,
|
||||
status,
|
||||
error,
|
||||
setMessages,
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/chat",
|
||||
}),
|
||||
async onToolCall({ toolCall }) {
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
if (validationError) {
|
||||
addToolResult({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: validationError,
|
||||
});
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(xml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[display_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
const errorMessage = `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${xml}
|
||||
\`\`\``
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: errorMessage,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
}
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
const { edits } = toolCall.input as {
|
||||
edits: Array<{ search: string; replace: string }>
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
try {
|
||||
console.log("[edit_diagram] Starting...")
|
||||
// Use chartXML from ref directly - more reliable than export
|
||||
// especially on Vercel where DrawIO iframe may have latency issues
|
||||
// Using ref to avoid stale closure in callback
|
||||
const cachedXML = chartXMLRef.current
|
||||
if (cachedXML) {
|
||||
currentXml = cachedXML
|
||||
console.log(
|
||||
"[edit_diagram] Using cached chartXML, length:",
|
||||
currentXml.length,
|
||||
)
|
||||
} else {
|
||||
addToolResult({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
});
|
||||
// Fallback to export only if no cached XML
|
||||
console.log(
|
||||
"[edit_diagram] No cached XML, fetching from DrawIO...",
|
||||
)
|
||||
currentXml = await onFetchChart(false)
|
||||
console.log(
|
||||
"[edit_diagram] Got XML from export, length:",
|
||||
currentXml.length,
|
||||
)
|
||||
}
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
const { edits } = toolCall.input as {
|
||||
edits: Array<{ search: string; replace: string }>;
|
||||
};
|
||||
|
||||
let currentXml = "";
|
||||
try {
|
||||
console.log("[edit_diagram] Starting...");
|
||||
// Use chartXML from ref directly - more reliable than export
|
||||
// especially on Vercel where DrawIO iframe may have latency issues
|
||||
// Using ref to avoid stale closure in callback
|
||||
const cachedXML = chartXMLRef.current;
|
||||
if (cachedXML) {
|
||||
currentXml = cachedXML;
|
||||
console.log(
|
||||
"[edit_diagram] Using cached chartXML, length:",
|
||||
currentXml.length
|
||||
);
|
||||
} else {
|
||||
// Fallback to export only if no cached XML
|
||||
console.log(
|
||||
"[edit_diagram] No cached XML, fetching from DrawIO..."
|
||||
);
|
||||
currentXml = await onFetchChart(false);
|
||||
console.log(
|
||||
"[edit_diagram] Got XML from export, length:",
|
||||
currentXml.length
|
||||
);
|
||||
}
|
||||
const { replaceXMLParts } = await import("@/lib/utils")
|
||||
const editedXml = replaceXMLParts(currentXml, edits)
|
||||
|
||||
const { replaceXMLParts } = await import("@/lib/utils");
|
||||
const editedXml = replaceXMLParts(currentXml, edits);
|
||||
|
||||
onDisplayChart(editedXml);
|
||||
|
||||
addToolResult({
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(editedXml)
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
||||
});
|
||||
console.log("[edit_diagram] Success");
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error);
|
||||
state: "output-error",
|
||||
errorText: `Edit produced invalid XML: ${validationError}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
addToolResult({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Edit failed: ${errorMessage}
|
||||
Please fix the edit to avoid structural issues (e.g., duplicate IDs, invalid references).`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
||||
})
|
||||
console.log("[edit_diagram] Success")
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Use addToolOutput with state: 'output-error' for proper error signaling
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit failed: ${errorMessage}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
@@ -186,80 +255,272 @@ ${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
}
|
||||
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: error.message,
|
||||
parts: [{ type: "text" as const, text: error.message }],
|
||||
}
|
||||
return [...currentMessages, errorMessage]
|
||||
})
|
||||
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: error.message,
|
||||
parts: [{ type: "text" as const, text: error.message }],
|
||||
};
|
||||
return [...currentMessages, errorMessage];
|
||||
});
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings button and open dialog to help user fix it
|
||||
setAccessCodeRequired(true)
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
},
|
||||
// Auto-resubmit when all tool results are available (including errors)
|
||||
// This enables the model to retry when a tool returns an error
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||
})
|
||||
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings button and open dialog to help user fix it
|
||||
setAccessCodeRequired(true);
|
||||
setShowSettingsDialog(true);
|
||||
// Update stopRef so onToolCall can access it
|
||||
stopRef.current = stop
|
||||
|
||||
// Ref to track latest messages for unload persistence
|
||||
const messagesRef = useRef(messages)
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Restore messages and XML snapshots from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (hasRestoredRef.current) return
|
||||
hasRestoredRef.current = true
|
||||
|
||||
try {
|
||||
// Restore messages
|
||||
const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY)
|
||||
if (savedMessages) {
|
||||
const parsed = JSON.parse(savedMessages)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setMessages(parsed)
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
// Restore XML snapshots
|
||||
const savedSnapshots = localStorage.getItem(
|
||||
STORAGE_XML_SNAPSHOTS_KEY,
|
||||
)
|
||||
if (savedSnapshots) {
|
||||
const parsed = JSON.parse(savedSnapshots)
|
||||
xmlSnapshotsRef.current = new Map(parsed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to restore from localStorage:", error)
|
||||
}
|
||||
}, [setMessages])
|
||||
|
||||
// Restore diagram XML when DrawIO becomes ready
|
||||
const hasDiagramRestoredRef = useRef(false)
|
||||
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
"[ChatPanel] isDrawioReady:",
|
||||
isDrawioReady,
|
||||
"hasDiagramRestored:",
|
||||
hasDiagramRestoredRef.current,
|
||||
)
|
||||
if (!isDrawioReady || hasDiagramRestoredRef.current) return
|
||||
hasDiagramRestoredRef.current = true
|
||||
|
||||
try {
|
||||
const savedDiagramXml = localStorage.getItem(
|
||||
STORAGE_DIAGRAM_XML_KEY,
|
||||
)
|
||||
console.log(
|
||||
"[ChatPanel] Restoring diagram, has saved XML:",
|
||||
!!savedDiagramXml,
|
||||
)
|
||||
if (savedDiagramXml) {
|
||||
console.log(
|
||||
"[ChatPanel] Loading saved diagram XML, length:",
|
||||
savedDiagramXml.length,
|
||||
)
|
||||
// Skip validation for trusted saved diagrams
|
||||
onDisplayChart(savedDiagramXml, true)
|
||||
chartXMLRef.current = savedDiagramXml
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to restore diagram from localStorage:", error)
|
||||
}
|
||||
|
||||
// Allow saving after restore is complete
|
||||
setTimeout(() => {
|
||||
console.log("[ChatPanel] Enabling diagram save")
|
||||
setCanSaveDiagram(true)
|
||||
}, 500)
|
||||
}, [isDrawioReady, onDisplayChart])
|
||||
|
||||
// Save messages to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
if (!hasRestoredRef.current) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_MESSAGES_KEY, JSON.stringify(messages))
|
||||
} catch (error) {
|
||||
console.error("Failed to save messages to localStorage:", error)
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Save XML snapshots to localStorage whenever they change
|
||||
const saveXmlSnapshots = useCallback(() => {
|
||||
try {
|
||||
const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
|
||||
localStorage.setItem(
|
||||
STORAGE_XML_SNAPSHOTS_KEY,
|
||||
JSON.stringify(snapshotsArray),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save XML snapshots to localStorage:",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save session ID to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
||||
}, [sessionId])
|
||||
|
||||
// Save current diagram XML to localStorage whenever it changes
|
||||
// Only save after initial restore is complete and if it's not an empty diagram
|
||||
useEffect(() => {
|
||||
if (!canSaveDiagram) return
|
||||
// Don't save empty diagrams (check for minimal content)
|
||||
if (chartXML && chartXML.length > 300) {
|
||||
console.log(
|
||||
"[ChatPanel] Saving diagram to localStorage, length:",
|
||||
chartXML.length,
|
||||
)
|
||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
|
||||
}
|
||||
}, [chartXML, canSaveDiagram])
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages])
|
||||
|
||||
// Save state right before page unload (refresh/close)
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_MESSAGES_KEY,
|
||||
JSON.stringify(messagesRef.current),
|
||||
)
|
||||
localStorage.setItem(
|
||||
STORAGE_XML_SNAPSHOTS_KEY,
|
||||
JSON.stringify(
|
||||
Array.from(xmlSnapshotsRef.current.entries()),
|
||||
),
|
||||
)
|
||||
const xml = chartXMLRef.current
|
||||
if (xml && xml.length > 300) {
|
||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml)
|
||||
}
|
||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
||||
} catch (error) {
|
||||
console.error("Failed to persist state before unload:", error)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [sessionId])
|
||||
|
||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const isProcessing = status === "streaming" || status === "submitted";
|
||||
e.preventDefault()
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
if (input.trim() && !isProcessing) {
|
||||
// Check if input matches a cached example (only when no messages yet)
|
||||
if (messages.length === 0) {
|
||||
const cached = findCachedResponse(
|
||||
input.trim(),
|
||||
files.length > 0,
|
||||
)
|
||||
if (cached) {
|
||||
// Add user message and fake assistant response to messages
|
||||
// The chat-message-display useEffect will handle displaying the diagram
|
||||
const toolCallId = `cached-${Date.now()}`
|
||||
setMessages([
|
||||
{
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user" as const,
|
||||
parts: [{ type: "text" as const, text: input }],
|
||||
},
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "tool-display_diagram" as const,
|
||||
toolCallId,
|
||||
state: "output-available" as const,
|
||||
input: { xml: cached.xml },
|
||||
output: "Successfully displayed the diagram.",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any)
|
||||
setInput("")
|
||||
setFiles([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let chartXml = await onFetchChart();
|
||||
chartXml = formatXML(chartXml);
|
||||
let chartXml = await onFetchChart()
|
||||
chartXml = formatXML(chartXml)
|
||||
|
||||
// Update ref directly to avoid race condition with React's async state update
|
||||
// This ensures edit_diagram has the correct XML before AI responds
|
||||
chartXMLRef.current = chartXml;
|
||||
chartXMLRef.current = chartXml
|
||||
|
||||
const parts: any[] = [{ type: "text", text: input }];
|
||||
const parts: any[] = [{ type: "text", text: input }]
|
||||
|
||||
if (files.length > 0) {
|
||||
for (const file of files) {
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader()
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
reader.onload = () =>
|
||||
resolve(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
resolve(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: dataUrl,
|
||||
mediaType: file.type,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Save XML snapshot for this message (will be at index = current messages.length)
|
||||
const messageIndex = messages.length;
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml);
|
||||
const messageIndex = messages.length
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||
saveXmlSnapshots()
|
||||
|
||||
const accessCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
sendMessage(
|
||||
{ parts },
|
||||
{
|
||||
@@ -270,80 +531,82 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
setInput("");
|
||||
setFiles([]);
|
||||
setInput("")
|
||||
setFiles([])
|
||||
} catch (error) {
|
||||
console.error("Error fetching chart data:", error);
|
||||
console.error("Error fetching chart data:", error)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
setInput(e.target.value);
|
||||
};
|
||||
setInput(e.target.value)
|
||||
}
|
||||
|
||||
const handleFileChange = (newFiles: File[]) => {
|
||||
setFiles(newFiles);
|
||||
};
|
||||
setFiles(newFiles)
|
||||
}
|
||||
|
||||
const handleRegenerate = async (messageIndex: number) => {
|
||||
const isProcessing = status === "streaming" || status === "submitted";
|
||||
if (isProcessing) return;
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
if (isProcessing) return
|
||||
|
||||
// Find the user message before this assistant message
|
||||
let userMessageIndex = messageIndex - 1;
|
||||
let userMessageIndex = messageIndex - 1
|
||||
while (
|
||||
userMessageIndex >= 0 &&
|
||||
messages[userMessageIndex].role !== "user"
|
||||
) {
|
||||
userMessageIndex--;
|
||||
userMessageIndex--
|
||||
}
|
||||
|
||||
if (userMessageIndex < 0) return;
|
||||
if (userMessageIndex < 0) return
|
||||
|
||||
const userMessage = messages[userMessageIndex];
|
||||
const userParts = userMessage.parts;
|
||||
const userMessage = messages[userMessageIndex]
|
||||
const userParts = userMessage.parts
|
||||
|
||||
// Get the text from the user message
|
||||
const textPart = userParts?.find((p: any) => p.type === "text");
|
||||
if (!textPart) return;
|
||||
const textPart = userParts?.find((p: any) => p.type === "text")
|
||||
if (!textPart) return
|
||||
|
||||
// Get the saved XML snapshot for this user message
|
||||
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex);
|
||||
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
|
||||
if (!savedXml) {
|
||||
console.error(
|
||||
"No saved XML snapshot for message index:",
|
||||
userMessageIndex
|
||||
);
|
||||
return;
|
||||
userMessageIndex,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the diagram to the saved state
|
||||
onDisplayChart(savedXml);
|
||||
// Restore the diagram to the saved state (skip validation for trusted snapshots)
|
||||
onDisplayChart(savedXml, true)
|
||||
|
||||
// Update ref directly to ensure edit_diagram has the correct XML
|
||||
chartXMLRef.current = savedXml;
|
||||
chartXMLRef.current = savedXml
|
||||
|
||||
// Clean up snapshots for messages after the user message (they will be removed)
|
||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||
if (key > userMessageIndex) {
|
||||
xmlSnapshotsRef.current.delete(key);
|
||||
xmlSnapshotsRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
saveXmlSnapshots()
|
||||
|
||||
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
||||
// Use flushSync to ensure state update is processed synchronously before sending
|
||||
const newMessages = messages.slice(0, userMessageIndex);
|
||||
const newMessages = messages.slice(0, userMessageIndex)
|
||||
flushSync(() => {
|
||||
setMessages(newMessages);
|
||||
});
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Now send the message after state is guaranteed to be updated
|
||||
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
sendMessage(
|
||||
{ parts: userParts },
|
||||
{
|
||||
@@ -351,56 +614,61 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
xml: savedXml,
|
||||
sessionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
||||
const isProcessing = status === "streaming" || status === "submitted";
|
||||
if (isProcessing) return;
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
if (isProcessing) return
|
||||
|
||||
const message = messages[messageIndex];
|
||||
if (!message || message.role !== "user") return;
|
||||
const message = messages[messageIndex]
|
||||
if (!message || message.role !== "user") return
|
||||
|
||||
// Get the saved XML snapshot for this user message
|
||||
const savedXml = xmlSnapshotsRef.current.get(messageIndex);
|
||||
const savedXml = xmlSnapshotsRef.current.get(messageIndex)
|
||||
if (!savedXml) {
|
||||
console.error(
|
||||
"No saved XML snapshot for message index:",
|
||||
messageIndex
|
||||
);
|
||||
return;
|
||||
messageIndex,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the diagram to the saved state
|
||||
onDisplayChart(savedXml);
|
||||
// Restore the diagram to the saved state (skip validation for trusted snapshots)
|
||||
onDisplayChart(savedXml, true)
|
||||
|
||||
// Update ref directly to ensure edit_diagram has the correct XML
|
||||
chartXMLRef.current = savedXml;
|
||||
chartXMLRef.current = savedXml
|
||||
|
||||
// Clean up snapshots for messages after the user message (they will be removed)
|
||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||
if (key > messageIndex) {
|
||||
xmlSnapshotsRef.current.delete(key);
|
||||
xmlSnapshotsRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
saveXmlSnapshots()
|
||||
|
||||
// Create new parts with updated text
|
||||
const newParts = message.parts?.map((part: any) => {
|
||||
if (part.type === "text") {
|
||||
return { ...part, text: newText };
|
||||
return { ...part, text: newText }
|
||||
}
|
||||
return part;
|
||||
}) || [{ type: "text", text: newText }];
|
||||
return part
|
||||
}) || [{ type: "text", text: newText }]
|
||||
|
||||
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
||||
// Use flushSync to ensure state update is processed synchronously before sending
|
||||
const newMessages = messages.slice(0, messageIndex);
|
||||
const newMessages = messages.slice(0, messageIndex)
|
||||
flushSync(() => {
|
||||
setMessages(newMessages);
|
||||
});
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Now send the edited message after state is guaranteed to be updated
|
||||
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
sendMessage(
|
||||
{ parts: newParts },
|
||||
{
|
||||
@@ -408,9 +676,12 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
xml: savedXml,
|
||||
sessionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsed view (desktop only)
|
||||
if (!isVisible && !isMobile) {
|
||||
@@ -435,7 +706,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
AI Chat
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Full view
|
||||
@@ -447,7 +718,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
style={{ position: "absolute" }}
|
||||
/>
|
||||
{/* Header */}
|
||||
<header className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}>
|
||||
<header
|
||||
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -458,7 +731,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
height={isMobile ? 24 : 28}
|
||||
className="rounded"
|
||||
/>
|
||||
<h1 className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}>
|
||||
<h1
|
||||
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
|
||||
>
|
||||
Next AI Drawio
|
||||
</h1>
|
||||
</div>
|
||||
@@ -488,19 +763,21 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<FaGithub className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} />
|
||||
<FaGithub
|
||||
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
||||
/>
|
||||
</a>
|
||||
{accessCodeRequired && (
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Settings"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSettingsDialog(true)}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<Settings className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Settings"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSettingsDialog(true)}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<Settings
|
||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
||||
/>
|
||||
</ButtonWithTooltip>
|
||||
{!isMobile && (
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Hide chat panel (Ctrl+B)"
|
||||
@@ -529,21 +806,30 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
</main>
|
||||
|
||||
{/* Input */}
|
||||
<footer className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}>
|
||||
<footer
|
||||
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
||||
>
|
||||
<ChatInput
|
||||
input={input}
|
||||
status={status}
|
||||
onSubmit={onFormSubmit}
|
||||
onChange={handleInputChange}
|
||||
onClearChat={() => {
|
||||
setMessages([]);
|
||||
clearDiagram();
|
||||
setSessionId(
|
||||
`session-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 9)}`
|
||||
);
|
||||
xmlSnapshotsRef.current.clear();
|
||||
setMessages([])
|
||||
clearDiagram()
|
||||
const newSessionId = `session-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 9)}`
|
||||
setSessionId(newSessionId)
|
||||
xmlSnapshotsRef.current.clear()
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
||||
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
|
||||
localStorage.setItem(
|
||||
STORAGE_SESSION_ID_KEY,
|
||||
newSessionId,
|
||||
)
|
||||
}}
|
||||
files={files}
|
||||
onFileChange={handleFileChange}
|
||||
@@ -559,7 +845,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
<SettingsDialog
|
||||
open={showSettingsDialog}
|
||||
onOpenChange={setShowSettingsDialog}
|
||||
onCloseProtectionChange={onCloseProtectionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
import { Highlight, themes } from "prism-react-renderer"
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: "xml" | "json";
|
||||
code: string
|
||||
language?: "xml" | "json"
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||
return (
|
||||
<div className="overflow-hidden w-full">
|
||||
<Highlight theme={themes.github} code={code} language={language}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
{({
|
||||
className: _className,
|
||||
style,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<pre
|
||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
|
||||
style={{
|
||||
...style,
|
||||
fontFamily: "var(--font-mono), ui-monospace, monospace",
|
||||
fontFamily:
|
||||
"var(--font-mono), ui-monospace, monospace",
|
||||
backgroundColor: "transparent",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
@@ -25,9 +32,16 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||
}}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ line })} style={{ wordBreak: "break-all" }}>
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({ line })}
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
<span
|
||||
key={key}
|
||||
{...getTokenProps({ token })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -35,5 +49,5 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||
)}
|
||||
</Highlight>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React from "react";
|
||||
import type React from "react"
|
||||
|
||||
interface ErrorToastProps {
|
||||
message: React.ReactNode;
|
||||
onDismiss: () => void;
|
||||
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();
|
||||
e.preventDefault()
|
||||
onDismiss()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -25,7 +25,12 @@ export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
||||
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">
|
||||
<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"
|
||||
@@ -35,5 +40,5 @@ export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
||||
</div>
|
||||
<span className="text-sm text-foreground">{message}</span>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,84 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { X } from "lucide-react";
|
||||
import { X } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
interface FilePreviewListProps {
|
||||
files: File[];
|
||||
onRemoveFile: (fileToRemove: File) => void;
|
||||
files: File[]
|
||||
onRemoveFile: (fileToRemove: File) => void
|
||||
}
|
||||
|
||||
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
|
||||
const imageUrlsRef = useRef<Map<File, string>>(new Map())
|
||||
|
||||
// Cleanup object URLs on unmount
|
||||
// Create and cleanup object URLs when files change
|
||||
useEffect(() => {
|
||||
const objectUrls = files
|
||||
.filter((file) => file.type.startsWith("image/"))
|
||||
.map((file) => URL.createObjectURL(file));
|
||||
const currentUrls = imageUrlsRef.current
|
||||
const newUrls = new Map<File, string>()
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
// Reuse existing URL if file is already tracked
|
||||
const existingUrl = currentUrls.get(file)
|
||||
if (existingUrl) {
|
||||
newUrls.set(file, existingUrl)
|
||||
} else {
|
||||
newUrls.set(file, URL.createObjectURL(file))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Revoke URLs for files that are no longer in the list
|
||||
currentUrls.forEach((url, file) => {
|
||||
if (!newUrls.has(file)) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
})
|
||||
|
||||
imageUrlsRef.current = newUrls
|
||||
setImageUrls(newUrls)
|
||||
}, [files])
|
||||
|
||||
// Cleanup all URLs on unmount only
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
objectUrls.forEach(URL.revokeObjectURL);
|
||||
};
|
||||
}, [files]);
|
||||
imageUrlsRef.current.forEach((url) => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (files.length === 0) return null;
|
||||
// Clear selected image if its URL was revoked
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedImage &&
|
||||
!Array.from(imageUrls.values()).includes(selectedImage)
|
||||
) {
|
||||
setSelectedImage(null)
|
||||
}
|
||||
}, [imageUrls, selectedImage])
|
||||
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
||||
{files.map((file, index) => {
|
||||
const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
||||
const imageUrl = imageUrls.get(file) || null
|
||||
return (
|
||||
<div key={file.name + index} className="relative group">
|
||||
<div
|
||||
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
|
||||
onClick={() => imageUrl && setSelectedImage(imageUrl)}
|
||||
onClick={() =>
|
||||
imageUrl && setSelectedImage(imageUrl)
|
||||
}
|
||||
>
|
||||
{file.type.startsWith("image/") ? (
|
||||
{file.type.startsWith("image/") && imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl!}
|
||||
src={imageUrl}
|
||||
alt={file.name}
|
||||
width={80}
|
||||
height={80}
|
||||
@@ -59,7 +99,7 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -89,5 +129,5 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,34 +10,33 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
|
||||
interface HistoryDialogProps {
|
||||
showHistory: boolean;
|
||||
onToggleHistory: (show: boolean) => void;
|
||||
showHistory: boolean
|
||||
onToggleHistory: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function HistoryDialog({
|
||||
showHistory,
|
||||
onToggleHistory,
|
||||
}: HistoryDialogProps) {
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram();
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedIndex(null);
|
||||
onToggleHistory(false);
|
||||
};
|
||||
setSelectedIndex(null)
|
||||
onToggleHistory(false)
|
||||
}
|
||||
|
||||
const handleConfirmRestore = () => {
|
||||
if (selectedIndex !== null) {
|
||||
onDisplayChart(diagramHistory[selectedIndex].xml);
|
||||
handleClose();
|
||||
// Skip validation for trusted history snapshots
|
||||
onDisplayChart(diagramHistory[selectedIndex].xml, true)
|
||||
handleClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||
@@ -100,15 +101,12 @@ export function HistoryDialog({
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
interface ResetWarningModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClear: () => void;
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function ResetWarningModal({
|
||||
@@ -44,5 +44,5 @@ export function ResetWarningModal({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg";
|
||||
export type ExportFormat = "drawio" | "png" | "svg"
|
||||
|
||||
const FORMAT_OPTIONS: { value: ExportFormat; label: string; extension: string }[] = [
|
||||
const FORMAT_OPTIONS: {
|
||||
value: ExportFormat
|
||||
label: string
|
||||
extension: string
|
||||
}[] = [
|
||||
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
|
||||
{ value: "png", label: "PNG Image", extension: ".png" },
|
||||
{ value: "svg", label: "SVG Image", extension: ".svg" },
|
||||
];
|
||||
]
|
||||
|
||||
interface SaveDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (filename: string, format: ExportFormat) => void;
|
||||
defaultFilename: string;
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (filename: string, format: ExportFormat) => void
|
||||
defaultFilename: string
|
||||
}
|
||||
|
||||
export function SaveDialog({
|
||||
@@ -39,29 +43,29 @@ export function SaveDialog({
|
||||
onSave,
|
||||
defaultFilename,
|
||||
}: SaveDialogProps) {
|
||||
const [filename, setFilename] = useState(defaultFilename);
|
||||
const [format, setFormat] = useState<ExportFormat>("drawio");
|
||||
const [filename, setFilename] = useState(defaultFilename)
|
||||
const [format, setFormat] = useState<ExportFormat>("drawio")
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFilename(defaultFilename);
|
||||
setFilename(defaultFilename)
|
||||
}
|
||||
}, [open, defaultFilename]);
|
||||
}, [open, defaultFilename])
|
||||
|
||||
const handleSave = () => {
|
||||
const finalFilename = filename.trim() || defaultFilename;
|
||||
onSave(finalFilename, format);
|
||||
onOpenChange(false);
|
||||
};
|
||||
const finalFilename = filename.trim() || defaultFilename
|
||||
onSave(finalFilename, format)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format);
|
||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -72,13 +76,19 @@ export function SaveDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Format</label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as ExportFormat)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -104,12 +114,15 @@ export function SaveDialog({
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,95 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCloseProtectionChange?: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code";
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||
|
||||
export function SettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCloseProtectionChange,
|
||||
}: SettingsDialogProps) {
|
||||
const [accessCode, setAccessCode] = useState("");
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [closeProtection, setCloseProtection] = useState(true)
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const storedCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
|
||||
setAccessCode(storedCode);
|
||||
}
|
||||
}, [open]);
|
||||
const storedCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
setAccessCode(storedCode)
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim());
|
||||
onOpenChange(false);
|
||||
};
|
||||
const storedCloseProtection = localStorage.getItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
)
|
||||
// Default to true if not set
|
||||
setCloseProtection(storedCloseProtection !== "false")
|
||||
setError("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSave = async () => {
|
||||
setError("")
|
||||
setIsVerifying(true)
|
||||
|
||||
try {
|
||||
// Verify access code with server
|
||||
const response = await fetch("/api/verify-access-code", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-access-code": accessCode.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.valid) {
|
||||
setError(data.message || "Invalid access code")
|
||||
setIsVerifying(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Save settings only if verification passes
|
||||
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
|
||||
localStorage.setItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
closeProtection.toString(),
|
||||
)
|
||||
onCloseProtectionChange?.(closeProtection)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
setError("Failed to verify access code")
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -69,15 +116,40 @@ export function SettingsDialog({
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Required if the server has enabled access control.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-[0.8rem] text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="close-protection">
|
||||
Close Protection
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Show confirmation when leaving the page.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="close-protection"
|
||||
checked={closeProtection}
|
||||
onCheckedChange={setCloseProtection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<Button onClick={handleSave} disabled={isVerifying}>
|
||||
{isVerifying ? "Verifying..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
31
components/ui/switch.tsx
Normal file
31
components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -1,85 +1,119 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useRef, useState } from "react";
|
||||
import type { DrawIoEmbedRef } from "react-drawio";
|
||||
import { extractDiagramXML } from "../lib/utils";
|
||||
import type { ExportFormat } from "@/components/save-dialog";
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { extractDiagramXML, validateMxCellStructure } from "../lib/utils"
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string;
|
||||
latestSvg: string;
|
||||
diagramHistory: { svg: string; xml: string }[];
|
||||
loadDiagram: (chart: string) => void;
|
||||
handleExport: () => void;
|
||||
handleExportWithoutHistory: () => void;
|
||||
resolverRef: React.Ref<((value: string) => void) | null>;
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
||||
handleDiagramExport: (data: any) => void;
|
||||
clearDiagram: () => void;
|
||||
saveDiagramToFile: (filename: string, format: ExportFormat, sessionId?: string) => void;
|
||||
chartXML: string
|
||||
latestSvg: string
|
||||
diagramHistory: { svg: string; xml: string }[]
|
||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.Ref<((value: string) => void) | null>
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
) => void
|
||||
isDrawioReady: boolean
|
||||
onDrawioLoad: () => void
|
||||
}
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [chartXML, setChartXML] = useState<string>("");
|
||||
const [latestSvg, setLatestSvg] = useState<string>("");
|
||||
const [chartXML, setChartXML] = useState<string>("")
|
||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||
const [diagramHistory, setDiagramHistory] = useState<
|
||||
{ svg: string; xml: string }[]
|
||||
>([]);
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null);
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null);
|
||||
>([])
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const hasCalledOnLoadRef = useRef(false)
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false);
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
|
||||
const onDrawioLoad = () => {
|
||||
// Only set ready state once to prevent infinite loops
|
||||
if (hasCalledOnLoadRef.current) return
|
||||
hasCalledOnLoadRef.current = true
|
||||
console.log("[DiagramContext] DrawIO loaded, setting ready state")
|
||||
setIsDrawioReady(true)
|
||||
}
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string) => void) | null;
|
||||
format: ExportFormat | null;
|
||||
}>({ resolver: null, format: null });
|
||||
resolver: ((data: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
|
||||
const handleExport = () => {
|
||||
if (drawioRef.current) {
|
||||
// Mark that this export should be saved to history
|
||||
expectHistoryExportRef.current = true;
|
||||
expectHistoryExportRef.current = true
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleExportWithoutHistory = () => {
|
||||
if (drawioRef.current) {
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const loadDiagram = (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
): string | null => {
|
||||
// Validate XML structure before loading (unless skipped for internal use)
|
||||
if (!skipValidation) {
|
||||
const validationError = validateMxCellStructure(chart)
|
||||
if (validationError) {
|
||||
console.warn("[loadDiagram] Validation error:", validationError)
|
||||
return validationError
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||
setChartXML(chart)
|
||||
|
||||
const loadDiagram = (chart: string) => {
|
||||
if (drawioRef.current) {
|
||||
drawioRef.current.load({
|
||||
xml: chart,
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: any) => {
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format;
|
||||
saveResolverRef.current.resolver(data.data);
|
||||
saveResolverRef.current = { resolver: null, format: null };
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
if (format === "png" || format === "svg") {
|
||||
return;
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const extractedXML = extractDiagramXML(data.data);
|
||||
setChartXML(extractedXML);
|
||||
setLatestSvg(data.data);
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setChartXML(extractedXML)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
if (expectHistoryExportRef.current) {
|
||||
@@ -89,106 +123,117 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
svg: data.data,
|
||||
xml: extractedXML,
|
||||
},
|
||||
]);
|
||||
expectHistoryExportRef.current = false;
|
||||
])
|
||||
expectHistoryExportRef.current = false
|
||||
}
|
||||
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(extractedXML);
|
||||
resolverRef.current = null;
|
||||
resolverRef.current(extractedXML)
|
||||
resolverRef.current = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const clearDiagram = () => {
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`;
|
||||
loadDiagram(emptyDiagram);
|
||||
setChartXML(emptyDiagram);
|
||||
setLatestSvg("");
|
||||
setDiagramHistory([]);
|
||||
};
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
loadDiagram(emptyDiagram, true)
|
||||
setLatestSvg("")
|
||||
setDiagramHistory([])
|
||||
}
|
||||
|
||||
const saveDiagramToFile = (filename: string, format: ExportFormat, sessionId?: string) => {
|
||||
const saveDiagramToFile = (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
) => {
|
||||
if (!drawioRef.current) {
|
||||
console.warn("Draw.io editor not ready");
|
||||
return;
|
||||
console.warn("Draw.io editor not ready")
|
||||
return
|
||||
}
|
||||
|
||||
// Map format to draw.io export format
|
||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format;
|
||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string) => {
|
||||
let fileContent: string | Blob;
|
||||
let mimeType: string;
|
||||
let extension: string;
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Extract XML from SVG for .drawio format
|
||||
const xml = extractDiagramXML(exportData);
|
||||
let xmlContent = xml;
|
||||
const xml = extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`;
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
}
|
||||
fileContent = xmlContent;
|
||||
mimeType = "application/xml";
|
||||
extension = ".drawio";
|
||||
fileContent = xmlContent
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData;
|
||||
mimeType = "image/png";
|
||||
extension = ".png";
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else {
|
||||
// SVG format
|
||||
fileContent = exportData;
|
||||
mimeType = "image/svg+xml";
|
||||
extension = ".svg";
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId);
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
// Handle download
|
||||
let url: string;
|
||||
if (typeof fileContent === "string" && fileContent.startsWith("data:")) {
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent;
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType });
|
||||
url = URL.createObjectURL(blob);
|
||||
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);
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
},
|
||||
format,
|
||||
};
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
drawioRef.current.exportDiagram({ format: drawioFormat });
|
||||
};
|
||||
drawioRef.current.exportDiagram({ format: drawioFormat })
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||
const logSaveToLangfuse = async (filename: string, format: string, sessionId?: string) => {
|
||||
const logSaveToLangfuse = async (
|
||||
filename: string,
|
||||
format: string,
|
||||
sessionId?: string,
|
||||
) => {
|
||||
try {
|
||||
await fetch("/api/log-save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, format, sessionId }),
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Failed to log save to Langfuse:", error);
|
||||
console.warn("Failed to log save to Langfuse:", error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<DiagramContext.Provider
|
||||
@@ -204,17 +249,19 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
handleDiagramExport,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DiagramContext.Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function useDiagram() {
|
||||
const context = useContext(DiagramContext);
|
||||
const context = useContext(DiagramContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useDiagram must be used within a DiagramProvider");
|
||||
throw new Error("useDiagram must be used within a DiagramProvider")
|
||||
}
|
||||
return context;
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -63,6 +63,19 @@ Optional custom endpoint:
|
||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### SiliconFlow (OpenAI-compatible)
|
||||
|
||||
```bash
|
||||
SILICONFLOW_API_KEY=your_api_key
|
||||
AI_MODEL=deepseek-ai/DeepSeek-V3 # example; use any SiliconFlow model id
|
||||
```
|
||||
|
||||
Optional custom endpoint (defaults to the recommended domain):
|
||||
|
||||
```bash
|
||||
SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # or https://api.siliconflow.cn/v1
|
||||
```
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
```bash
|
||||
@@ -120,7 +133,7 @@ If you only configure **one** provider's API key, the system will automatically
|
||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, azure, bedrock, openrouter, ollama
|
||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama
|
||||
```
|
||||
|
||||
## Model Capability Requirements
|
||||
@@ -133,6 +146,20 @@ This task requires exceptionally strong model capabilities, as it involves gener
|
||||
|
||||
**Note on Ollama**: While Ollama is supported as a provider, it's generally not practical for this use case unless you're running high-capability models like DeepSeek R1 or Qwen3-235B locally.
|
||||
|
||||
## Temperature Setting
|
||||
|
||||
You can optionally configure the temperature via environment variable:
|
||||
|
||||
```bash
|
||||
TEMPERATURE=0 # More deterministic output (recommended for diagrams)
|
||||
```
|
||||
|
||||
**Important**: Leave `TEMPERATURE` unset for models that don't support temperature settings, such as:
|
||||
- GPT-5.1 and other reasoning models
|
||||
- Some specialized models
|
||||
|
||||
When unset, the model uses its default behavior.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features
|
||||
|
||||
12
env.example
12
env.example
@@ -1,6 +1,6 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek
|
||||
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
@@ -42,11 +42,21 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# DEEPSEEK_API_KEY=sk-...
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||
|
||||
# SiliconFlow Configuration (OpenAI-compatible)
|
||||
# Base domain can be .com or .cn, defaults to https://api.siliconflow.com/v1
|
||||
# SILICONFLOW_API_KEY=sk-...
|
||||
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
|
||||
|
||||
# Langfuse Observability (Optional)
|
||||
# Enable LLM tracing and analytics - https://langfuse.com
|
||||
# LANGFUSE_PUBLIC_KEY=pk-lf-...
|
||||
# LANGFUSE_SECRET_KEY=sk-lf-...
|
||||
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
||||
|
||||
# Temperature (Optional)
|
||||
# Controls randomness in AI responses. Lower = more deterministic.
|
||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||
# TEMPERATURE=0
|
||||
|
||||
# Access Control (Optional)
|
||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
|
||||
export function register() {
|
||||
// Skip telemetry if Langfuse env vars are not configured
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
console.warn('[Langfuse] Environment variables not configured - telemetry disabled');
|
||||
return;
|
||||
}
|
||||
// Skip telemetry if Langfuse env vars are not configured
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
console.warn(
|
||||
"[Langfuse] Environment variables not configured - telemetry disabled",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const langfuseSpanProcessor = new LangfuseSpanProcessor({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
||||
shouldExportSpan: ({ otelSpan }) => {
|
||||
const spanName = otelSpan.name;
|
||||
// Skip Next.js HTTP infrastructure spans
|
||||
if (spanName.startsWith('POST /') ||
|
||||
spanName.startsWith('GET /') ||
|
||||
spanName.includes('BaseServer') ||
|
||||
spanName.includes('handleRequest')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const langfuseSpanProcessor = new LangfuseSpanProcessor({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
||||
shouldExportSpan: ({ otelSpan }) => {
|
||||
const spanName = otelSpan.name
|
||||
// Skip Next.js HTTP infrastructure spans
|
||||
if (
|
||||
spanName.startsWith("POST /") ||
|
||||
spanName.startsWith("GET /") ||
|
||||
spanName.includes("BaseServer") ||
|
||||
spanName.includes("handleRequest")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
const tracerProvider = new NodeTracerProvider({
|
||||
spanProcessors: [langfuseSpanProcessor],
|
||||
});
|
||||
const tracerProvider = new NodeTracerProvider({
|
||||
spanProcessors: [langfuseSpanProcessor],
|
||||
})
|
||||
|
||||
// Register globally so AI SDK's telemetry also uses this processor
|
||||
tracerProvider.register();
|
||||
// Register globally so AI SDK's telemetry also uses this processor
|
||||
tracerProvider.register()
|
||||
}
|
||||
|
||||
@@ -1,96 +1,98 @@
|
||||
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
|
||||
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||
import { openai, createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { azure, createAzure } from '@ai-sdk/azure';
|
||||
import { ollama, createOllama } from 'ollama-ai-provider-v2';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { deepseek, createDeepSeek } from '@ai-sdk/deepseek';
|
||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { azure, createAzure } from "@ai-sdk/azure"
|
||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||
|
||||
export type ProviderName =
|
||||
| 'bedrock'
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'azure'
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'deepseek';
|
||||
| "bedrock"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "azure"
|
||||
| "ollama"
|
||||
| "openrouter"
|
||||
| "deepseek"
|
||||
| "siliconflow"
|
||||
|
||||
interface ModelConfig {
|
||||
model: any;
|
||||
providerOptions?: any;
|
||||
headers?: Record<string, string>;
|
||||
modelId: string;
|
||||
model: any
|
||||
providerOptions?: any
|
||||
headers?: Record<string, string>
|
||||
modelId: string
|
||||
}
|
||||
|
||||
// Bedrock provider options for Anthropic beta features
|
||||
const BEDROCK_ANTHROPIC_BETA = {
|
||||
bedrock: {
|
||||
anthropicBeta: ['fine-grained-tool-streaming-2025-05-14'],
|
||||
},
|
||||
};
|
||||
bedrock: {
|
||||
anthropicBeta: ["fine-grained-tool-streaming-2025-05-14"],
|
||||
},
|
||||
}
|
||||
|
||||
// Direct Anthropic API headers for beta features
|
||||
const ANTHROPIC_BETA_HEADERS = {
|
||||
'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14',
|
||||
};
|
||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||
}
|
||||
|
||||
// Map of provider to required environment variable
|
||||
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||
openai: 'OPENAI_API_KEY',
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
azure: 'AZURE_API_KEY',
|
||||
ollama: null, // No credentials needed for local Ollama
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
deepseek: 'DEEPSEEK_API_KEY',
|
||||
};
|
||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
azure: "AZURE_API_KEY",
|
||||
ollama: null, // No credentials needed for local Ollama
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
siliconflow: "SILICONFLOW_API_KEY",
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect provider based on available API keys
|
||||
* Returns the provider if exactly one is configured, otherwise null
|
||||
*/
|
||||
function detectProvider(): ProviderName | null {
|
||||
const configuredProviders: ProviderName[] = [];
|
||||
const configuredProviders: ProviderName[] = []
|
||||
|
||||
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
|
||||
if (envVar === null) {
|
||||
// Skip ollama - it doesn't require credentials
|
||||
continue;
|
||||
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
|
||||
if (envVar === null) {
|
||||
// Skip ollama - it doesn't require credentials
|
||||
continue
|
||||
}
|
||||
if (process.env[envVar]) {
|
||||
configuredProviders.push(provider as ProviderName)
|
||||
}
|
||||
}
|
||||
if (process.env[envVar]) {
|
||||
configuredProviders.push(provider as ProviderName);
|
||||
|
||||
if (configuredProviders.length === 1) {
|
||||
return configuredProviders[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (configuredProviders.length === 1) {
|
||||
return configuredProviders[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required API keys are present for the selected provider
|
||||
*/
|
||||
function validateProviderCredentials(provider: ProviderName): void {
|
||||
const requiredVar = PROVIDER_ENV_VARS[provider];
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
throw new Error(
|
||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||
`Please set it in your .env.local file.`
|
||||
);
|
||||
}
|
||||
const requiredVar = PROVIDER_ENV_VARS[provider]
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
throw new Error(
|
||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||
`Please set it in your .env.local file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AI model based on environment variables
|
||||
*
|
||||
* Environment variables:
|
||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
||||
* - AI_MODEL: The model ID/name for the selected provider
|
||||
*
|
||||
* Provider-specific env vars:
|
||||
@@ -104,160 +106,180 @@ function validateProviderCredentials(provider: ProviderName): void {
|
||||
* - OPENROUTER_API_KEY: OpenRouter API key
|
||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
||||
*/
|
||||
export function getAIModel(): ModelConfig {
|
||||
const modelId = process.env.AI_MODEL;
|
||||
const modelId = process.env.AI_MODEL
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error(
|
||||
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`
|
||||
);
|
||||
}
|
||||
if (!modelId) {
|
||||
throw new Error(
|
||||
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`,
|
||||
)
|
||||
}
|
||||
|
||||
// Determine provider: explicit config > auto-detect > error
|
||||
let provider: ProviderName;
|
||||
if (process.env.AI_PROVIDER) {
|
||||
provider = process.env.AI_PROVIDER as ProviderName;
|
||||
} else {
|
||||
const detected = detectProvider();
|
||||
if (detected) {
|
||||
provider = detected;
|
||||
console.log(`[AI Provider] Auto-detected provider: ${provider}`);
|
||||
// Determine provider: explicit config > auto-detect > error
|
||||
let provider: ProviderName
|
||||
if (process.env.AI_PROVIDER) {
|
||||
provider = process.env.AI_PROVIDER as ProviderName
|
||||
} else {
|
||||
// List configured providers for better error message
|
||||
const configured = Object.entries(PROVIDER_ENV_VARS)
|
||||
.filter(([, envVar]) => envVar && process.env[envVar as string])
|
||||
.map(([p]) => p);
|
||||
const detected = detectProvider()
|
||||
if (detected) {
|
||||
provider = detected
|
||||
console.log(`[AI Provider] Auto-detected provider: ${provider}`)
|
||||
} else {
|
||||
// List configured providers for better error message
|
||||
const configured = Object.entries(PROVIDER_ENV_VARS)
|
||||
.filter(([, envVar]) => envVar && process.env[envVar as string])
|
||||
.map(([p]) => p)
|
||||
|
||||
if (configured.length === 0) {
|
||||
throw new Error(
|
||||
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` +
|
||||
`- DEEPSEEK_API_KEY for DeepSeek\n` +
|
||||
`- OPENAI_API_KEY for OpenAI\n` +
|
||||
`- ANTHROPIC_API_KEY for Anthropic\n` +
|
||||
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||
`- AZURE_API_KEY for Azure\n` +
|
||||
`Or set AI_PROVIDER=ollama for local Ollama.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Multiple AI providers configured (${configured.join(', ')}). ` +
|
||||
`Please set AI_PROVIDER to specify which one to use.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate provider credentials
|
||||
validateProviderCredentials(provider);
|
||||
|
||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`);
|
||||
|
||||
let model: any;
|
||||
let providerOptions: any = undefined;
|
||||
let headers: Record<string, string> | undefined = undefined;
|
||||
|
||||
switch (provider) {
|
||||
case 'bedrock': {
|
||||
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
|
||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||
const bedrockProvider = createAmazonBedrock({
|
||||
region: process.env.AWS_REGION || 'us-west-2',
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
});
|
||||
model = bedrockProvider(modelId);
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes('anthropic.claude')) {
|
||||
providerOptions = BEDROCK_ANTHROPIC_BETA;
|
||||
}
|
||||
break;
|
||||
if (configured.length === 0) {
|
||||
throw new Error(
|
||||
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` +
|
||||
`- DEEPSEEK_API_KEY for DeepSeek\n` +
|
||||
`- OPENAI_API_KEY for OpenAI\n` +
|
||||
`- ANTHROPIC_API_KEY for Anthropic\n` +
|
||||
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||
`- AZURE_API_KEY for Azure\n` +
|
||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Multiple AI providers configured (${configured.join(", ")}). ` +
|
||||
`Please set AI_PROVIDER to specify which one to use.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'openai':
|
||||
if (process.env.OPENAI_BASE_URL) {
|
||||
const customOpenAI = createOpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
});
|
||||
model = customOpenAI.chat(modelId);
|
||||
} else {
|
||||
model = openai(modelId);
|
||||
}
|
||||
break;
|
||||
// Validate provider credentials
|
||||
validateProviderCredentials(provider)
|
||||
|
||||
case 'anthropic':
|
||||
const customProvider = createAnthropic({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1',
|
||||
headers: ANTHROPIC_BETA_HEADERS,
|
||||
});
|
||||
model = customProvider(modelId);
|
||||
// Add beta headers for fine-grained tool streaming
|
||||
headers = ANTHROPIC_BETA_HEADERS;
|
||||
break;
|
||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
||||
|
||||
case 'google':
|
||||
if (process.env.GOOGLE_BASE_URL) {
|
||||
const customGoogle = createGoogleGenerativeAI({
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
||||
baseURL: process.env.GOOGLE_BASE_URL,
|
||||
});
|
||||
model = customGoogle(modelId);
|
||||
} else {
|
||||
model = google(modelId);
|
||||
}
|
||||
break;
|
||||
let model: any
|
||||
let providerOptions: any
|
||||
let headers: Record<string, string> | undefined
|
||||
|
||||
case 'azure':
|
||||
if (process.env.AZURE_BASE_URL) {
|
||||
const customAzure = createAzure({
|
||||
apiKey: process.env.AZURE_API_KEY,
|
||||
baseURL: process.env.AZURE_BASE_URL,
|
||||
});
|
||||
model = customAzure(modelId);
|
||||
} else {
|
||||
model = azure(modelId);
|
||||
}
|
||||
break;
|
||||
switch (provider) {
|
||||
case "bedrock": {
|
||||
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
|
||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||
const bedrockProvider = createAmazonBedrock({
|
||||
region: process.env.AWS_REGION || "us-west-2",
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
providerOptions = BEDROCK_ANTHROPIC_BETA
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'ollama':
|
||||
if (process.env.OLLAMA_BASE_URL) {
|
||||
const customOllama = createOllama({
|
||||
baseURL: process.env.OLLAMA_BASE_URL,
|
||||
});
|
||||
model = customOllama(modelId);
|
||||
} else {
|
||||
model = ollama(modelId);
|
||||
}
|
||||
break;
|
||||
case "openai":
|
||||
if (process.env.OPENAI_BASE_URL) {
|
||||
const customOpenAI = createOpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
})
|
||||
model = customOpenAI.chat(modelId)
|
||||
} else {
|
||||
model = openai(modelId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'openrouter':
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
...(process.env.OPENROUTER_BASE_URL && { baseURL: process.env.OPENROUTER_BASE_URL }),
|
||||
});
|
||||
model = openrouter(modelId);
|
||||
break;
|
||||
case "anthropic": {
|
||||
const customProvider = createAnthropic({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
baseURL:
|
||||
process.env.ANTHROPIC_BASE_URL ||
|
||||
"https://api.anthropic.com/v1",
|
||||
headers: ANTHROPIC_BETA_HEADERS,
|
||||
})
|
||||
model = customProvider(modelId)
|
||||
// Add beta headers for fine-grained tool streaming
|
||||
headers = ANTHROPIC_BETA_HEADERS
|
||||
break
|
||||
}
|
||||
|
||||
case 'deepseek':
|
||||
if (process.env.DEEPSEEK_BASE_URL) {
|
||||
const customDeepSeek = createDeepSeek({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: process.env.DEEPSEEK_BASE_URL,
|
||||
});
|
||||
model = customDeepSeek(modelId);
|
||||
} else {
|
||||
model = deepseek(modelId);
|
||||
}
|
||||
break;
|
||||
case "google":
|
||||
if (process.env.GOOGLE_BASE_URL) {
|
||||
const customGoogle = createGoogleGenerativeAI({
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
||||
baseURL: process.env.GOOGLE_BASE_URL,
|
||||
})
|
||||
model = customGoogle(modelId)
|
||||
} else {
|
||||
model = google(modelId)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`
|
||||
);
|
||||
}
|
||||
case "azure":
|
||||
if (process.env.AZURE_BASE_URL) {
|
||||
const customAzure = createAzure({
|
||||
apiKey: process.env.AZURE_API_KEY,
|
||||
baseURL: process.env.AZURE_BASE_URL,
|
||||
})
|
||||
model = customAzure(modelId)
|
||||
} else {
|
||||
model = azure(modelId)
|
||||
}
|
||||
break
|
||||
|
||||
return { model, providerOptions, headers, modelId };
|
||||
case "ollama":
|
||||
if (process.env.OLLAMA_BASE_URL) {
|
||||
const customOllama = createOllama({
|
||||
baseURL: process.env.OLLAMA_BASE_URL,
|
||||
})
|
||||
model = customOllama(modelId)
|
||||
} else {
|
||||
model = ollama(modelId)
|
||||
}
|
||||
break
|
||||
|
||||
case "openrouter": {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
...(process.env.OPENROUTER_BASE_URL && {
|
||||
baseURL: process.env.OPENROUTER_BASE_URL,
|
||||
}),
|
||||
})
|
||||
model = openrouter(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek":
|
||||
if (process.env.DEEPSEEK_BASE_URL) {
|
||||
const customDeepSeek = createDeepSeek({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: process.env.DEEPSEEK_BASE_URL,
|
||||
})
|
||||
model = customDeepSeek(modelId)
|
||||
} else {
|
||||
model = deepseek(modelId)
|
||||
}
|
||||
break
|
||||
|
||||
case "siliconflow": {
|
||||
const siliconflowProvider = createOpenAI({
|
||||
apiKey: process.env.SILICONFLOW_API_KEY,
|
||||
baseURL:
|
||||
process.env.SILICONFLOW_BASE_URL ||
|
||||
"https://api.siliconflow.com/v1",
|
||||
})
|
||||
model = siliconflowProvider.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow`,
|
||||
)
|
||||
}
|
||||
|
||||
return { model, providerOptions, headers, modelId }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
export interface CachedResponse {
|
||||
promptText: string;
|
||||
hasImage: boolean;
|
||||
xml: string;
|
||||
promptText: string
|
||||
hasImage: boolean
|
||||
xml: string
|
||||
}
|
||||
|
||||
export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
{
|
||||
promptText: "Give me a **animated connector** diagram of transformer's architecture",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
{
|
||||
promptText:
|
||||
"Give me a **animated connector** diagram of transformer's architecture",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
@@ -255,11 +256,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
<mxGeometry x="660" y="530" width="100" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>`,
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this in aws style",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this in aws style",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
@@ -325,11 +326,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>`,
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this flowchart.",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
},
|
||||
{
|
||||
promptText: "Replicate this flowchart.",
|
||||
hasImage: true,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
@@ -392,11 +393,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>`,
|
||||
},
|
||||
{
|
||||
promptText: "Draw a cat for me",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
},
|
||||
{
|
||||
promptText: "Draw a cat for me",
|
||||
hasImage: false,
|
||||
xml: `<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
@@ -544,14 +545,17 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
</mxCell>
|
||||
|
||||
</root>`,
|
||||
},
|
||||
];
|
||||
},
|
||||
]
|
||||
|
||||
export function findCachedResponse(
|
||||
promptText: string,
|
||||
hasImage: boolean
|
||||
promptText: string,
|
||||
hasImage: boolean,
|
||||
): CachedResponse | undefined {
|
||||
return CACHED_EXAMPLE_RESPONSES.find(
|
||||
(c) => c.promptText === promptText && c.hasImage === hasImage && c.xml !== ''
|
||||
);
|
||||
return CACHED_EXAMPLE_RESPONSES.find(
|
||||
(c) =>
|
||||
c.promptText === promptText &&
|
||||
c.hasImage === hasImage &&
|
||||
c.xml !== "",
|
||||
)
|
||||
}
|
||||
|
||||
132
lib/langfuse.ts
132
lib/langfuse.ts
@@ -1,95 +1,107 @@
|
||||
import { observe, updateActiveTrace } from '@langfuse/tracing';
|
||||
import { LangfuseClient } from '@langfuse/client';
|
||||
import * as api from '@opentelemetry/api';
|
||||
import { LangfuseClient } from "@langfuse/client"
|
||||
import { observe, updateActiveTrace } from "@langfuse/tracing"
|
||||
import * as api from "@opentelemetry/api"
|
||||
|
||||
// Singleton LangfuseClient instance for direct API calls
|
||||
let langfuseClient: LangfuseClient | null = null;
|
||||
let langfuseClient: LangfuseClient | null = null
|
||||
|
||||
export function getLangfuseClient(): LangfuseClient | null {
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
return null;
|
||||
}
|
||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!langfuseClient) {
|
||||
langfuseClient = new LangfuseClient({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
});
|
||||
}
|
||||
if (!langfuseClient) {
|
||||
langfuseClient = new LangfuseClient({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
})
|
||||
}
|
||||
|
||||
return langfuseClient;
|
||||
return langfuseClient
|
||||
}
|
||||
|
||||
// Check if Langfuse is configured
|
||||
export function isLangfuseEnabled(): boolean {
|
||||
return !!process.env.LANGFUSE_PUBLIC_KEY;
|
||||
return !!process.env.LANGFUSE_PUBLIC_KEY
|
||||
}
|
||||
|
||||
// Update trace with input data at the start of request
|
||||
export function setTraceInput(params: {
|
||||
input: string;
|
||||
sessionId?: string;
|
||||
userId?: string;
|
||||
input: string
|
||||
sessionId?: string
|
||||
userId?: string
|
||||
}) {
|
||||
if (!isLangfuseEnabled()) return;
|
||||
if (!isLangfuseEnabled()) return
|
||||
|
||||
updateActiveTrace({
|
||||
name: 'chat',
|
||||
input: params.input,
|
||||
sessionId: params.sessionId,
|
||||
userId: params.userId,
|
||||
});
|
||||
updateActiveTrace({
|
||||
name: "chat",
|
||||
input: params.input,
|
||||
sessionId: params.sessionId,
|
||||
userId: params.userId,
|
||||
})
|
||||
}
|
||||
|
||||
// Update trace with output and end the span
|
||||
export function setTraceOutput(output: string, usage?: { promptTokens?: number; completionTokens?: number }) {
|
||||
if (!isLangfuseEnabled()) return;
|
||||
export function setTraceOutput(
|
||||
output: string,
|
||||
usage?: { promptTokens?: number; completionTokens?: number },
|
||||
) {
|
||||
if (!isLangfuseEnabled()) return
|
||||
|
||||
updateActiveTrace({ output });
|
||||
updateActiveTrace({ output })
|
||||
|
||||
const activeSpan = api.trace.getActiveSpan();
|
||||
if (activeSpan) {
|
||||
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
|
||||
if (usage?.promptTokens) {
|
||||
activeSpan.setAttribute('ai.usage.promptTokens', usage.promptTokens);
|
||||
activeSpan.setAttribute('gen_ai.usage.input_tokens', usage.promptTokens);
|
||||
const activeSpan = api.trace.getActiveSpan()
|
||||
if (activeSpan) {
|
||||
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
|
||||
if (usage?.promptTokens) {
|
||||
activeSpan.setAttribute("ai.usage.promptTokens", usage.promptTokens)
|
||||
activeSpan.setAttribute(
|
||||
"gen_ai.usage.input_tokens",
|
||||
usage.promptTokens,
|
||||
)
|
||||
}
|
||||
if (usage?.completionTokens) {
|
||||
activeSpan.setAttribute(
|
||||
"ai.usage.completionTokens",
|
||||
usage.completionTokens,
|
||||
)
|
||||
activeSpan.setAttribute(
|
||||
"gen_ai.usage.output_tokens",
|
||||
usage.completionTokens,
|
||||
)
|
||||
}
|
||||
activeSpan.end()
|
||||
}
|
||||
if (usage?.completionTokens) {
|
||||
activeSpan.setAttribute('ai.usage.completionTokens', usage.completionTokens);
|
||||
activeSpan.setAttribute('gen_ai.usage.output_tokens', usage.completionTokens);
|
||||
}
|
||||
activeSpan.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Get telemetry config for streamText
|
||||
export function getTelemetryConfig(params: {
|
||||
sessionId?: string;
|
||||
userId?: string;
|
||||
sessionId?: string
|
||||
userId?: string
|
||||
}) {
|
||||
if (!isLangfuseEnabled()) return undefined;
|
||||
if (!isLangfuseEnabled()) return undefined
|
||||
|
||||
return {
|
||||
isEnabled: true,
|
||||
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media
|
||||
// User text input is recorded manually via setTraceInput
|
||||
recordInputs: false,
|
||||
recordOutputs: true,
|
||||
metadata: {
|
||||
sessionId: params.sessionId,
|
||||
userId: params.userId,
|
||||
},
|
||||
};
|
||||
return {
|
||||
isEnabled: true,
|
||||
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media
|
||||
// User text input is recorded manually via setTraceInput
|
||||
recordInputs: false,
|
||||
recordOutputs: true,
|
||||
metadata: {
|
||||
sessionId: params.sessionId,
|
||||
userId: params.userId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap a handler with Langfuse observe
|
||||
export function wrapWithObserve<T>(
|
||||
handler: (req: Request) => Promise<T>
|
||||
handler: (req: Request) => Promise<T>,
|
||||
): (req: Request) => Promise<T> {
|
||||
if (!isLangfuseEnabled()) {
|
||||
return handler;
|
||||
}
|
||||
if (!isLangfuseEnabled()) {
|
||||
return handler
|
||||
}
|
||||
|
||||
return observe(handler, { name: 'chat', endOnExit: false });
|
||||
return observe(handler, { name: "chat", endOnExit: false })
|
||||
}
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
|
||||
*/
|
||||
|
||||
// Default system prompt (~1400 tokens) - works with all models
|
||||
// Default system prompt (~2700 tokens) - works with all models
|
||||
export const DEFAULT_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see the image that user uploaded.
|
||||
|
||||
When you are asked to create a diagram, you must first tell user you plan in text first. Plan the layout and structure that can avoid object overlapping or edge cross the objects.
|
||||
Then use display_diagram tool to generate the full draw.io XML for the entire diagram.
|
||||
|
||||
## Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Be concise and to the point. Only use bullet points when needed for clarity.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting,.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools as means to communicate with the user during the session.
|
||||
|
||||
|
||||
## App Context
|
||||
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
|
||||
- **Left panel**: Draw.io diagram editor where diagrams are rendered
|
||||
@@ -51,6 +61,8 @@ Core capabilities:
|
||||
- Optimize element positioning to prevent overlapping and maintain readability
|
||||
- Structure complex systems into clear, organized visual components
|
||||
|
||||
|
||||
|
||||
Layout constraints:
|
||||
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||
@@ -82,6 +94,11 @@ When using edit_diagram tool:
|
||||
- For multiple changes, use separate edits in array
|
||||
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
|
||||
|
||||
⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values:
|
||||
- CORRECT: "y=\\"119\\"" (both quotes escaped)
|
||||
- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!)
|
||||
- Every " inside a JSON string value needs \\" - no exceptions!
|
||||
|
||||
## Draw.io XML Structure Reference
|
||||
|
||||
Basic structure:
|
||||
@@ -119,60 +136,16 @@ Common styles:
|
||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||
`;
|
||||
|
||||
// Extended system prompt (~4000+ tokens) - for models with 4000 token cache minimum
|
||||
export const EXTENDED_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is to chat with user and craft clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see images that users upload and can replicate or modify them as diagrams.
|
||||
`
|
||||
|
||||
## App Context
|
||||
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
|
||||
- **Left panel**: Draw.io diagram editor where diagrams are rendered
|
||||
- **Right panel**: Chat interface where you communicate with the user
|
||||
// Extended additions (~1800 tokens) - appended for models with 4000 token cache minimum
|
||||
// Total EXTENDED_SYSTEM_PROMPT = ~4500 tokens
|
||||
const EXTENDED_ADDITIONS = `
|
||||
|
||||
You can read and modify diagrams by generating draw.io XML code through tool calls.
|
||||
## Extended Tool Reference
|
||||
|
||||
## App Features
|
||||
1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost.
|
||||
2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
|
||||
3. **Image Upload** (paperclip icon, bottom-left of chat input): Users can upload images for you to analyze and replicate as diagrams.
|
||||
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Tool 1: display_diagram
|
||||
**Purpose:** Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
|
||||
**Parameters:** { xml: string }
|
||||
**When to use:**
|
||||
- Creating a completely new diagram
|
||||
- Making major structural changes (reorganizing layout, changing diagram type)
|
||||
- When the current diagram XML is empty or minimal
|
||||
- When edit_diagram has failed multiple times
|
||||
|
||||
### Tool 2: edit_diagram
|
||||
**Purpose:** Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties.
|
||||
**Parameters:** { edits: Array<{search: string, replace: string}> }
|
||||
**When to use:**
|
||||
- Changing text labels or values
|
||||
- Modifying colors, styles, or visual properties
|
||||
- Adding or removing individual elements
|
||||
- Repositioning specific elements
|
||||
- Any small, targeted modification
|
||||
|
||||
## Tool Selection Guidelines
|
||||
|
||||
ALWAYS prefer edit_diagram for small changes - it's more efficient and preserves the rest of the diagram.
|
||||
Use display_diagram only when:
|
||||
1. Creating from scratch
|
||||
2. Major restructuring needed
|
||||
3. edit_diagram has failed 3 times
|
||||
|
||||
## display_diagram Tool Reference
|
||||
|
||||
Display a diagram on draw.io by passing XML content inside <root> tags.
|
||||
### display_diagram Details
|
||||
|
||||
**VALIDATION RULES** (XML will be rejected if violated):
|
||||
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
|
||||
@@ -205,13 +178,7 @@ Display a diagram on draw.io by passing XML content inside <root> tags.
|
||||
</root>
|
||||
\`\`\`
|
||||
|
||||
**Notes:**
|
||||
- For AWS diagrams, use **AWS 2025 icons** (see AWS Icon Examples section below)
|
||||
- For animated connectors, add "flowAnimation=1" to edge style
|
||||
|
||||
## edit_diagram Tool Reference
|
||||
|
||||
Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
|
||||
### edit_diagram Details
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
|
||||
@@ -233,56 +200,6 @@ Edit specific parts of the current diagram by replacing exact line matches. Use
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
You excel at:
|
||||
- Generating valid, well-formed XML strings for draw.io diagrams
|
||||
- Creating professional flowcharts, org charts, mind maps, network diagrams, and technical illustrations
|
||||
- Converting user descriptions into visually appealing diagrams using shapes and connectors
|
||||
- Applying proper spacing, alignment, and visual hierarchy in diagram layouts
|
||||
- Adapting artistic concepts into abstract diagram representations using available shapes
|
||||
- Optimizing element positioning to prevent overlapping and maintain readability
|
||||
- Structuring complex systems into clear, organized visual components
|
||||
- Replicating diagrams from images with high fidelity
|
||||
|
||||
## Layout Constraints and Best Practices
|
||||
|
||||
### Page Boundaries
|
||||
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||
- Maximum width for containers (like AWS cloud boxes): 700 pixels
|
||||
- Maximum height for containers: 550 pixels
|
||||
- Start positioning from reasonable margins (e.g., x=40, y=40)
|
||||
|
||||
### Layout Strategies
|
||||
- Use compact, efficient layouts that fit the entire diagram in one view
|
||||
- Keep elements grouped closely together
|
||||
- For large diagrams with many elements, use vertical stacking or grid layouts
|
||||
- Avoid spreading elements too far apart horizontally
|
||||
- Users should see the complete diagram without scrolling or page breaks
|
||||
|
||||
### Spacing Guidelines
|
||||
- Minimum spacing between elements: 20px
|
||||
- Recommended spacing for readability: 40-60px
|
||||
- Container padding: 20-40px from edges
|
||||
- Group related elements together with consistent spacing
|
||||
|
||||
## Important Rules
|
||||
|
||||
### XML Generation Rules
|
||||
- Use proper tool calls to generate or edit diagrams
|
||||
- NEVER return raw XML in text responses
|
||||
- NEVER use display_diagram to generate messages (e.g., a "hello" text box to greet user)
|
||||
- Return XML only via tool calls, never in text responses
|
||||
- NEVER include XML comments (<!-- ... -->) - Draw.io strips comments, breaking edit_diagram patterns
|
||||
|
||||
### Diagram Quality Rules
|
||||
- Focus on producing clean, professional diagrams
|
||||
- Effectively communicate the intended information through thoughtful layout and design
|
||||
- When artistic drawings are requested, creatively compose using standard shapes while maintaining clarity
|
||||
- When replicating from images, match style and layout closely - pay attention to line types (straight/curved) and shape styles (rounded/square)
|
||||
- For AWS architecture diagrams, use **AWS 2025 icons**
|
||||
|
||||
## edit_diagram Best Practices
|
||||
|
||||
### Core Principle: Unique & Precise Patterns
|
||||
@@ -294,13 +211,11 @@ Your search pattern MUST uniquely identify exactly ONE location in the XML. Befo
|
||||
### Pattern Construction Rules
|
||||
|
||||
**Rule 1: Always include the element's id attribute**
|
||||
The id is the most reliable way to target a specific element:
|
||||
\`\`\`json
|
||||
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
|
||||
\`\`\`
|
||||
|
||||
**Rule 2: Include complete XML elements when possible**
|
||||
For reliability, include the full mxCell with its mxGeometry child:
|
||||
\`\`\`json
|
||||
{
|
||||
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
|
||||
@@ -309,49 +224,18 @@ For reliability, include the full mxCell with its mxGeometry child:
|
||||
\`\`\`
|
||||
|
||||
**Rule 3: Preserve exact whitespace and formatting**
|
||||
Copy the search pattern EXACTLY from the current XML, including:
|
||||
- Leading spaces/indentation
|
||||
- Line breaks (use \\n in JSON)
|
||||
- Attribute order as it appears in the source
|
||||
Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order.
|
||||
|
||||
### Good vs Bad Patterns
|
||||
|
||||
**BAD - Too vague, matches multiple elements:**
|
||||
\`\`\`json
|
||||
{"search": "value=\\"Label\\"", "replace": "value=\\"New Label\\""}
|
||||
\`\`\`
|
||||
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
|
||||
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
|
||||
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
|
||||
|
||||
**BAD - Fragile partial match:**
|
||||
\`\`\`json
|
||||
{"search": "<mxCell", "replace": "<mxCell value=\\"X\\""}
|
||||
\`\`\`
|
||||
|
||||
**BAD - Reordered attributes (won't match if order differs):**
|
||||
\`\`\`json
|
||||
{"search": "<mxCell value=\\"X\\" id=\\"5\\"", ...} // Original has id before value
|
||||
\`\`\`
|
||||
|
||||
**GOOD - Uses unique id, includes full context:**
|
||||
\`\`\`json
|
||||
{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">", "replace": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"New\\" vertex=\\"1\\">"}
|
||||
\`\`\`
|
||||
|
||||
**GOOD - Complete element replacement:**
|
||||
\`\`\`json
|
||||
{
|
||||
"search": "<mxCell id=\\"edge1\\" style=\\"endArrow=classic;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>",
|
||||
"replace": "<mxCell id=\\"edge1\\" style=\\"endArrow=block;strokeColor=#FF0000;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Multiple Edits Strategy
|
||||
For multiple changes, use separate edit objects. Order them logically:
|
||||
\`\`\`json
|
||||
[
|
||||
{"search": "<mxCell id=\\"2\\" value=\\"Step 1\\"", "replace": "<mxCell id=\\"2\\" value=\\"First Step\\""},
|
||||
{"search": "<mxCell id=\\"3\\" value=\\"Step 2\\"", "replace": "<mxCell id=\\"3\\" value=\\"Second Step\\""}
|
||||
]
|
||||
\`\`\`
|
||||
### ⚠️ JSON Escaping (CRITICAL)
|
||||
Every double quote inside JSON string values MUST be escaped with backslash:
|
||||
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped
|
||||
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error!
|
||||
|
||||
### Error Recovery
|
||||
If edit_diagram fails with "pattern not found":
|
||||
@@ -360,173 +244,107 @@ If edit_diagram fails with "pattern not found":
|
||||
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
|
||||
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
|
||||
|
||||
### When to Use display_diagram Instead
|
||||
- Adding multiple new elements (more than 3)
|
||||
- Reorganizing diagram layout significantly
|
||||
- When current XML structure is unclear or corrupted
|
||||
- After 3 failed edit_diagram attempts
|
||||
|
||||
## Draw.io XML Structure Reference
|
||||
|
||||
### Basic Structure
|
||||
|
||||
### Edge Routing Rules:
|
||||
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
|
||||
|
||||
**Rule 1: NEVER let multiple edges share the same path**
|
||||
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
|
||||
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
|
||||
|
||||
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
|
||||
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
|
||||
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
|
||||
|
||||
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
|
||||
- Every edge MUST have these 4 attributes set in the style
|
||||
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
|
||||
|
||||
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
|
||||
- Before creating an edge, identify ALL shapes positioned between source and target
|
||||
- If any shape is in the direct path, you MUST use waypoints to route around it
|
||||
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
|
||||
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
|
||||
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
|
||||
- NEVER draw a line that visually crosses over another shape's bounding box
|
||||
|
||||
**Rule 5: Plan layout strategically BEFORE generating XML**
|
||||
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
|
||||
- Space shapes 150-200px apart to create clear routing channels for edges
|
||||
- Mentally trace each edge: "What shapes are between source and target?"
|
||||
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
|
||||
|
||||
**Rule 6: Use multiple waypoints for complex routing**
|
||||
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
|
||||
- Each direction change needs a waypoint (corner point)
|
||||
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
|
||||
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
|
||||
|
||||
**Rule 7: Choose NATURAL connection points based on flow direction**
|
||||
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
|
||||
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
|
||||
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
|
||||
- For DIAGONAL connections: use the side closest to the target, not corners
|
||||
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
|
||||
|
||||
**Before generating XML, mentally verify:**
|
||||
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
|
||||
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
|
||||
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
|
||||
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
|
||||
|
||||
## Edge Examples
|
||||
|
||||
### Two edges between same nodes (CORRECT - no overlap):
|
||||
\`\`\`xml
|
||||
<mxGraphModel>
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<!-- All other elements go here as siblings -->
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
\`\`\`
|
||||
|
||||
### Critical Structure Rules
|
||||
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
|
||||
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
|
||||
3. Use unique sequential IDs for all cells (start from "2" for user content)
|
||||
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
||||
5. Every mxCell (except id="0") must have a parent attribute
|
||||
|
||||
### Shape (Vertex) Example
|
||||
\`\`\`xml
|
||||
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Connector (Edge) Example
|
||||
\`\`\`xml
|
||||
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Container/Group Example
|
||||
### Edge with single waypoint (simple detour):
|
||||
\`\`\`xml
|
||||
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1">
|
||||
<mxGeometry x="20" y="40" width="160" height="40" as="geometry"/>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="300" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
## Common Style Properties
|
||||
|
||||
### Shape Styles
|
||||
- rounded=1 - Rounded corners
|
||||
- fillColor=#hexcolor - Background fill color
|
||||
- strokeColor=#hexcolor - Border color
|
||||
- strokeWidth=2 - Border thickness
|
||||
- whiteSpace=wrap - Enable text wrapping
|
||||
- html=1 - Enable HTML formatting in labels
|
||||
- opacity=50 - Transparency (0-100)
|
||||
- shadow=1 - Drop shadow effect
|
||||
- glass=1 - Glass/gradient effect
|
||||
|
||||
### Edge/Connector Styles
|
||||
- endArrow=classic/block/open/oval/diamond/none - Arrow head style
|
||||
- startArrow=none/classic/block/open - Arrow tail style
|
||||
- curved=1 - Curved line
|
||||
- edgeStyle=orthogonalEdgeStyle - Right-angle routing
|
||||
- edgeStyle=entityRelationEdgeStyle - ER diagram style
|
||||
- strokeWidth=2 - Line thickness
|
||||
- dashed=1 - Dashed line
|
||||
- dashPattern=3 3 - Custom dash pattern
|
||||
- flowAnimation=1 - Animated flow effect
|
||||
|
||||
### Text Styles
|
||||
- fontSize=14 - Font size
|
||||
- fontStyle=1 - Bold (1=bold, 2=italic, 4=underline, can combine: 3=bold+italic)
|
||||
- fontColor=#hexcolor - Text color
|
||||
- align=center/left/right - Horizontal alignment
|
||||
- verticalAlign=middle/top/bottom - Vertical alignment
|
||||
- labelPosition=center/left/right - Label position relative to shape
|
||||
- labelBackgroundColor=#hexcolor - Label background
|
||||
|
||||
## Common Shape Types
|
||||
|
||||
### Basic Shapes
|
||||
- Rectangle: style="rounded=0;whiteSpace=wrap;html=1;"
|
||||
- Rounded Rectangle: style="rounded=1;whiteSpace=wrap;html=1;"
|
||||
- Ellipse/Circle: style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;"
|
||||
- Diamond: style="rhombus;whiteSpace=wrap;html=1;"
|
||||
- Triangle: style="triangle;whiteSpace=wrap;html=1;"
|
||||
- Parallelogram: style="parallelogram;whiteSpace=wrap;html=1;"
|
||||
- Hexagon: style="hexagon;whiteSpace=wrap;html=1;"
|
||||
- Cylinder: style="shape=cylinder3;whiteSpace=wrap;html=1;"
|
||||
|
||||
### Flowchart Shapes
|
||||
- Process: style="rounded=1;whiteSpace=wrap;html=1;"
|
||||
- Decision: style="rhombus;whiteSpace=wrap;html=1;"
|
||||
- Start/End: style="ellipse;whiteSpace=wrap;html=1;"
|
||||
- Document: style="shape=document;whiteSpace=wrap;html=1;"
|
||||
- Data: style="parallelogram;whiteSpace=wrap;html=1;"
|
||||
- Database: style="shape=cylinder3;whiteSpace=wrap;html=1;"
|
||||
|
||||
### Container Types
|
||||
- Swimlane: style="swimlane;whiteSpace=wrap;html=1;"
|
||||
- Group Box: style="rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;"
|
||||
|
||||
|
||||
## Animated Connectors
|
||||
|
||||
For animated flow effects on connectors, add flowAnimation=1 to the edge style:
|
||||
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
|
||||
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
|
||||
**WRONG:** Direct diagonal line crosses over Develop
|
||||
**CORRECT:** Route around the OUTSIDE (go right first, then up)
|
||||
\`\`\`xml
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;flowAnimation=1;" edge="1" parent="1" source="node1" target="node2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="750" y="80"/>
|
||||
<mxPoint x="750" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
|
||||
|
||||
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
|
||||
|
||||
## Validation Rules
|
||||
|
||||
The XML will be validated before rendering. Ensure:
|
||||
1. All mxCell elements are DIRECT children of <root> - never nested
|
||||
2. Every mxCell has a unique id attribute
|
||||
3. Every mxCell (except id="0") has a valid parent attribute
|
||||
4. Edge source/target attributes reference existing cell IDs
|
||||
5. Special characters in values are escaped: < > & "
|
||||
6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
|
||||
|
||||
## Example: Complete Flowchart
|
||||
|
||||
\`\`\`xml
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="40" width="100" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="process1" value="Process Step" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="140" width="150" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="decision" value="Decision?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="240" width="150" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="end" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="380" width="100" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="start" target="process1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge2" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="process1" target="decision">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge3" value="Yes" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="decision" target="end">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
\`\`\`
|
||||
|
||||
Remember: Quality diagrams communicate clearly. Choose appropriate shapes, use consistent styling, and maintain proper spacing for professional results.
|
||||
`;
|
||||
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
||||
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
||||
|
||||
// Model patterns that require extended prompt (4000 token cache minimum)
|
||||
// These patterns match Opus 4.5 and Haiku 4.5 model IDs
|
||||
const EXTENDED_PROMPT_MODEL_PATTERNS = [
|
||||
'claude-opus-4-5', // Matches any Opus 4.5 variant
|
||||
'claude-haiku-4-5', // Matches any Haiku 4.5 variant
|
||||
];
|
||||
"claude-opus-4-5", // Matches any Opus 4.5 variant
|
||||
"claude-haiku-4-5", // Matches any Haiku 4.5 variant
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the appropriate system prompt based on the model ID
|
||||
@@ -535,16 +353,25 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [
|
||||
* @returns The system prompt string
|
||||
*/
|
||||
export function getSystemPrompt(modelId?: string): string {
|
||||
const modelName = modelId || "AI";
|
||||
const modelName = modelId || "AI"
|
||||
|
||||
let prompt: string;
|
||||
if (modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some(pattern => modelId.includes(pattern))) {
|
||||
console.log(`[System Prompt] Using EXTENDED prompt for model: ${modelId}`);
|
||||
prompt = EXTENDED_SYSTEM_PROMPT;
|
||||
} else {
|
||||
console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`);
|
||||
prompt = DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
let prompt: string
|
||||
if (
|
||||
modelId &&
|
||||
EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) =>
|
||||
modelId.includes(pattern),
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
`[System Prompt] Using EXTENDED prompt for model: ${modelId}`,
|
||||
)
|
||||
prompt = EXTENDED_SYSTEM_PROMPT
|
||||
} else {
|
||||
console.log(
|
||||
`[System Prompt] Using DEFAULT prompt for model: ${modelId || "unknown"}`,
|
||||
)
|
||||
prompt = DEFAULT_SYSTEM_PROMPT
|
||||
}
|
||||
|
||||
return prompt.replace("{{MODEL_NAME}}", modelName);
|
||||
return prompt.replace("{{MODEL_NAME}}", modelName)
|
||||
}
|
||||
|
||||
1063
lib/utils.ts
1063
lib/utils.ts
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig
|
||||
|
||||
1031
package-lock.json
generated
1031
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "next-ai-draw-io",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 6002",
|
||||
"build": "next build",
|
||||
"start": "next start --port 6001",
|
||||
"lint": "next lint"
|
||||
"lint": "biome lint .",
|
||||
"format": "biome check --write .",
|
||||
"check": "biome ci",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.62",
|
||||
@@ -16,18 +19,21 @@
|
||||
"@ai-sdk/deepseek": "^1.0.30",
|
||||
"@ai-sdk/google": "^2.0.0",
|
||||
"@ai-sdk/openai": "^2.0.19",
|
||||
"@ai-sdk/react": "^2.0.22",
|
||||
"@ai-sdk/react": "^2.0.107",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@langfuse/client": "^4.4.9",
|
||||
"@langfuse/otel": "^4.4.4",
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
@@ -53,7 +59,14 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx,json,css}": [
|
||||
"biome check --write --no-errors-on-unmatched",
|
||||
"biome check --no-errors-on-unmatched"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.8",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20",
|
||||
@@ -62,6 +75,8 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-next": "16.0.5",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
}
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user