mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 23:02:31 +08:00
Compare commits
1 Commits
v0.3.0
...
fix/preven
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d0d4039d |
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"next/typescript"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
npx lint-staged
|
|
||||||
23
.vscode/settings.json
vendored
23
.vscode/settings.json
vendored
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# 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.
|
|
||||||
13
README.md
13
README.md
@@ -81,7 +81,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
## Multi-Provider Support
|
## Multi-Provider Support
|
||||||
|
|
||||||
- AWS Bedrock (default)
|
- AWS Bedrock (default)
|
||||||
- OpenAI
|
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
@@ -89,12 +89,6 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
|
|
||||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|
||||||
|
|
||||||
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
|
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-4o, Gemini 2.0, and DeepSeek V3/R1.
|
|
||||||
|
|
||||||
Note that `claude-sonnet-4-5` 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 `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -149,11 +143,8 @@ 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)
|
||||||
- Set `AI_MODEL` to the specific model you want to use
|
- Set `AI_MODEL` to the specific model you want to use
|
||||||
- Add the required API keys for your provider
|
- Add the required API keys for your provider
|
||||||
- `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.
|
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
|
||||||
|
|
||||||
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
|
||||||
|
|
||||||
4. Run the development server:
|
4. Run the development server:
|
||||||
|
|
||||||
|
|||||||
13
README_CN.md
13
README_CN.md
@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
## 多提供商支持
|
## 多提供商支持
|
||||||
|
|
||||||
- AWS Bedrock(默认)
|
- AWS Bedrock(默认)
|
||||||
- OpenAI
|
- OpenAI / OpenAI兼容API(通过 `OPENAI_BASE_URL`)
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
|
|
||||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
|
||||||
|
|
||||||
📖 **[详细的提供商配置指南](./docs/ai-providers.md)** - 查看各提供商的设置说明。
|
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
|
|
||||||
|
|
||||||
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -149,11 +143,8 @@ cp env.example .env.local
|
|||||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||||
- 添加您的提供商所需的API密钥
|
- 添加您的提供商所需的API密钥
|
||||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
|
||||||
|
|
||||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
请参阅上面的[多提供商支持](#多提供商支持)部分了解特定提供商的配置示例。
|
||||||
|
|
||||||
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
|
|
||||||
|
|
||||||
4. 运行开发服务器:
|
4. 运行开发服务器:
|
||||||
|
|
||||||
|
|||||||
13
README_JA.md
13
README_JA.md
@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
## マルチプロバイダーサポート
|
## マルチプロバイダーサポート
|
||||||
|
|
||||||
- AWS Bedrock(デフォルト)
|
- AWS Bedrock(デフォルト)
|
||||||
- OpenAI
|
- OpenAI / OpenAI互換API(`OPENAI_BASE_URL`経由)
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
|
|
||||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
|
||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./docs/ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
|
|
||||||
|
|
||||||
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
@@ -149,11 +143,8 @@ cp env.example .env.local
|
|||||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- `AI_MODEL`を使用する特定のモデルに設定
|
- `AI_MODEL`を使用する特定のモデルに設定
|
||||||
- プロバイダーに必要なAPIキーを追加
|
- プロバイダーに必要なAPIキーを追加
|
||||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
|
||||||
|
|
||||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
プロバイダー固有の設定例については、上記の[マルチプロバイダーサポート](#マルチプロバイダーサポート)セクションを参照してください。
|
||||||
|
|
||||||
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
|
|
||||||
|
|
||||||
4. 開発サーバーを起動:
|
4. 開発サーバーを起動:
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image"
|
import Link from "next/link";
|
||||||
import Link from "next/link"
|
import { FaGithub } from "react-icons/fa";
|
||||||
import { FaGithub } from "react-icons/fa"
|
import Image from "next/image";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "关于 - Next AI Draw.io",
|
title: "关于 - Next AI Draw.io",
|
||||||
description:
|
description: "AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
|
||||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function AboutCN() {
|
export default function AboutCN() {
|
||||||
return (
|
return (
|
||||||
@@ -17,23 +16,14 @@ export default function AboutCN() {
|
|||||||
<header className="bg-white border-b border-gray-200">
|
<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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
|
||||||
href="/"
|
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Next AI Draw.io
|
Next AI Draw.io
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
<Link
|
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
|
||||||
href="/"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
编辑器
|
编辑器
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/about/cn" className="text-blue-600 font-semibold">
|
||||||
href="/about/cn"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
关于
|
关于
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
@@ -55,41 +45,22 @@ export default function AboutCN() {
|
|||||||
<article className="prose prose-lg max-w-none">
|
<article className="prose prose-lg max-w-none">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
|
||||||
Next AI Draw.io
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||||
<Link
|
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link>
|
||||||
href="/about"
|
|
||||||
className="text-gray-600 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</Link>
|
|
||||||
<span className="text-gray-400">|</span>
|
<span className="text-gray-400">|</span>
|
||||||
<Link
|
<Link href="/about/cn" className="text-blue-600 font-semibold">中文</Link>
|
||||||
href="/about/cn"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
中文
|
|
||||||
</Link>
|
|
||||||
<span className="text-gray-400">|</span>
|
<span className="text-gray-400">|</span>
|
||||||
<Link
|
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600">日本語</Link>
|
||||||
href="/about/ja"
|
|
||||||
className="text-gray-600 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
日本語
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||||
<p className="text-amber-800">
|
<p className="text-amber-800">
|
||||||
本应用设计运行于 Claude Opus 4.5
|
本应用设计运行于 Claude Opus 4.5 以获得最佳性能。但由于流量超出预期,运行顶级模型的成本变得难以承受。为避免服务中断并控制成本,我已将后端切换至 Claude Haiku 4.5。
|
||||||
以获得最佳性能。但由于流量超出预期,运行顶级模型的成本变得难以承受。为避免服务中断并控制成本,我已将后端切换至
|
|
||||||
Claude Haiku 4.5。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,167 +69,80 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">功能特性</h2>
|
||||||
功能特性
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li><strong>LLM驱动的图表创建</strong>:利用大语言模型通过自然语言命令直接创建和操作draw.io图表</li>
|
||||||
<strong>LLM驱动的图表创建</strong>
|
<li><strong>基于图像的图表复制</strong>:上传现有图表或图像,让AI自动复制和增强</li>
|
||||||
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
<li><strong>图表历史记录</strong>:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本</li>
|
||||||
</li>
|
<li><strong>交互式聊天界面</strong>:与AI实时对话来完善您的图表</li>
|
||||||
<li>
|
<li><strong>AWS架构图支持</strong>:专门支持生成AWS架构图</li>
|
||||||
<strong>基于图像的图表复制</strong>
|
<li><strong>动画连接器</strong>:在图表元素之间创建动态动画连接器,实现更好的可视化效果</li>
|
||||||
:上传现有图表或图像,让AI自动复制和增强
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>图表历史记录</strong>
|
|
||||||
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>交互式聊天界面</strong>
|
|
||||||
:与AI实时对话来完善您的图表
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>AWS架构图支持</strong>
|
|
||||||
:专门支持生成AWS架构图
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>动画连接器</strong>
|
|
||||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Examples */}
|
{/* Examples */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">示例</h2>
|
||||||
示例
|
<p className="text-gray-700 mb-6">以下是一些示例提示词及其生成的图表:</p>
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
以下是一些示例提示词及其生成的图表:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">动画Transformer连接器</h3>
|
||||||
动画Transformer连接器
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>提示词:</strong> 给我一个带有
|
<strong>提示词:</strong> 给我一个带有<strong>动画连接器</strong>的Transformer架构图。
|
||||||
<strong>动画连接器</strong>的Transformer架构图。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/animated_connectors.svg" alt="带动画连接器的Transformer架构" width={480} height={360} className="mx-auto" />
|
||||||
src="/animated_connectors.svg"
|
|
||||||
alt="带动画连接器的Transformer架构"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCP架构图</h3>
|
||||||
GCP架构图
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>提示词:</strong> 使用<strong>GCP图标</strong>生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||||
<strong>GCP图标</strong>
|
|
||||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/gcp_demo.svg" alt="GCP架构图" width={400} height={300} className="mx-auto" />
|
||||||
src="/gcp_demo.svg"
|
|
||||||
alt="GCP架构图"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS架构图</h3>
|
||||||
AWS架构图
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>提示词:</strong> 使用<strong>AWS图标</strong>生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||||
<strong>AWS图标</strong>
|
|
||||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/aws_demo.svg" alt="AWS架构图" width={400} height={300} className="mx-auto" />
|
||||||
src="/aws_demo.svg"
|
|
||||||
alt="AWS架构图"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure架构图</h3>
|
||||||
Azure架构图
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>提示词:</strong> 使用<strong>Azure图标</strong>生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||||
<strong>Azure图标</strong>
|
|
||||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/azure_demo.svg" alt="Azure架构图" width={400} height={300} className="mx-auto" />
|
||||||
src="/azure_demo.svg"
|
|
||||||
alt="Azure架构图"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">猫咪素描</h3>
|
||||||
猫咪素描
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong>{" "}
|
<strong>提示词:</strong> 给我画一只可爱的猫。
|
||||||
给我画一只可爱的猫。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/cat_demo.svg" alt="猫咪绘图" width={240} height={240} className="mx-auto" />
|
||||||
src="/cat_demo.svg"
|
|
||||||
alt="猫咪绘图"
|
|
||||||
width={240}
|
|
||||||
height={240}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">工作原理</h2>
|
||||||
工作原理
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li><strong>Next.js</strong>:用于前端框架和路由</li>
|
||||||
<strong>Next.js</strong>:用于前端框架和路由
|
<li><strong>Vercel AI SDK</strong>(<code>ai</code> + <code>@ai-sdk/*</code>):用于流式AI响应和多提供商支持</li>
|
||||||
</li>
|
<li><strong>react-drawio</strong>:用于图表表示和操作</li>
|
||||||
<li>
|
|
||||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
|
||||||
<code>@ai-sdk/*</code>
|
|
||||||
):用于流式AI响应和多提供商支持
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>react-drawio</strong>:用于图表表示和操作
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
{/* Multi-Provider Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">多提供商支持</h2>
|
||||||
多提供商支持
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>AWS Bedrock(默认)</li>
|
<li>AWS Bedrock(默认)</li>
|
||||||
<li>
|
<li>OpenAI / OpenAI兼容API(通过 <code>OPENAI_BASE_URL</code>)</li>
|
||||||
OpenAI / OpenAI兼容API(通过{" "}
|
|
||||||
<code>OPENAI_BASE_URL</code>)
|
|
||||||
</li>
|
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
@@ -267,15 +151,12 @@ export default function AboutCN() {
|
|||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
注意:<code>claude-sonnet-4-5</code> 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||||
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||||
<h2 className="text-2xl font-semibold text-gray-900">
|
<h2 className="text-2xl font-semibold text-gray-900">支持与联系</h2>
|
||||||
支持与联系
|
|
||||||
</h2>
|
|
||||||
<iframe
|
<iframe
|
||||||
src="https://github.com/sponsors/DayuanJiang/button"
|
src="https://github.com/sponsors/DayuanJiang/button"
|
||||||
title="Sponsor DayuanJiang"
|
title="Sponsor DayuanJiang"
|
||||||
@@ -286,24 +167,14 @@ export default function AboutCN() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
如果您觉得这个项目有用,请考虑{" "}
|
如果您觉得这个项目有用,请考虑{" "}
|
||||||
<a
|
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
赞助
|
赞助
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
来帮助托管在线演示站点!
|
来帮助托管在线演示站点!
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mt-2">
|
<p className="text-gray-700 mt-2">
|
||||||
如需支持或咨询,请在{" "}
|
如需支持或咨询,请在{" "}
|
||||||
<a
|
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
GitHub仓库
|
GitHub仓库
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
上提交issue或联系:me[at]jiang.jp
|
上提交issue或联系:me[at]jiang.jp
|
||||||
@@ -330,5 +201,5 @@ export default function AboutCN() {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image"
|
import Link from "next/link";
|
||||||
import Link from "next/link"
|
import { FaGithub } from "react-icons/fa";
|
||||||
import { FaGithub } from "react-icons/fa"
|
import Image from "next/image";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "概要 - Next AI Draw.io",
|
title: "概要 - Next AI Draw.io",
|
||||||
description:
|
description: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
keywords: ["AIダイアグラム", "draw.io", "AWSアーキテクチャ", "GCPダイアグラム", "Azureダイアグラム", "LLM"],
|
||||||
keywords: [
|
};
|
||||||
"AIダイアグラム",
|
|
||||||
"draw.io",
|
|
||||||
"AWSアーキテクチャ",
|
|
||||||
"GCPダイアグラム",
|
|
||||||
"Azureダイアグラム",
|
|
||||||
"LLM",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutJA() {
|
export default function AboutJA() {
|
||||||
return (
|
return (
|
||||||
@@ -24,23 +16,14 @@ export default function AboutJA() {
|
|||||||
<header className="bg-white border-b border-gray-200">
|
<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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
|
||||||
href="/"
|
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Next AI Draw.io
|
Next AI Draw.io
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
<Link
|
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
|
||||||
href="/"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
エディタ
|
エディタ
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/about/ja" className="text-blue-600 font-semibold">
|
||||||
href="/about/ja"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
概要
|
概要
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
@@ -62,43 +45,22 @@ export default function AboutJA() {
|
|||||||
<article className="prose prose-lg max-w-none">
|
<article className="prose prose-lg max-w-none">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
|
||||||
Next AI Draw.io
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
AI搭載のダイアグラム作成ツール -
|
AI搭載のダイアグラム作成ツール - チャット、描画、可視化
|
||||||
チャット、描画、可視化
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||||
<Link
|
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link>
|
||||||
href="/about"
|
|
||||||
className="text-gray-600 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</Link>
|
|
||||||
<span className="text-gray-400">|</span>
|
<span className="text-gray-400">|</span>
|
||||||
<Link
|
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600">中文</Link>
|
||||||
href="/about/cn"
|
|
||||||
className="text-gray-600 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
中文
|
|
||||||
</Link>
|
|
||||||
<span className="text-gray-400">|</span>
|
<span className="text-gray-400">|</span>
|
||||||
<Link
|
<Link href="/about/ja" className="text-blue-600 font-semibold">日本語</Link>
|
||||||
href="/about/ja"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
日本語
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||||
<p className="text-amber-800">
|
<p className="text-amber-800">
|
||||||
本アプリは最高のパフォーマンスを発揮するため、Claude
|
本アプリは最高のパフォーマンスを発揮するため、Claude Opus 4.5 で動作するよう設計されています。しかし、予想以上のトラフィックにより、最上位モデルの運用コストが負担となっています。サービスの中断を避け、コストを管理するため、バックエンドを Claude Haiku 4.5 に切り替えました。
|
||||||
Opus 4.5
|
|
||||||
で動作するよう設計されています。しかし、予想以上のトラフィックにより、最上位モデルの運用コストが負担となっています。サービスの中断を避け、コストを管理するため、バックエンドを
|
|
||||||
Claude Haiku 4.5 に切り替えました。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,176 +69,80 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">機能</h2>
|
||||||
機能
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li><strong>LLM搭載のダイアグラム作成</strong>:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作</li>
|
||||||
<strong>LLM搭載のダイアグラム作成</strong>
|
<li><strong>画像ベースのダイアグラム複製</strong>:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化</li>
|
||||||
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
<li><strong>ダイアグラム履歴</strong>:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能</li>
|
||||||
</li>
|
<li><strong>インタラクティブなチャットインターフェース</strong>:AIとリアルタイムでコミュニケーションしてダイアグラムを改善</li>
|
||||||
<li>
|
<li><strong>AWSアーキテクチャダイアグラムサポート</strong>:AWSアーキテクチャダイアグラムの生成を専門的にサポート</li>
|
||||||
<strong>画像ベースのダイアグラム複製</strong>
|
<li><strong>アニメーションコネクタ</strong>:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成</li>
|
||||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>ダイアグラム履歴</strong>
|
|
||||||
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>
|
|
||||||
インタラクティブなチャットインターフェース
|
|
||||||
</strong>
|
|
||||||
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>
|
|
||||||
AWSアーキテクチャダイアグラムサポート
|
|
||||||
</strong>
|
|
||||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>アニメーションコネクタ</strong>
|
|
||||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Examples */}
|
{/* Examples */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">例</h2>
|
||||||
例
|
<p className="text-gray-700 mb-6">以下はいくつかのプロンプト例と生成されたダイアグラムです:</p>
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">アニメーションTransformerコネクタ</h3>
|
||||||
アニメーションTransformerコネクタ
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>プロンプト:</strong> <strong>アニメーションコネクタ</strong>付きのTransformerアーキテクチャ図を作成してください。
|
||||||
<strong>アニメーションコネクタ</strong>
|
|
||||||
付きのTransformerアーキテクチャ図を作成してください。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width={480} height={360} className="mx-auto" />
|
||||||
src="/animated_connectors.svg"
|
|
||||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCPアーキテクチャ図</h3>
|
||||||
GCPアーキテクチャ図
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>プロンプト:</strong> <strong>GCPアイコン</strong>を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
<strong>GCPアイコン</strong>
|
|
||||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/gcp_demo.svg" alt="GCPアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||||
src="/gcp_demo.svg"
|
|
||||||
alt="GCPアーキテクチャ図"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWSアーキテクチャ図</h3>
|
||||||
AWSアーキテクチャ図
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>プロンプト:</strong> <strong>AWSアイコン</strong>を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
<strong>AWSアイコン</strong>
|
|
||||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/aws_demo.svg" alt="AWSアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||||
src="/aws_demo.svg"
|
|
||||||
alt="AWSアーキテクチャ図"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azureアーキテクチャ図</h3>
|
||||||
Azureアーキテクチャ図
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>プロンプト:</strong> <strong>Azureアイコン</strong>を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
<strong>Azureアイコン</strong>
|
|
||||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/azure_demo.svg" alt="Azureアーキテクチャ図" width={400} height={300} className="mx-auto" />
|
||||||
src="/azure_demo.svg"
|
|
||||||
alt="Azureアーキテクチャ図"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">猫のスケッチ</h3>
|
||||||
猫のスケッチ
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>プロンプト:</strong> かわいい猫を描いてください。
|
||||||
かわいい猫を描いてください。
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/cat_demo.svg" alt="猫の絵" width={240} height={240} className="mx-auto" />
|
||||||
src="/cat_demo.svg"
|
|
||||||
alt="猫の絵"
|
|
||||||
width={240}
|
|
||||||
height={240}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">仕組み</h2>
|
||||||
仕組み
|
<p className="text-gray-700 mb-4">本アプリケーションは以下の技術を使用しています:</p>
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-4">
|
|
||||||
本アプリケーションは以下の技術を使用しています:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li><strong>Next.js</strong>:フロントエンドフレームワークとルーティング</li>
|
||||||
<strong>Next.js</strong>
|
<li><strong>Vercel AI SDK</strong>(<code>ai</code> + <code>@ai-sdk/*</code>):ストリーミングAIレスポンスとマルチプロバイダーサポート</li>
|
||||||
:フロントエンドフレームワークとルーティング
|
<li><strong>react-drawio</strong>:ダイアグラムの表現と操作</li>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
|
||||||
<code>@ai-sdk/*</code>
|
|
||||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>react-drawio</strong>
|
|
||||||
:ダイアグラムの表現と操作
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
{/* Multi-Provider Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">マルチプロバイダーサポート</h2>
|
||||||
マルチプロバイダーサポート
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>AWS Bedrock(デフォルト)</li>
|
<li>AWS Bedrock(デフォルト)</li>
|
||||||
<li>
|
<li>OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>経由)</li>
|
||||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
|
||||||
経由)
|
|
||||||
</li>
|
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
@@ -285,15 +151,12 @@ export default function AboutJA() {
|
|||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注:<code>claude-sonnet-4-5</code>
|
注:<code>claude-sonnet-4-5</code>はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||||
<h2 className="text-2xl font-semibold text-gray-900">
|
<h2 className="text-2xl font-semibold text-gray-900">サポート&お問い合わせ</h2>
|
||||||
サポート&お問い合わせ
|
|
||||||
</h2>
|
|
||||||
<iframe
|
<iframe
|
||||||
src="https://github.com/sponsors/DayuanJiang/button"
|
src="https://github.com/sponsors/DayuanJiang/button"
|
||||||
title="Sponsor DayuanJiang"
|
title="Sponsor DayuanJiang"
|
||||||
@@ -304,24 +167,14 @@ export default function AboutJA() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||||
<a
|
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
スポンサー
|
スポンサー
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
をご検討ください!
|
をご検討ください!
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mt-2">
|
<p className="text-gray-700 mt-2">
|
||||||
サポートやお問い合わせについては、{" "}
|
サポートやお問い合わせについては、{" "}
|
||||||
<a
|
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
GitHubリポジトリ
|
GitHubリポジトリ
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
||||||
@@ -343,11 +196,10 @@ export default function AboutJA() {
|
|||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<p className="text-center text-gray-600 text-sm">
|
<p className="text-center text-gray-600 text-sm">
|
||||||
Next AI Draw.io -
|
Next AI Draw.io - オープンソースAI搭載ダイアグラムジェネレーター
|
||||||
オープンソースAI搭載ダイアグラムジェネレーター
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image"
|
import Link from "next/link";
|
||||||
import Link from "next/link"
|
import { FaGithub } from "react-icons/fa";
|
||||||
import { FaGithub } from "react-icons/fa"
|
import Image from "next/image";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "About - Next AI Draw.io",
|
title: "About - Next AI Draw.io",
|
||||||
description:
|
description: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||||
"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"],
|
||||||
keywords: [
|
};
|
||||||
"AI diagram",
|
|
||||||
"draw.io",
|
|
||||||
"AWS architecture",
|
|
||||||
"GCP diagram",
|
|
||||||
"Azure diagram",
|
|
||||||
"LLM",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return (
|
return (
|
||||||
@@ -24,23 +16,14 @@ export default function About() {
|
|||||||
<header className="bg-white border-b border-gray-200">
|
<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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
|
||||||
href="/"
|
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Next AI Draw.io
|
Next AI Draw.io
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
<Link
|
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
|
||||||
href="/"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
Editor
|
Editor
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/about" className="text-blue-600 font-semibold">
|
||||||
href="/about"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
@@ -62,236 +45,105 @@ export default function About() {
|
|||||||
<article className="prose prose-lg max-w-none">
|
<article className="prose prose-lg max-w-none">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
|
||||||
Next AI Draw.io
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
AI-Powered Diagram Creation Tool - Chat, Draw, Visualize
|
||||||
Visualize
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-4 mt-4 text-sm">
|
<div className="flex justify-center gap-4 mt-4 text-sm">
|
||||||
<Link
|
<Link href="/about" className="text-blue-600 font-semibold">English</Link>
|
||||||
href="/about"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</Link>
|
|
||||||
<span className="text-gray-400">|</span>
|
<span className="text-gray-400">|</span>
|
||||||
<Link
|
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600">中文</Link>
|
||||||
href="/about/cn"
|
|
||||||
className="text-gray-600 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
中文
|
|
||||||
</Link>
|
|
||||||
<span className="text-gray-400">|</span>
|
<span className="text-gray-400">|</span>
|
||||||
<Link
|
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600">日本語</Link>
|
||||||
href="/about/ja"
|
|
||||||
className="text-gray-600 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
日本語
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||||
<p className="text-amber-800">
|
<p className="text-amber-800">
|
||||||
This app is designed to run on Claude Opus 4.5 for
|
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.
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
A Next.js web application that integrates AI
|
A Next.js web application that integrates AI capabilities with draw.io diagrams.
|
||||||
capabilities with draw.io diagrams. Create, modify, and
|
Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||||
enhance diagrams through natural language commands and
|
|
||||||
AI-assisted visualization.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Features</h2>
|
||||||
Features
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li><strong>LLM-Powered Diagram Creation</strong>: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands</li>
|
||||||
<strong>LLM-Powered Diagram Creation</strong>:
|
<li><strong>Image-Based Diagram Replication</strong>: Upload existing diagrams or images and have the AI replicate and enhance them automatically</li>
|
||||||
Leverage Large Language Models to create and
|
<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>
|
||||||
manipulate draw.io diagrams directly through natural
|
<li><strong>Interactive Chat Interface</strong>: Communicate with AI to refine your diagrams in real-time</li>
|
||||||
language commands
|
<li><strong>AWS Architecture Diagram Support</strong>: Specialized support for generating AWS architecture diagrams</li>
|
||||||
</li>
|
<li><strong>Animated Connectors</strong>: Create dynamic and animated connectors between diagram elements for better visualization</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>
|
</ul>
|
||||||
|
|
||||||
{/* Examples */}
|
{/* Examples */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Examples</h2>
|
||||||
Examples
|
<p className="text-gray-700 mb-6">Here are some example prompts and their generated diagrams:</p>
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
Here are some example prompts and their generated
|
|
||||||
diagrams:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Animated Transformer Connectors</h3>
|
||||||
Animated Transformer Connectors
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>Prompt:</strong> Give me an <strong>animated connector</strong> diagram of transformer's architecture.
|
||||||
<strong>animated connector</strong> diagram of
|
|
||||||
transformer's architecture.
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width={480} height={360} className="mx-auto" />
|
||||||
src="/animated_connectors.svg"
|
|
||||||
alt="Transformer Architecture with Animated Connectors"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Cloud Architecture Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCP Architecture Diagram</h3>
|
||||||
GCP Architecture Diagram
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a GCP
|
<strong>Prompt:</strong> Generate a GCP architecture diagram with <strong>GCP icons</strong>. Users connect to a frontend hosted on an instance.
|
||||||
architecture diagram with{" "}
|
|
||||||
<strong>GCP icons</strong>. Users connect to
|
|
||||||
a frontend hosted on an instance.
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/gcp_demo.svg" alt="GCP Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||||
src="/gcp_demo.svg"
|
|
||||||
alt="GCP Architecture Diagram"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS Architecture Diagram</h3>
|
||||||
AWS Architecture Diagram
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate an AWS
|
<strong>Prompt:</strong> Generate an AWS architecture diagram with <strong>AWS icons</strong>. Users connect to a frontend hosted on an instance.
|
||||||
architecture diagram with{" "}
|
|
||||||
<strong>AWS icons</strong>. Users connect to
|
|
||||||
a frontend hosted on an instance.
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/aws_demo.svg" alt="AWS Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||||
src="/aws_demo.svg"
|
|
||||||
alt="AWS Architecture Diagram"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure Architecture Diagram</h3>
|
||||||
Azure Architecture Diagram
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate an Azure
|
<strong>Prompt:</strong> Generate an Azure architecture diagram with <strong>Azure icons</strong>. Users connect to a frontend hosted on an instance.
|
||||||
architecture diagram with{" "}
|
|
||||||
<strong>Azure icons</strong>. Users connect
|
|
||||||
to a frontend hosted on an instance.
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/azure_demo.svg" alt="Azure Architecture Diagram" width={400} height={300} className="mx-auto" />
|
||||||
src="/azure_demo.svg"
|
|
||||||
alt="Azure Architecture Diagram"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Cat Sketch</h3>
|
||||||
Cat Sketch
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Draw a cute cat for
|
<strong>Prompt:</strong> Draw a cute cat for me.
|
||||||
me.
|
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<Image src="/cat_demo.svg" alt="Cat Drawing" width={240} height={240} className="mx-auto" />
|
||||||
src="/cat_demo.svg"
|
|
||||||
alt="Cat Drawing"
|
|
||||||
width={240}
|
|
||||||
height={240}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">How It Works</h2>
|
||||||
How It Works
|
<p className="text-gray-700 mb-4">The application uses the following technologies:</p>
|
||||||
</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">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li><strong>Next.js</strong>: For the frontend framework and routing</li>
|
||||||
<strong>Next.js</strong>: For the frontend framework
|
<li><strong>Vercel AI SDK</strong> (<code>ai</code> + <code>@ai-sdk/*</code>): For streaming AI responses and multi-provider support</li>
|
||||||
and routing
|
<li><strong>react-drawio</strong>: For diagram representation and manipulation</li>
|
||||||
</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>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Diagrams are represented as XML that can be rendered in
|
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||||
draw.io. The AI processes your commands and generates or
|
|
||||||
modifies this XML accordingly.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
{/* Multi-Provider Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Multi-Provider Support</h2>
|
||||||
Multi-Provider Support
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>AWS Bedrock (default)</li>
|
<li>AWS Bedrock (default)</li>
|
||||||
<li>
|
<li>OpenAI / OpenAI-compatible APIs (via <code>OPENAI_BASE_URL</code>)</li>
|
||||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
|
||||||
<code>OPENAI_BASE_URL</code>)
|
|
||||||
</li>
|
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
<li>Azure OpenAI</li>
|
<li>Azure OpenAI</li>
|
||||||
@@ -300,17 +152,12 @@ export default function About() {
|
|||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
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.
|
||||||
draw.io diagrams with AWS logos, so if you want to
|
|
||||||
create AWS architecture diagrams, this is the best
|
|
||||||
choice.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<div className="flex items-center gap-4 mt-10 mb-4">
|
<div className="flex items-center gap-4 mt-10 mb-4">
|
||||||
<h2 className="text-2xl font-semibold text-gray-900">
|
<h2 className="text-2xl font-semibold text-gray-900">Support & Contact</h2>
|
||||||
Support & Contact
|
|
||||||
</h2>
|
|
||||||
<iframe
|
<iframe
|
||||||
src="https://github.com/sponsors/DayuanJiang/button"
|
src="https://github.com/sponsors/DayuanJiang/button"
|
||||||
title="Sponsor DayuanJiang"
|
title="Sponsor DayuanJiang"
|
||||||
@@ -321,24 +168,14 @@ export default function About() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
If you find this project useful, please consider{" "}
|
If you find this project useful, please consider{" "}
|
||||||
<a
|
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
sponsoring
|
sponsoring
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to help host the live demo site!
|
to help host the live demo site!
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mt-2">
|
<p className="text-gray-700 mt-2">
|
||||||
For support or inquiries, please open an issue on the{" "}
|
For support or inquiries, please open an issue on the{" "}
|
||||||
<a
|
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
GitHub repository
|
GitHub repository
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
or contact: me[at]jiang.jp
|
or contact: me[at]jiang.jp
|
||||||
@@ -360,11 +197,10 @@ export default function About() {
|
|||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<p className="text-center text-gray-600 text-sm">
|
<p className="text-center text-gray-600 text-sm">
|
||||||
Next AI Draw.io - Open Source AI-Powered Diagram
|
Next AI Draw.io - Open Source AI-Powered Diagram Generator
|
||||||
Generator
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,223 +1,107 @@
|
|||||||
import {
|
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
||||||
convertToModelMessages,
|
import { getAIModel } from '@/lib/ai-providers';
|
||||||
createUIMessageStream,
|
import { findCachedResponse } from '@/lib/cached-responses';
|
||||||
createUIMessageStreamResponse,
|
import { getSystemPrompt } from '@/lib/system-prompts';
|
||||||
streamText,
|
import { z } from "zod";
|
||||||
} 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 = 60
|
export const maxDuration = 300;
|
||||||
|
|
||||||
// File upload limits (must match client-side)
|
|
||||||
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") || []
|
|
||||||
|
|
||||||
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?.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
|
// Helper function to check if diagram is minimal/empty
|
||||||
function isMinimalDiagram(xml: string): boolean {
|
function isMinimalDiagram(xml: string): boolean {
|
||||||
const stripped = xml.replace(/\s/g, "")
|
const stripped = xml.replace(/\s/g, '');
|
||||||
return !stripped.includes('id="2"')
|
return !stripped.includes('id="2"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create cached stream response
|
// Helper function to create cached stream response
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
const toolCallId = `cached-${Date.now()}`
|
const toolCallId = `cached-${Date.now()}`;
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
const stream = createUIMessageStream({
|
||||||
execute: async ({ writer }) => {
|
execute: async ({ writer }) => {
|
||||||
writer.write({ type: "start" })
|
writer.write({ type: 'start' });
|
||||||
writer.write({
|
writer.write({ type: 'tool-input-start', toolCallId, toolName: 'display_diagram' });
|
||||||
type: "tool-input-start",
|
writer.write({ type: 'tool-input-delta', toolCallId, inputTextDelta: xml });
|
||||||
toolCallId,
|
writer.write({ type: 'tool-input-available', toolCallId, toolName: 'display_diagram', input: { xml } });
|
||||||
toolName: "display_diagram",
|
writer.write({ type: 'finish' });
|
||||||
})
|
|
||||||
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
|
// Inner handler function
|
||||||
async function handleChatRequest(req: Request): Promise<Response> {
|
async function handleChatRequest(req: Request): Promise<Response> {
|
||||||
// Check for access code
|
const { messages, xml } = await req.json();
|
||||||
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()
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
|
|
||||||
// === FILE VALIDATION START ===
|
|
||||||
const fileValidation = validateFileParts(messages)
|
|
||||||
if (!fileValidation.valid) {
|
|
||||||
return Response.json({ error: fileValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
// === FILE VALIDATION END ===
|
|
||||||
|
|
||||||
// === CACHE CHECK START ===
|
// === CACHE CHECK START ===
|
||||||
const isFirstMessage = messages.length === 1
|
const isFirstMessage = messages.length === 1;
|
||||||
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
|
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
|
||||||
|
|
||||||
if (isFirstMessage && isEmptyDiagram) {
|
if (isFirstMessage && isEmptyDiagram) {
|
||||||
const lastMessage = messages[0]
|
const lastMessage = messages[0];
|
||||||
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
|
const textPart = lastMessage.parts?.find((p: any) => p.type === 'text');
|
||||||
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
|
const filePart = lastMessage.parts?.find((p: any) => p.type === 'file');
|
||||||
|
|
||||||
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
const cached = findCachedResponse(textPart?.text || '', !!filePart);
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(
|
console.log('[Cache] Returning cached response for:', textPart?.text);
|
||||||
"[Cache] Returning cached response for:",
|
return createCachedStreamResponse(cached.xml);
|
||||||
textPart?.text,
|
|
||||||
)
|
|
||||||
return createCachedStreamResponse(cached.xml)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// === CACHE CHECK END ===
|
// === CACHE CHECK END ===
|
||||||
|
|
||||||
// Get AI model from environment configuration
|
// Get AI model from environment configuration
|
||||||
const { model, providerOptions, headers, modelId } = getAIModel()
|
const { model, providerOptions, headers, modelId } = getAIModel();
|
||||||
|
|
||||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||||
const systemMessage = getSystemPrompt(modelId)
|
const systemMessage = getSystemPrompt(modelId);
|
||||||
|
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
|
||||||
// Extract text from the last message parts
|
// Extract text from the last message parts
|
||||||
const lastMessageText =
|
const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || '';
|
||||||
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
|
|
||||||
|
|
||||||
// Extract file parts (images) from the last message
|
// Extract file parts (images) from the last message
|
||||||
const fileParts =
|
const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || [];
|
||||||
lastMessage.parts?.filter((part: any) => part.type === "file") || []
|
|
||||||
|
|
||||||
// User input only - XML is now in a separate cached system message
|
// User input only - XML is now in a separate cached system message
|
||||||
const formattedUserInput = `User input:
|
const formattedUserInput = `User input:
|
||||||
"""md
|
"""md
|
||||||
${lastMessageText}
|
${lastMessageText}
|
||||||
"""`
|
"""`;
|
||||||
|
|
||||||
// Convert UIMessages to ModelMessages and add system message
|
// Convert UIMessages to ModelMessages and add system message
|
||||||
const modelMessages = convertToModelMessages(messages)
|
const modelMessages = convertToModelMessages(messages);
|
||||||
|
|
||||||
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
||||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
||||||
let enhancedMessages = modelMessages.filter(
|
let enhancedMessages = modelMessages.filter((msg: any) =>
|
||||||
(msg: any) =>
|
msg.content && Array.isArray(msg.content) && msg.content.length > 0
|
||||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
);
|
||||||
)
|
|
||||||
|
|
||||||
// Update the last message with user input only (XML moved to separate cached system message)
|
// Update the last message with user input only (XML moved to separate cached system message)
|
||||||
if (enhancedMessages.length >= 1) {
|
if (enhancedMessages.length >= 1) {
|
||||||
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
|
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1];
|
||||||
if (lastModelMessage.role === "user") {
|
if (lastModelMessage.role === 'user') {
|
||||||
// Build content array with user input text and file parts
|
// Build content array with user input text and file parts
|
||||||
const contentParts: any[] = [
|
const contentParts: any[] = [
|
||||||
{ type: "text", text: formattedUserInput },
|
{ type: 'text', text: formattedUserInput }
|
||||||
]
|
];
|
||||||
|
|
||||||
// Add image parts back
|
// Add image parts back
|
||||||
for (const filePart of fileParts) {
|
for (const filePart of fileParts) {
|
||||||
contentParts.push({
|
contentParts.push({
|
||||||
type: "image",
|
type: 'image',
|
||||||
image: filePart.url,
|
image: filePart.url,
|
||||||
mimeType: filePart.mediaType,
|
mimeType: filePart.mediaType
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
enhancedMessages = [
|
enhancedMessages = [
|
||||||
...enhancedMessages.slice(0, -1),
|
...enhancedMessages.slice(0, -1),
|
||||||
{ ...lastModelMessage, content: contentParts },
|
{ ...lastModelMessage, content: contentParts }
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,14 +111,14 @@ ${lastMessageText}
|
|||||||
if (enhancedMessages.length >= 2) {
|
if (enhancedMessages.length >= 2) {
|
||||||
// Find the last assistant message (should be second-to-last, before current user message)
|
// Find the last assistant message (should be second-to-last, before current user message)
|
||||||
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
|
for (let i = enhancedMessages.length - 2; i >= 0; i--) {
|
||||||
if (enhancedMessages[i].role === "assistant") {
|
if (enhancedMessages[i].role === 'assistant') {
|
||||||
enhancedMessages[i] = {
|
enhancedMessages[i] = {
|
||||||
...enhancedMessages[i],
|
...enhancedMessages[i],
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
bedrock: { cachePoint: { type: 'default' } },
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
break // Only cache the last assistant message
|
break; // Only cache the last assistant message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,48 +131,51 @@ ${lastMessageText}
|
|||||||
const systemMessages = [
|
const systemMessages = [
|
||||||
// Cache breakpoint 1: Instructions (rarely change)
|
// Cache breakpoint 1: Instructions (rarely change)
|
||||||
{
|
{
|
||||||
role: "system" as const,
|
role: 'system' as const,
|
||||||
content: systemMessage,
|
content: systemMessage,
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
bedrock: { cachePoint: { type: 'default' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Cache breakpoint 2: Current diagram XML context
|
// Cache breakpoint 2: Current diagram XML context
|
||||||
{
|
{
|
||||||
role: "system" as const,
|
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!`,
|
content: `Current diagram XML:\n"""xml\n${xml || ''}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`,
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
bedrock: { cachePoint: { type: 'default' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
const allMessages = [...systemMessages, ...enhancedMessages];
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
...(providerOptions && { providerOptions }),
|
...(providerOptions && { providerOptions }),
|
||||||
...(headers && { headers }),
|
...(headers && { headers }),
|
||||||
// Langfuse telemetry config (returns undefined if not configured)
|
onFinish: ({ usage, providerMetadata, finishReason, text, toolCalls }) => {
|
||||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
// Detect potential mid-stream failures (e.g., Bedrock 503 ServiceUnavailableException)
|
||||||
experimental_telemetry: getTelemetryConfig({
|
// When this happens, usage is empty and providerMetadata is undefined
|
||||||
sessionId: validSessionId,
|
const hasUsage = usage && Object.keys(usage).length > 0;
|
||||||
userId,
|
if (!hasUsage) {
|
||||||
}),
|
console.error('[Stream Error] Empty usage detected - possible Bedrock 503 or mid-stream failure');
|
||||||
}),
|
console.error('[Stream Error] finishReason:', finishReason);
|
||||||
onFinish: ({ text, usage, providerMetadata }) => {
|
console.error('[Stream Error] text received:', text?.substring(0, 200) || '(none)');
|
||||||
console.log(
|
console.error('[Stream Error] toolCalls:', toolCalls?.length || 0);
|
||||||
"[Cache] Full providerMetadata:",
|
// Log the user's last message for debugging
|
||||||
JSON.stringify(providerMetadata, null, 2),
|
const lastUserMsg = enhancedMessages.filter(m => m.role === 'user').pop();
|
||||||
)
|
if (lastUserMsg) {
|
||||||
console.log("[Cache] Usage:", JSON.stringify(usage, null, 2))
|
const content = lastUserMsg.content;
|
||||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
const preview = Array.isArray(content)
|
||||||
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
|
? (content.find((c) => c.type === 'text') as { type: 'text'; text: string } | undefined)?.text?.substring(0, 100)
|
||||||
setTraceOutput(text, {
|
: String(content).substring(0, 100);
|
||||||
promptTokens: usage?.inputTokens,
|
console.error('[Stream Error] Last user message preview:', preview);
|
||||||
completionTokens: usage?.outputTokens,
|
}
|
||||||
})
|
} else {
|
||||||
|
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
|
||||||
|
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
// Client-side tool that will be executed on the client
|
// Client-side tool that will be executed on the client
|
||||||
@@ -329,10 +216,8 @@ Notes:
|
|||||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||||
`,
|
`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
xml: z
|
xml: z.string().describe("XML string to be displayed on draw.io")
|
||||||
.string()
|
})
|
||||||
.describe("XML string to be displayed on draw.io"),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
edit_diagram: {
|
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.
|
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.
|
||||||
@@ -344,47 +229,65 @@ IMPORTANT: Keep edits concise:
|
|||||||
- Each search must contain complete lines (never truncate mid-line)
|
- Each search must contain complete lines (never truncate mid-line)
|
||||||
- First match only - be specific enough to target the right element`,
|
- First match only - be specific enough to target the right element`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
edits: z
|
edits: z.array(z.object({
|
||||||
.array(
|
search: z.string().describe("EXACT lines copied from current XML (preserve attribute order!)"),
|
||||||
z.object({
|
replace: z.string().describe("Replacement lines")
|
||||||
search: z
|
})).describe("Array of search/replace pairs to apply sequentially")
|
||||||
.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,
|
temperature: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse()
|
// Error handler function to provide detailed error messages
|
||||||
}
|
function errorHandler(error: unknown) {
|
||||||
|
if (error == null) {
|
||||||
// Wrap handler with error handling
|
return 'unknown error';
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap with Langfuse observe (if configured)
|
const errorString = typeof error === 'string'
|
||||||
const observedHandler = wrapWithObserve(safeHandler)
|
? error
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: JSON.stringify(error);
|
||||||
|
|
||||||
|
// Check for Bedrock service errors (503, throttling, etc.)
|
||||||
|
if (errorString.includes('ServiceUnavailable') ||
|
||||||
|
errorString.includes('503') ||
|
||||||
|
errorString.includes('temporarily unavailable')) {
|
||||||
|
console.error('[Bedrock Error] ServiceUnavailableException:', errorString);
|
||||||
|
return 'The AI service is temporarily unavailable. Please try again in a few seconds.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for throttling errors
|
||||||
|
if (errorString.includes('ThrottlingException') ||
|
||||||
|
errorString.includes('rate limit') ||
|
||||||
|
errorString.includes('too many requests') ||
|
||||||
|
errorString.includes('429')) {
|
||||||
|
console.error('[Bedrock Error] ThrottlingException:', errorString);
|
||||||
|
return 'Too many requests. Please wait a moment and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for image not supported error (e.g., DeepSeek models)
|
||||||
|
if (errorString.includes('image_url') ||
|
||||||
|
errorString.includes('unknown variant') ||
|
||||||
|
(errorString.includes('image') && errorString.includes('not supported'))) {
|
||||||
|
return 'This model does not support image inputs. Please remove the image and try again, or switch to a vision-capable model.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toUIMessageStreamResponse({
|
||||||
|
onError: errorHandler,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
return observedHandler(req)
|
try {
|
||||||
|
return await handleChatRequest(req);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in chat route:', error);
|
||||||
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const accessCodes =
|
|
||||||
process.env.ACCESS_CODE_LIST?.split(",")
|
|
||||||
.map((code) => code.trim())
|
|
||||||
.filter(Boolean) || []
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
accessCodeRequired: accessCodes.length > 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
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`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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,71 +0,0 @@
|
|||||||
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
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}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// 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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -68,14 +67,14 @@
|
|||||||
|
|
||||||
/* Light muted tones */
|
/* Light muted tones */
|
||||||
--muted: oklch(0.965 0.005 260);
|
--muted: oklch(0.965 0.005 260);
|
||||||
--muted-foreground: oklch(0.5 0.02 260);
|
--muted-foreground: oklch(0.50 0.02 260);
|
||||||
|
|
||||||
/* Soft lavender accent */
|
/* Soft lavender accent */
|
||||||
--accent: oklch(0.94 0.03 280);
|
--accent: oklch(0.94 0.03 280);
|
||||||
--accent-foreground: oklch(0.35 0.08 270);
|
--accent-foreground: oklch(0.35 0.08 270);
|
||||||
|
|
||||||
/* Coral destructive */
|
/* Coral destructive */
|
||||||
--destructive: oklch(0.6 0.2 25);
|
--destructive: oklch(0.60 0.20 25);
|
||||||
|
|
||||||
/* Subtle borders */
|
/* Subtle borders */
|
||||||
--border: oklch(0.92 0.01 260);
|
--border: oklch(0.92 0.01 260);
|
||||||
@@ -85,9 +84,9 @@
|
|||||||
/* Chart colors - harmonious palette */
|
/* Chart colors - harmonious palette */
|
||||||
--chart-1: oklch(0.55 0.18 265);
|
--chart-1: oklch(0.55 0.18 265);
|
||||||
--chart-2: oklch(0.65 0.15 170);
|
--chart-2: oklch(0.65 0.15 170);
|
||||||
--chart-3: oklch(0.7 0.18 45);
|
--chart-3: oklch(0.70 0.18 45);
|
||||||
--chart-4: oklch(0.6 0.2 330);
|
--chart-4: oklch(0.60 0.20 330);
|
||||||
--chart-5: oklch(0.5 0.15 200);
|
--chart-5: oklch(0.50 0.15 200);
|
||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
--sidebar: oklch(0.99 0.002 260);
|
--sidebar: oklch(0.99 0.002 260);
|
||||||
@@ -104,44 +103,44 @@
|
|||||||
--background: oklch(0.15 0.015 260);
|
--background: oklch(0.15 0.015 260);
|
||||||
--foreground: oklch(0.95 0.01 260);
|
--foreground: oklch(0.95 0.01 260);
|
||||||
|
|
||||||
--card: oklch(0.2 0.015 260);
|
--card: oklch(0.20 0.015 260);
|
||||||
--card-foreground: oklch(0.95 0.01 260);
|
--card-foreground: oklch(0.95 0.01 260);
|
||||||
|
|
||||||
--popover: oklch(0.2 0.015 260);
|
--popover: oklch(0.20 0.015 260);
|
||||||
--popover-foreground: oklch(0.95 0.01 260);
|
--popover-foreground: oklch(0.95 0.01 260);
|
||||||
|
|
||||||
--primary: oklch(0.7 0.16 265);
|
--primary: oklch(0.70 0.16 265);
|
||||||
--primary-foreground: oklch(0.15 0.02 260);
|
--primary-foreground: oklch(0.15 0.02 260);
|
||||||
|
|
||||||
--secondary: oklch(0.25 0.015 260);
|
--secondary: oklch(0.25 0.015 260);
|
||||||
--secondary-foreground: oklch(0.9 0.01 260);
|
--secondary-foreground: oklch(0.90 0.01 260);
|
||||||
|
|
||||||
--muted: oklch(0.25 0.015 260);
|
--muted: oklch(0.25 0.015 260);
|
||||||
--muted-foreground: oklch(0.65 0.02 260);
|
--muted-foreground: oklch(0.65 0.02 260);
|
||||||
|
|
||||||
--accent: oklch(0.3 0.04 280);
|
--accent: oklch(0.30 0.04 280);
|
||||||
--accent-foreground: oklch(0.9 0.03 270);
|
--accent-foreground: oklch(0.90 0.03 270);
|
||||||
|
|
||||||
--destructive: oklch(0.65 0.22 25);
|
--destructive: oklch(0.65 0.22 25);
|
||||||
|
|
||||||
--border: oklch(0.28 0.015 260);
|
--border: oklch(0.28 0.015 260);
|
||||||
--input: oklch(0.25 0.015 260);
|
--input: oklch(0.25 0.015 260);
|
||||||
--ring: oklch(0.7 0.16 265);
|
--ring: oklch(0.70 0.16 265);
|
||||||
|
|
||||||
--chart-1: oklch(0.7 0.16 265);
|
--chart-1: oklch(0.70 0.16 265);
|
||||||
--chart-2: oklch(0.7 0.13 170);
|
--chart-2: oklch(0.70 0.13 170);
|
||||||
--chart-3: oklch(0.75 0.16 45);
|
--chart-3: oklch(0.75 0.16 45);
|
||||||
--chart-4: oklch(0.7 0.18 330);
|
--chart-4: oklch(0.70 0.18 330);
|
||||||
--chart-5: oklch(0.6 0.13 200);
|
--chart-5: oklch(0.60 0.13 200);
|
||||||
|
|
||||||
--sidebar: oklch(0.18 0.015 260);
|
--sidebar: oklch(0.18 0.015 260);
|
||||||
--sidebar-foreground: oklch(0.95 0.01 260);
|
--sidebar-foreground: oklch(0.95 0.01 260);
|
||||||
--sidebar-primary: oklch(0.7 0.16 265);
|
--sidebar-primary: oklch(0.70 0.16 265);
|
||||||
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
||||||
--sidebar-accent: oklch(0.25 0.03 270);
|
--sidebar-accent: oklch(0.25 0.03 270);
|
||||||
--sidebar-accent-foreground: oklch(0.9 0.02 265);
|
--sidebar-accent-foreground: oklch(0.90 0.02 265);
|
||||||
--sidebar-border: oklch(0.28 0.015 260);
|
--sidebar-border: oklch(0.28 0.015 260);
|
||||||
--sidebar-ring: oklch(0.7 0.16 265);
|
--sidebar-ring: oklch(0.70 0.16 265);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -153,12 +152,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix for Radix ScrollArea viewport horizontal overflow */
|
|
||||||
[data-slot="scroll-area-viewport"] > div {
|
|
||||||
display: block !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.scrollbar-thin {
|
.scrollbar-thin {
|
||||||
@@ -248,11 +241,7 @@
|
|||||||
|
|
||||||
/* Gradient text utility */
|
/* Gradient text utility */
|
||||||
.text-gradient-primary {
|
.text-gradient-primary {
|
||||||
background: linear-gradient(
|
background: linear-gradient(135deg, oklch(0.55 0.18 265), oklch(0.60 0.20 290));
|
||||||
135deg,
|
|
||||||
oklch(0.55 0.18 265),
|
|
||||||
oklch(0.6 0.2 290)
|
|
||||||
);
|
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
|||||||
@@ -1,53 +1,34 @@
|
|||||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
import type { Metadata } from "next";
|
||||||
import { Analytics } from "@vercel/analytics/react"
|
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
|
||||||
import type { Metadata, Viewport } from "next"
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
import { GoogleAnalytics } from "@next/third-parties/google";
|
||||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
import { DiagramProvider } from "@/contexts/diagram-context";
|
||||||
|
|
||||||
import "./globals.css"
|
import "./globals.css";
|
||||||
|
|
||||||
const plusJakarta = Plus_Jakarta_Sans({
|
const plusJakarta = Plus_Jakarta_Sans({
|
||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "600", "700"],
|
weight: ["400", "500", "600", "700"],
|
||||||
})
|
});
|
||||||
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
variable: "--font-mono",
|
variable: "--font-mono",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500"],
|
weight: ["400", "500"],
|
||||||
})
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1,
|
|
||||||
maximumScale: 1,
|
|
||||||
userScalable: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Next AI Draw.io - AI-Powered Diagram Generator",
|
title: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||||
description:
|
description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||||
"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"],
|
||||||
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" }],
|
authors: [{ name: "Next AI Draw.io" }],
|
||||||
creator: "Next AI Draw.io",
|
creator: "Next AI Draw.io",
|
||||||
publisher: "Next AI Draw.io",
|
publisher: "Next AI Draw.io",
|
||||||
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Next AI Draw.io - AI Diagram Generator",
|
title: "Next AI Draw.io - AI Diagram Generator",
|
||||||
description:
|
description: "Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
||||||
"Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
|
||||||
type: "website",
|
type: "website",
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
url: "https://next-ai-drawio.jiang.jp",
|
||||||
siteName: "Next AI Draw.io",
|
siteName: "Next AI Draw.io",
|
||||||
@@ -64,8 +45,7 @@ export const metadata: Metadata = {
|
|||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Next AI Draw.io - AI Diagram Generator",
|
title: "Next AI Draw.io - AI Diagram Generator",
|
||||||
description:
|
description: "Create professional diagrams with AI assistance. Free, no login required.",
|
||||||
"Create professional diagrams with AI assistance. Free, no login required.",
|
|
||||||
images: ["/architecture.png"],
|
images: ["/architecture.png"],
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
@@ -82,28 +62,27 @@ export const metadata: Metadata = {
|
|||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.ico",
|
icon: "/favicon.ico",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
'@context': 'https://schema.org',
|
||||||
"@type": "SoftwareApplication",
|
'@type': 'SoftwareApplication',
|
||||||
name: "Next AI Draw.io",
|
name: 'Next AI Draw.io',
|
||||||
applicationCategory: "DesignApplication",
|
applicationCategory: 'DesignApplication',
|
||||||
operatingSystem: "Web Browser",
|
operatingSystem: 'Web Browser',
|
||||||
description:
|
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.',
|
||||||
"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',
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
|
||||||
offers: {
|
offers: {
|
||||||
"@type": "Offer",
|
'@type': 'Offer',
|
||||||
price: "0",
|
price: '0',
|
||||||
priceCurrency: "USD",
|
priceCurrency: 'USD',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -117,11 +96,12 @@ export default function RootLayout({
|
|||||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<DiagramProvider>{children}</DiagramProvider>
|
<DiagramProvider>{children}</DiagramProvider>
|
||||||
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||||
)}
|
)}
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
147
app/page.tsx
147
app/page.tsx
@@ -1,97 +1,77 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import { useEffect, useRef, useState } from "react"
|
import React, { useState, useEffect } from "react";
|
||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio";
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import ChatPanel from "@/components/chat-panel";
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import {
|
import { Monitor } from "lucide-react";
|
||||||
ResizableHandle,
|
|
||||||
ResizablePanel,
|
|
||||||
ResizablePanelGroup,
|
|
||||||
} from "@/components/ui/resizable"
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport } = useDiagram()
|
const { drawioRef, handleDiagramExport } = useDiagram();
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
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
|
|
||||||
}
|
|
||||||
return "min"
|
|
||||||
})
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
setIsMobile(window.innerWidth < 768)
|
setIsMobile(window.innerWidth < 768);
|
||||||
}
|
};
|
||||||
|
|
||||||
checkMobile()
|
checkMobile();
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile);
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
|
||||||
const panel = chatPanelRef.current
|
|
||||||
if (panel) {
|
|
||||||
if (panel.isCollapsed()) {
|
|
||||||
panel.expand()
|
|
||||||
setIsChatVisible(true)
|
|
||||||
} else {
|
|
||||||
panel.collapse()
|
|
||||||
setIsChatVisible(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
toggleChatPanel()
|
setIsChatVisible((prev) => !prev);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Show confirmation dialog when user tries to leave the page
|
// Show confirmation dialog when user tries to leave the page
|
||||||
// This helps prevent accidental navigation from browser back gestures
|
// This helps prevent accidental navigation from browser back gestures
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
return ""
|
return '';
|
||||||
}
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
return () =>
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
}, []);
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-background relative overflow-hidden">
|
<div className="flex h-screen bg-background relative overflow-hidden">
|
||||||
<ResizablePanelGroup
|
{/* Mobile warning overlay */}
|
||||||
key={isMobile ? "mobile" : "desktop"}
|
{isMobile && (
|
||||||
direction={isMobile ? "vertical" : "horizontal"}
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||||
className="h-full"
|
<div className="text-center p-8 max-w-sm mx-auto animate-fade-in">
|
||||||
>
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Monitor className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground mb-3">
|
||||||
|
Desktop Required
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
This application works best on desktop or laptop devices. Please open it on a larger screen for the full experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Draw.io Canvas */}
|
{/* Draw.io Canvas */}
|
||||||
<ResizablePanel defaultSize={isMobile ? 50 : 67} minSize={20}>
|
|
||||||
<div
|
<div
|
||||||
className={`h-full relative ${
|
className={`${isChatVisible ? 'w-2/3' : 'w-full'} h-full relative transition-all duration-300 ease-out`}
|
||||||
isMobile ? "p-1" : "p-2"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
<div className="absolute inset-2 rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={drawioUi}
|
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
|
||||||
spin: true,
|
spin: true,
|
||||||
libraries: false,
|
libraries: false,
|
||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
@@ -100,37 +80,18 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
|
||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Panel */}
|
||||||
<ResizablePanel
|
<div
|
||||||
ref={chatPanelRef}
|
className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full transition-all duration-300 ease-out`}
|
||||||
defaultSize={isMobile ? 50 : 33}
|
|
||||||
minSize={isMobile ? 20 : 15}
|
|
||||||
maxSize={isMobile ? 80 : 50}
|
|
||||||
collapsible={!isMobile}
|
|
||||||
collapsedSize={isMobile ? 0 : 3}
|
|
||||||
onCollapse={() => setIsChatVisible(false)}
|
|
||||||
onExpand={() => setIsChatVisible(true)}
|
|
||||||
>
|
>
|
||||||
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
<div className="h-full py-2 pr-2">
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
isVisible={isChatVisible}
|
isVisible={isChatVisible}
|
||||||
onToggleVisibility={toggleChatPanel}
|
onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
|
||||||
drawioUi={drawioUi}
|
|
||||||
onToggleDrawioUi={() => {
|
|
||||||
const newTheme =
|
|
||||||
drawioUi === "min" ? "sketch" : "min"
|
|
||||||
localStorage.setItem("drawio-theme", newTheme)
|
|
||||||
setDrawioUi(newTheme)
|
|
||||||
}}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { MetadataRoute } from "next"
|
import { MetadataRoute } from 'next'
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
userAgent: "*",
|
userAgent: '*',
|
||||||
allow: "/",
|
allow: '/',
|
||||||
disallow: "/api/",
|
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 type { MetadataRoute } from "next"
|
import { MetadataRoute } from 'next'
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
url: 'https://next-ai-drawio.jiang.jp',
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "weekly",
|
changeFrequency: 'weekly',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "https://next-ai-drawio.jiang.jp/about",
|
url: 'https://next-ai-drawio.jiang.jp/about',
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "monthly",
|
changeFrequency: 'monthly',
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
83
biome.json
83
biome.json
@@ -1,83 +0,0 @@
|
|||||||
{
|
|
||||||
"$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,19 +1,19 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import React from "react";
|
||||||
import type React from "react"
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Button, type buttonVariants } from "@/components/ui/button"
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip";
|
||||||
|
import { type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
interface ButtonWithTooltipProps
|
interface ButtonWithTooltipProps
|
||||||
extends React.ComponentProps<"button">,
|
extends React.ComponentProps<"button">,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
tooltipContent: string
|
tooltipContent: string;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ButtonWithTooltip({
|
export function ButtonWithTooltip({
|
||||||
@@ -27,10 +27,8 @@ export function ButtonWithTooltip({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button {...buttonProps}>{children}</Button>
|
<Button {...buttonProps}>{children}</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs text-wrap">
|
<TooltipContent className="max-w-xs text-wrap">{tooltipContent}</TooltipContent>
|
||||||
{tooltipContent}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Cloud, GitBranch, Palette, Zap } from "lucide-react"
|
import { Zap, Cloud, GitBranch, Palette } from "lucide-react";
|
||||||
|
|
||||||
interface ExampleCardProps {
|
interface ExampleCardProps {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode;
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
|
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
|
||||||
@@ -29,43 +29,43 @@ function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExamplePanel({
|
export default function ExamplePanel({
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
}: {
|
}: {
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void;
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const handleReplicateFlowchart = async () => {
|
const handleReplicateFlowchart = async () => {
|
||||||
setInput("Replicate this flowchart.")
|
setInput("Replicate this flowchart.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/example.png")
|
const response = await fetch("/example.png");
|
||||||
const blob = await response.blob()
|
const blob = await response.blob();
|
||||||
const file = new File([blob], "example.png", { type: "image/png" })
|
const file = new File([blob], "example.png", { type: "image/png" });
|
||||||
setFiles([file])
|
setFiles([file]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading example image:", error)
|
console.error("Error loading example image:", error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleReplicateArchitecture = async () => {
|
const handleReplicateArchitecture = async () => {
|
||||||
setInput("Replicate this in aws style")
|
setInput("Replicate this in aws style");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/architecture.png")
|
const response = await fetch("/architecture.png");
|
||||||
const blob = await response.blob()
|
const blob = await response.blob();
|
||||||
const file = new File([blob], "architecture.png", {
|
const file = new File([blob], "architecture.png", {
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
})
|
});
|
||||||
setFiles([file])
|
setFiles([file]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading architecture image:", error)
|
console.error("Error loading architecture image:", error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
@@ -75,8 +75,7 @@ export default function ExamplePanel({
|
|||||||
Create diagrams with AI
|
Create diagrams with AI
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
Describe what you want to create or upload an image to
|
Describe what you want to create or upload an image to replicate
|
||||||
replicate
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,9 +91,7 @@ export default function ExamplePanel({
|
|||||||
title="Animated Diagram"
|
title="Animated Diagram"
|
||||||
description="Draw a transformer architecture with animated connectors"
|
description="Draw a transformer architecture with animated connectors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setInput(
|
setInput("Give me a **animated connector** diagram of transformer's architecture")
|
||||||
"Give me a **animated connector** diagram of transformer's architecture",
|
|
||||||
)
|
|
||||||
setFiles([])
|
setFiles([])
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -129,5 +126,5 @@ export default function ExamplePanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +1,35 @@
|
|||||||
"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 {
|
import {
|
||||||
Download,
|
|
||||||
History,
|
|
||||||
Image as ImageIcon,
|
|
||||||
LayoutGrid,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
PenTool,
|
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
Image as ImageIcon,
|
||||||
import type React from "react"
|
History,
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
Download,
|
||||||
import { toast } from "sonner"
|
Paperclip,
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
} from "lucide-react";
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { FilePreviewList } from "./file-preview-list";
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { HistoryDialog } from "@/components/history-dialog";
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} 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
|
|
||||||
|
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
function showErrorToast(message: React.ReactNode) {
|
|
||||||
toast.custom(
|
|
||||||
(t) => (
|
|
||||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
|
||||||
),
|
|
||||||
{ duration: 5000 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidationResult {
|
|
||||||
validFiles: File[]
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateFiles(
|
|
||||||
newFiles: File[],
|
|
||||||
existingCount: number,
|
|
||||||
): ValidationResult {
|
|
||||||
const errors: string[] = []
|
|
||||||
const validFiles: File[] = []
|
|
||||||
|
|
||||||
const availableSlots = MAX_FILES - existingCount
|
|
||||||
|
|
||||||
if (availableSlots <= 0) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
errors.push(
|
|
||||||
`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
validFiles.push(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { validFiles, errors }
|
|
||||||
}
|
|
||||||
|
|
||||||
function showValidationErrors(errors: string[]) {
|
|
||||||
if (errors.length === 0) return
|
|
||||||
|
|
||||||
if (errors.length === 1) {
|
|
||||||
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>
|
|
||||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
|
||||||
{errors.slice(0, 3).map((err) => (
|
|
||||||
<li key={err}>{err}</li>
|
|
||||||
))}
|
|
||||||
{errors.length > 3 && (
|
|
||||||
<li>...and {errors.length - 3} more</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string
|
input: string;
|
||||||
status: "submitted" | "streaming" | "ready" | "error"
|
status: "submitted" | "streaming" | "ready" | "error";
|
||||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
onClearChat: () => void
|
onClearChat: () => void;
|
||||||
files?: File[]
|
files?: File[];
|
||||||
onFileChange?: (files: File[]) => void
|
onFileChange?: (files: File[]) => void;
|
||||||
showHistory?: boolean
|
showHistory?: boolean;
|
||||||
onToggleHistory?: (show: boolean) => void
|
onToggleHistory?: (show: boolean) => void;
|
||||||
sessionId?: string
|
error?: Error | null;
|
||||||
error?: Error | null
|
|
||||||
drawioUi?: "min" | "sketch"
|
|
||||||
onToggleDrawioUi?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -137,144 +42,126 @@ export function ChatInput({
|
|||||||
onFileChange = () => {},
|
onFileChange = () => {},
|
||||||
showHistory = false,
|
showHistory = false,
|
||||||
onToggleHistory = () => {},
|
onToggleHistory = () => {},
|
||||||
sessionId,
|
|
||||||
error = null,
|
error = null,
|
||||||
drawioUi = "min",
|
|
||||||
onToggleDrawioUi = () => {},
|
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||||
const [showSaveDialog, setShowSaveDialog] = 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")
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
const isDisabled =
|
const isDisabled = (status === "streaming" || status === "submitted") && !error;
|
||||||
(status === "streaming" || status === "submitted") && !error
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
|
||||||
|
}, [status, isDisabled]);
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
const textarea = textareaRef.current
|
const textarea = textareaRef.current;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.style.height = "auto"
|
textarea.style.height = "auto";
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adjustTextareaHeight()
|
adjustTextareaHeight();
|
||||||
}, [input, adjustTextareaHeight])
|
}, [input, adjustTextareaHeight]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
onChange(e)
|
|
||||||
adjustTextareaHeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
const form = e.currentTarget.closest("form")
|
const form = e.currentTarget.closest("form");
|
||||||
if (form && input.trim() && !isDisabled) {
|
if (form && input.trim() && !isDisabled) {
|
||||||
form.requestSubmit()
|
form.requestSubmit();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
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) =>
|
const imageItems = Array.from(items).filter((item) =>
|
||||||
item.type.startsWith("image/"),
|
item.type.startsWith("image/")
|
||||||
)
|
);
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (imageItems.length > 0) {
|
||||||
const imageFiles = (
|
const imageFiles = await Promise.all(
|
||||||
await Promise.all(
|
imageItems.map(async (item) => {
|
||||||
imageItems.map(async (item, index) => {
|
const file = item.getAsFile();
|
||||||
const file = item.getAsFile()
|
if (!file) return null;
|
||||||
if (!file) return null
|
|
||||||
return new File(
|
return new File(
|
||||||
[file],
|
[file],
|
||||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
|
||||||
{ type: file.type },
|
{
|
||||||
)
|
type: file.type,
|
||||||
}),
|
}
|
||||||
)
|
);
|
||||||
).filter((f): f is File => f !== null)
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(
|
const validFiles = imageFiles.filter(
|
||||||
imageFiles,
|
(file): file is File => file !== null
|
||||||
files.length,
|
);
|
||||||
)
|
|
||||||
showValidationErrors(errors)
|
|
||||||
if (validFiles.length > 0) {
|
if (validFiles.length > 0) {
|
||||||
onFileChange([...files, ...validFiles])
|
onFileChange([...files, ...validFiles]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newFiles = Array.from(e.target.files || [])
|
const newFiles = Array.from(e.target.files || []);
|
||||||
const { validFiles, errors } = validateFiles(newFiles, files.length)
|
onFileChange([...files, ...newFiles]);
|
||||||
showValidationErrors(errors)
|
};
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
// Reset input so same file can be selected again
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveFile = (fileToRemove: File) => {
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
onFileChange(files.filter((file) => file !== fileToRemove));
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = "";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
const triggerFileInput = () => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setIsDragging(true)
|
setIsDragging(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setIsDragging(false)
|
setIsDragging(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setIsDragging(false)
|
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) =>
|
const imageFiles = Array.from(droppedFiles).filter((file) =>
|
||||||
file.type.startsWith("image/"),
|
file.type.startsWith("image/")
|
||||||
)
|
);
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(imageFiles, files.length)
|
if (imageFiles.length > 0) {
|
||||||
showValidationErrors(errors)
|
onFileChange([...files, ...imageFiles]);
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
onClearChat()
|
onClearChat();
|
||||||
setShowClearDialog(false)
|
setShowClearDialog(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -291,10 +178,7 @@ export function ChatInput({
|
|||||||
{/* File previews */}
|
{/* File previews */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<FilePreviewList
|
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
|
||||||
files={files}
|
|
||||||
onRemoveFile={handleRemoveFile}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -303,7 +187,7 @@ export function ChatInput({
|
|||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleChange}
|
onChange={onChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder="Describe your diagram or paste an image..."
|
placeholder="Describe your diagram or paste an image..."
|
||||||
@@ -337,60 +221,6 @@ export function ChatInput({
|
|||||||
showHistory={showHistory}
|
showHistory={showHistory}
|
||||||
onToggleHistory={onToggleHistory}
|
onToggleHistory={onToggleHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowThemeWarning(true)}
|
|
||||||
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" ? (
|
|
||||||
<PenTool className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={showThemeWarning}
|
|
||||||
onOpenChange={setShowThemeWarning}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Switch Theme?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Switching themes will reload the diagram
|
|
||||||
editor and clear any unsaved changes.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setShowThemeWarning(false)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
onClearChat()
|
|
||||||
onToggleDrawioUi()
|
|
||||||
setShowThemeWarning(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Switch Theme
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right actions */}
|
{/* Right actions */}
|
||||||
@@ -422,12 +252,8 @@ export function ChatInput({
|
|||||||
<SaveDialog
|
<SaveDialog
|
||||||
open={showSaveDialog}
|
open={showSaveDialog}
|
||||||
onOpenChange={setShowSaveDialog}
|
onOpenChange={setShowSaveDialog}
|
||||||
onSave={(filename, format) =>
|
onSave={(filename, format) => saveDiagramToFile(filename, format)}
|
||||||
saveDiagramToFile(filename, format, sessionId)
|
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
|
||||||
}
|
|
||||||
defaultFilename={`diagram-${new Date()
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -459,9 +285,7 @@ export function ChatInput({
|
|||||||
disabled={isDisabled || !input.trim()}
|
disabled={isDisabled || !input.trim()}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
aria-label={
|
aria-label={isDisabled ? "Sending..." : "Send message"}
|
||||||
isDisabled ? "Sending..." : "Send message"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isDisabled ? (
|
{isDisabled ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -475,6 +299,7 @@ export function ChatInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,24 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import type { UIMessage } from "ai"
|
import { useRef, useEffect, useState, useCallback } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import {
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
Check,
|
import ExamplePanel from "./chat-example-panel";
|
||||||
ChevronDown,
|
import { UIMessage } from "ai";
|
||||||
ChevronUp,
|
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
|
||||||
Copy,
|
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, RotateCcw, Pencil } from "lucide-react";
|
||||||
Cpu,
|
import { CodeBlock } from "./code-block";
|
||||||
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 {
|
interface EditPair {
|
||||||
search: string
|
search: string;
|
||||||
replace: 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[] }) {
|
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{edits.map((edit, index) => (
|
{edits.map((edit, index) => (
|
||||||
<div
|
<div key={index} className="rounded-lg border border-border/50 overflow-hidden bg-background/50">
|
||||||
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">
|
<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">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
Change {index + 1}
|
Change {index + 1}
|
||||||
@@ -60,9 +29,7 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
|||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<Minus className="w-3 h-3 text-red-500" />
|
<Minus className="w-3 h-3 text-red-500" />
|
||||||
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
|
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">Remove</span>
|
||||||
Remove
|
|
||||||
</span>
|
|
||||||
</div>
|
</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">
|
<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}
|
{edit.search}
|
||||||
@@ -72,9 +39,7 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
|||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<Plus className="w-3 h-3 text-green-500" />
|
<Plus className="w-3 h-3 text-green-500" />
|
||||||
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
|
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">Add</span>
|
||||||
Add
|
|
||||||
</span>
|
|
||||||
</div>
|
</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">
|
<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}
|
{edit.replace}
|
||||||
@@ -84,190 +49,145 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
|
|
||||||
const getMessageTextContent = (message: UIMessage): string => {
|
const getMessageTextContent = (message: UIMessage): string => {
|
||||||
if (!message.parts) return ""
|
if (!message.parts) return "";
|
||||||
return message.parts
|
return message.parts
|
||||||
.filter((part) => part.type === "text")
|
.filter((part: any) => part.type === "text")
|
||||||
.map((part) => (part as { text: string }).text)
|
.map((part: any) => part.text)
|
||||||
.join("\n")
|
.join("\n");
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[];
|
||||||
setInput: (input: string) => void
|
error?: Error | null;
|
||||||
setFiles: (files: File[]) => void
|
setInput: (input: string) => void;
|
||||||
sessionId?: string
|
setFiles: (files: File[]) => void;
|
||||||
onRegenerate?: (messageIndex: number) => void
|
onRegenerate?: (messageIndex: number) => void;
|
||||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
onEditMessage?: (messageIndex: number, newText: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
messages,
|
messages,
|
||||||
|
error,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
sessionId,
|
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("");
|
||||||
const processedToolCalls = useRef<Set<string>>(new Set())
|
const processedToolCalls = useRef<Set<string>>(new Set());
|
||||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||||
{},
|
{}
|
||||||
)
|
);
|
||||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
|
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
|
||||||
string | null
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||||
>(null)
|
const [editText, setEditText] = useState<string>("");
|
||||||
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) => {
|
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text);
|
||||||
setCopiedMessageId(messageId)
|
setCopiedMessageId(messageId);
|
||||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
setTimeout(() => setCopiedMessageId(null), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy message:", err)
|
console.error("Failed to copy message:", err);
|
||||||
setCopyFailedMessageId(messageId)
|
setCopyFailedMessageId(messageId);
|
||||||
setTimeout(() => setCopyFailedMessageId(null), 2000)
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch("/api/log-feedback", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
messageId,
|
|
||||||
feedback: value,
|
|
||||||
sessionId,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to log feedback:", error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string) => {
|
(xml: string) => {
|
||||||
const currentXml = xml || ""
|
const currentXml = xml || "";
|
||||||
const convertedXml = convertToLegalXml(currentXml)
|
const convertedXml = convertToLegalXml(currentXml);
|
||||||
if (convertedXml !== previousXML.current) {
|
if (convertedXml !== previousXML.current) {
|
||||||
const replacedXML = replaceNodes(chartXML, convertedXml)
|
const replacedXML = replaceNodes(chartXML, convertedXml);
|
||||||
|
|
||||||
const validationError = validateMxCellStructure(replacedXML)
|
const validationError = validateMxCellStructure(replacedXML);
|
||||||
if (!validationError) {
|
if (!validationError) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml;
|
||||||
onDisplayChart(replacedXML)
|
onDisplayChart(replacedXML);
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log("[ChatMessageDisplay] XML validation failed:", validationError);
|
||||||
"[ChatMessageDisplay] XML validation failed:",
|
|
||||||
validationError,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chartXML, onDisplayChart],
|
[chartXML, onDisplayChart]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current) {
|
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(() => {
|
useEffect(() => {
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (message.parts) {
|
if (message.parts) {
|
||||||
message.parts.forEach((part) => {
|
message.parts.forEach((part: any) => {
|
||||||
if (part.type?.startsWith("tool-")) {
|
if (part.type?.startsWith("tool-")) {
|
||||||
const toolPart = part as ToolPartLike
|
const { toolCallId, state } = part;
|
||||||
const { toolCallId, state, input } = toolPart
|
|
||||||
|
|
||||||
if (state === "output-available") {
|
if (state === "output-available") {
|
||||||
setExpandedTools((prev) => ({
|
setExpandedTools((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[toolCallId]: false,
|
[toolCallId]: false,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
part.type === "tool-display_diagram" &&
|
part.type === "tool-display_diagram" &&
|
||||||
input?.xml
|
part.input?.xml
|
||||||
) {
|
) {
|
||||||
const xml = input.xml as string
|
|
||||||
if (
|
if (
|
||||||
state === "input-streaming" ||
|
state === "input-streaming" ||
|
||||||
state === "input-available"
|
state === "input-available"
|
||||||
) {
|
) {
|
||||||
handleDisplayChart(xml)
|
handleDisplayChart(part.input.xml);
|
||||||
} else if (
|
} else if (
|
||||||
state === "output-available" &&
|
state === "output-available" &&
|
||||||
!processedToolCalls.current.has(toolCallId)
|
!processedToolCalls.current.has(toolCallId)
|
||||||
) {
|
) {
|
||||||
handleDisplayChart(xml)
|
handleDisplayChart(part.input.xml);
|
||||||
processedToolCalls.current.add(toolCallId)
|
processedToolCalls.current.add(toolCallId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, [messages, handleDisplayChart])
|
}, [messages, handleDisplayChart]);
|
||||||
|
|
||||||
const renderToolPart = (part: ToolPartLike) => {
|
const renderToolPart = (part: any) => {
|
||||||
const callId = part.toolCallId
|
const callId = part.toolCallId;
|
||||||
const { state, input, output } = part
|
const { state, input, output } = part;
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
const isExpanded = expandedTools[callId] ?? true;
|
||||||
const toolName = part.type?.replace("tool-", "")
|
const toolName = part.type?.replace("tool-", "");
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
const toggleExpanded = () => {
|
||||||
setExpandedTools((prev) => ({
|
setExpandedTools((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[callId]: !isExpanded,
|
[callId]: !isExpanded,
|
||||||
}))
|
}));
|
||||||
}
|
};
|
||||||
|
|
||||||
const getToolDisplayName = (name: string) => {
|
const getToolDisplayName = (name: string) => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "display_diagram":
|
case "display_diagram":
|
||||||
return "Generate Diagram"
|
return "Generate Diagram";
|
||||||
case "edit_diagram":
|
case "edit_diagram":
|
||||||
return "Edit Diagram"
|
return "Edit Diagram";
|
||||||
default:
|
default:
|
||||||
return name
|
return name;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -299,7 +219,6 @@ export function ChatMessageDisplay({
|
|||||||
)}
|
)}
|
||||||
{input && Object.keys(input).length > 0 && (
|
{input && Object.keys(input).length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
@@ -316,16 +235,10 @@ export function ChatMessageDisplay({
|
|||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
{typeof input === "object" && input.xml ? (
|
{typeof input === "object" && input.xml ? (
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
) : typeof input === "object" &&
|
) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? (
|
||||||
input.edits &&
|
|
||||||
Array.isArray(input.edits) ? (
|
|
||||||
<EditDiffDisplay edits={input.edits} />
|
<EditDiffDisplay edits={input.edits} />
|
||||||
) : typeof input === "object" &&
|
) : typeof input === "object" && Object.keys(input).length > 0 ? (
|
||||||
Object.keys(input).length > 0 ? (
|
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||||
<CodeBlock
|
|
||||||
code={JSON.stringify(input, null, 2)}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -335,57 +248,40 @@ export function ChatMessageDisplay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full px-4 scrollbar-thin">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
) : (
|
) : (
|
||||||
<div className="py-4 px-4 space-y-4">
|
<div className="py-4 space-y-4">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const userMessageText =
|
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
|
||||||
message.role === "user"
|
const isLastAssistantMessage = message.role === "assistant" && (
|
||||||
? getMessageTextContent(message)
|
messageIndex === messages.length - 1 ||
|
||||||
: ""
|
messages.slice(messageIndex + 1).every(m => m.role !== "assistant")
|
||||||
const isLastAssistantMessage =
|
);
|
||||||
message.role === "assistant" &&
|
const isLastUserMessage = message.role === "user" && (
|
||||||
(messageIndex === messages.length - 1 ||
|
messageIndex === messages.length - 1 ||
|
||||||
messages
|
messages.slice(messageIndex + 1).every(m => m.role !== "user")
|
||||||
.slice(messageIndex + 1)
|
);
|
||||||
.every((m) => m.role !== "assistant"))
|
const isEditing = editingMessageId === message.id;
|
||||||
const isLastUserMessage =
|
|
||||||
message.role === "user" &&
|
|
||||||
(messageIndex === messages.length - 1 ||
|
|
||||||
messages
|
|
||||||
.slice(messageIndex + 1)
|
|
||||||
.every((m) => m.role !== "user"))
|
|
||||||
const isEditing = editingMessageId === message.id
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||||
style={{
|
style={{ animationDelay: `${messageIndex * 50}ms` }}
|
||||||
animationDelay: `${messageIndex * 50}ms`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{message.role === "user" &&
|
{message.role === "user" && userMessageText && !isEditing && (
|
||||||
userMessageText &&
|
|
||||||
!isEditing && (
|
|
||||||
<div className="flex items-center gap-1 self-center mr-2">
|
<div className="flex items-center gap-1 self-center mr-2">
|
||||||
{/* Edit button - only on last user message */}
|
{/* Edit button - only on last user message */}
|
||||||
{onEditMessage &&
|
{onEditMessage && isLastUserMessage && (
|
||||||
isLastUserMessage && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingMessageId(
|
setEditingMessageId(message.id);
|
||||||
message.id,
|
setEditText(userMessageText);
|
||||||
)
|
|
||||||
setEditText(
|
|
||||||
userMessageText,
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||||
title="Edit message"
|
title="Edit message"
|
||||||
@@ -394,29 +290,13 @@ export function ChatMessageDisplay({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
|
||||||
onClick={() =>
|
|
||||||
copyMessageToClipboard(
|
|
||||||
message.id,
|
|
||||||
userMessageText,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||||
title={
|
title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
|
||||||
copiedMessageId ===
|
|
||||||
message.id
|
|
||||||
? "Copied!"
|
|
||||||
: copyFailedMessageId ===
|
|
||||||
message.id
|
|
||||||
? "Failed to copy"
|
|
||||||
: "Copy message"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{copiedMessageId ===
|
{copiedMessageId === message.id ? (
|
||||||
message.id ? (
|
|
||||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||||
) : copyFailedMessageId ===
|
) : copyFailedMessageId === message.id ? (
|
||||||
message.id ? (
|
|
||||||
<X className="h-3.5 w-3.5 text-red-500" />
|
<X className="h-3.5 w-3.5 text-red-500" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
@@ -424,77 +304,46 @@ export function ChatMessageDisplay({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-[85%] min-w-0">
|
<div className="max-w-[85%]">
|
||||||
{/* Edit mode for user messages */}
|
{/* Edit mode for user messages */}
|
||||||
{isEditing && message.role === "user" ? (
|
{isEditing && message.role === "user" ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
ref={editTextareaRef}
|
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
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"
|
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(
|
rows={Math.min(editText.split('\n').length + 1, 6)}
|
||||||
editText.split("\n")
|
autoFocus
|
||||||
.length + 1,
|
|
||||||
6,
|
|
||||||
)}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
setEditingMessageId(
|
setEditingMessageId(null);
|
||||||
null,
|
setEditText("");
|
||||||
)
|
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
setEditText("")
|
e.preventDefault();
|
||||||
} else if (
|
if (editText.trim() && onEditMessage) {
|
||||||
e.key === "Enter" &&
|
onEditMessage(messageIndex, editText.trim());
|
||||||
(e.metaKey || e.ctrlKey)
|
setEditingMessageId(null);
|
||||||
) {
|
setEditText("");
|
||||||
e.preventDefault()
|
|
||||||
if (
|
|
||||||
editText.trim() &&
|
|
||||||
onEditMessage
|
|
||||||
) {
|
|
||||||
onEditMessage(
|
|
||||||
messageIndex,
|
|
||||||
editText.trim(),
|
|
||||||
)
|
|
||||||
setEditingMessageId(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
setEditText("")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingMessageId(
|
setEditingMessageId(null);
|
||||||
null,
|
setEditText("");
|
||||||
)
|
|
||||||
setEditText("")
|
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (editText.trim() && onEditMessage) {
|
||||||
editText.trim() &&
|
onEditMessage(messageIndex, editText.trim());
|
||||||
onEditMessage
|
setEditingMessageId(null);
|
||||||
) {
|
setEditText("");
|
||||||
onEditMessage(
|
|
||||||
messageIndex,
|
|
||||||
editText.trim(),
|
|
||||||
)
|
|
||||||
setEditingMessageId(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
setEditText("")
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!editText.trim()}
|
disabled={!editText.trim()}
|
||||||
@@ -506,237 +355,101 @@ export function ChatMessageDisplay({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Text content in bubble */
|
/* Text content in bubble */
|
||||||
message.parts?.some(
|
message.parts?.some((part: any) => part.type === "text" || part.type === "file") && (
|
||||||
(part) =>
|
|
||||||
part.type === "text" ||
|
|
||||||
part.type === "file",
|
|
||||||
) && (
|
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||||
message.role === "user"
|
message.role === "user"
|
||||||
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
? "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"
|
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
||||||
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
||||||
role={
|
|
||||||
message.role === "user" &&
|
|
||||||
isLastUserMessage &&
|
|
||||||
onEditMessage
|
|
||||||
? "button"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
tabIndex={
|
|
||||||
message.role === "user" &&
|
|
||||||
isLastUserMessage &&
|
|
||||||
onEditMessage
|
|
||||||
? 0
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (message.role === "user" && isLastUserMessage && onEditMessage) {
|
||||||
message.role ===
|
setEditingMessageId(message.id);
|
||||||
"user" &&
|
setEditText(userMessageText);
|
||||||
isLastUserMessage &&
|
|
||||||
onEditMessage
|
|
||||||
) {
|
|
||||||
setEditingMessageId(
|
|
||||||
message.id,
|
|
||||||
)
|
|
||||||
setEditText(
|
|
||||||
userMessageText,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
title={message.role === "user" && isLastUserMessage && onEditMessage ? "Click to edit" : undefined}
|
||||||
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
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{message.parts?.map(
|
{message.parts?.map((part: any, index: number) => {
|
||||||
(part, index) => {
|
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={index} className="whitespace-pre-wrap break-words">
|
||||||
key={`${message.id}-text-${index}`}
|
{part.text}
|
||||||
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>
|
</div>
|
||||||
)
|
);
|
||||||
case "file":
|
case "file":
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={index} className="mt-2">
|
||||||
key={`${message.id}-file-${part.url}`}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
<Image
|
<Image
|
||||||
src={
|
src={part.url}
|
||||||
part.url
|
width={200}
|
||||||
}
|
height={200}
|
||||||
width={
|
|
||||||
200
|
|
||||||
}
|
|
||||||
height={
|
|
||||||
200
|
|
||||||
}
|
|
||||||
alt={`Uploaded diagram or image for AI analysis`}
|
alt={`Uploaded diagram or image for AI analysis`}
|
||||||
className="rounded-lg border border-white/20"
|
className="rounded-lg border border-white/20"
|
||||||
style={{
|
style={{
|
||||||
objectFit:
|
objectFit: "contain",
|
||||||
"contain",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
default:
|
default:
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
},
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{/* Tool calls outside bubble */}
|
{/* Tool calls outside bubble */}
|
||||||
{message.parts?.map((part) => {
|
{message.parts?.map((part: any) => {
|
||||||
if (part.type?.startsWith("tool-")) {
|
if (part.type?.startsWith("tool-")) {
|
||||||
return renderToolPart(
|
return renderToolPart(part);
|
||||||
part as ToolPartLike,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
})}
|
})}
|
||||||
{/* Action buttons for assistant messages */}
|
{/* Action buttons for assistant messages */}
|
||||||
{message.role === "assistant" && (
|
{message.role === "assistant" && (
|
||||||
<div className="flex items-center gap-1 mt-2">
|
<div className="flex items-center gap-1 mt-2">
|
||||||
{/* Copy button */}
|
{/* Copy button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={() => copyMessageToClipboard(message.id, getMessageTextContent(message))}
|
||||||
onClick={() =>
|
|
||||||
copyMessageToClipboard(
|
|
||||||
message.id,
|
|
||||||
getMessageTextContent(
|
|
||||||
message,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
copiedMessageId ===
|
copiedMessageId === message.id
|
||||||
message.id
|
|
||||||
? "text-green-600 bg-green-100"
|
? "text-green-600 bg-green-100"
|
||||||
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={copiedMessageId === message.id ? "Copied!" : "Copy response"}
|
||||||
copiedMessageId ===
|
|
||||||
message.id
|
|
||||||
? "Copied!"
|
|
||||||
: "Copy response"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{copiedMessageId ===
|
{copiedMessageId === message.id ? (
|
||||||
message.id ? (
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{/* Regenerate button - only on last assistant message */}
|
{/* Regenerate button - only on last assistant message */}
|
||||||
{onRegenerate &&
|
{onRegenerate && isLastAssistantMessage && (
|
||||||
isLastAssistantMessage && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={() => onRegenerate(messageIndex)}
|
||||||
onClick={() =>
|
|
||||||
onRegenerate(
|
|
||||||
messageIndex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||||
title="Regenerate response"
|
title="Regenerate response"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
|
||||||
{/* Thumbs up */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
submitFeedback(
|
|
||||||
message.id,
|
|
||||||
"good",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
|
||||||
feedback[message.id] ===
|
|
||||||
"good"
|
|
||||||
? "text-green-600 bg-green-100"
|
|
||||||
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
|
||||||
}`}
|
|
||||||
title="Good response"
|
|
||||||
>
|
|
||||||
<ThumbsUp className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
{/* Thumbs down */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
submitFeedback(
|
|
||||||
message.id,
|
|
||||||
"bad",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
|
||||||
feedback[message.id] ===
|
|
||||||
"bad"
|
|
||||||
? "text-red-600 bg-red-100"
|
|
||||||
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
|
||||||
}`}
|
|
||||||
title="Bad response"
|
|
||||||
>
|
|
||||||
<ThumbsDown className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mb-4 p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
<span className="font-medium">Error:</span> {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,29 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react"
|
import type React from "react";
|
||||||
import { DefaultChatTransport } from "ai"
|
import { useRef, useEffect, useState } from "react";
|
||||||
import {
|
import { flushSync } from "react-dom";
|
||||||
CheckCircle,
|
import { FaGithub } from "react-icons/fa";
|
||||||
PanelRightClose,
|
import { PanelRightClose, PanelRightOpen, CheckCircle } from "lucide-react";
|
||||||
PanelRightOpen,
|
import Link from "next/link";
|
||||||
Settings,
|
import Image from "next/image";
|
||||||
} from "lucide-react"
|
|
||||||
import Image from "next/image"
|
import { useChat } from "@ai-sdk/react";
|
||||||
import Link from "next/link"
|
import { DefaultChatTransport } from "ai";
|
||||||
import type React from "react"
|
import { ChatInput } from "@/components/chat-input";
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { ChatMessageDisplay } from "./chat-message-display";
|
||||||
import { flushSync } from "react-dom"
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
||||||
import { Toaster } from "sonner"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
|
||||||
import { ChatInput } from "@/components/chat-input"
|
|
||||||
import {
|
|
||||||
SettingsDialog,
|
|
||||||
STORAGE_ACCESS_CODE_KEY,
|
|
||||||
} from "@/components/settings-dialog"
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
|
||||||
import { formatXML, validateMxCellStructure } from "@/lib/utils"
|
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
isVisible: boolean
|
isVisible: boolean;
|
||||||
onToggleVisibility: () => void
|
onToggleVisibility: () => void;
|
||||||
drawioUi: "min" | "sketch"
|
|
||||||
onToggleDrawioUi: () => void
|
|
||||||
isMobile?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({
|
export default function ChatPanel({
|
||||||
isVisible,
|
isVisible,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
drawioUi,
|
|
||||||
onToggleDrawioUi,
|
|
||||||
isMobile = false,
|
|
||||||
}: ChatPanelProps) {
|
}: ChatPanelProps) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
@@ -47,134 +32,123 @@ export default function ChatPanel({
|
|||||||
resolverRef,
|
resolverRef,
|
||||||
chartXML,
|
chartXML,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
} = useDiagram()
|
} = useDiagram();
|
||||||
|
|
||||||
const onFetchChart = (saveToHistory = true) => {
|
const onFetchChart = (saveToHistory = true) => {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
if (resolverRef && "current" in resolverRef) {
|
if (resolverRef && "current" in resolverRef) {
|
||||||
resolverRef.current = resolve
|
resolverRef.current = resolve;
|
||||||
}
|
}
|
||||||
if (saveToHistory) {
|
if (saveToHistory) {
|
||||||
onExport()
|
onExport();
|
||||||
} else {
|
} else {
|
||||||
handleExportWithoutHistory()
|
handleExportWithoutHistory();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) =>
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() =>
|
() =>
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error("Chart export timed out after 10 seconds")
|
||||||
"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("")
|
|
||||||
|
|
||||||
// Check if access code is required on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/config")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
|
|
||||||
.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)}`,
|
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [streamingError, setStreamingError] = useState<Error | null>(null);
|
||||||
|
|
||||||
// Store XML snapshots for each user message (keyed by message index)
|
// 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());
|
||||||
|
|
||||||
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
||||||
const chartXMLRef = useRef(chartXML)
|
const chartXMLRef = useRef(chartXML);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chartXMLRef.current = chartXML
|
chartXMLRef.current = chartXML;
|
||||||
}, [chartXML])
|
}, [chartXML]);
|
||||||
|
|
||||||
const { messages, sendMessage, addToolResult, status, error, setMessages } =
|
const {
|
||||||
useChat({
|
messages,
|
||||||
|
sendMessage,
|
||||||
|
addToolResult,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
setMessages,
|
||||||
|
stop,
|
||||||
|
} = useChat({
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
api: "/api/chat",
|
api: "/api/chat",
|
||||||
}),
|
}),
|
||||||
async onToolCall({ toolCall }) {
|
async onToolCall({ toolCall }) {
|
||||||
if (toolCall.toolName === "display_diagram") {
|
if (toolCall.toolName === "display_diagram") {
|
||||||
const { xml } = toolCall.input as { xml: string }
|
const { xml } = toolCall.input as { xml: string };
|
||||||
|
|
||||||
const validationError = validateMxCellStructure(xml)
|
const validationError = validateMxCellStructure(xml);
|
||||||
|
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
addToolResult({
|
addToolResult({
|
||||||
tool: "display_diagram",
|
tool: "display_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
output: validationError,
|
output: validationError,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
addToolResult({
|
addToolResult({
|
||||||
tool: "display_diagram",
|
tool: "display_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
output: "Successfully displayed the diagram.",
|
output: "Successfully displayed the diagram.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} else if (toolCall.toolName === "edit_diagram") {
|
} else if (toolCall.toolName === "edit_diagram") {
|
||||||
const { edits } = toolCall.input as {
|
const { edits } = toolCall.input as {
|
||||||
edits: Array<{ search: string; replace: string }>
|
edits: Array<{ search: string; replace: string }>;
|
||||||
}
|
};
|
||||||
|
|
||||||
let currentXml = ""
|
let currentXml = "";
|
||||||
try {
|
try {
|
||||||
console.log("[edit_diagram] Starting...")
|
console.log("[edit_diagram] Starting...");
|
||||||
// Use chartXML from ref directly - more reliable than export
|
// Use chartXML from ref directly - more reliable than export
|
||||||
// especially on Vercel where DrawIO iframe may have latency issues
|
// especially on Vercel where DrawIO iframe may have latency issues
|
||||||
// Using ref to avoid stale closure in callback
|
// Using ref to avoid stale closure in callback
|
||||||
const cachedXML = chartXMLRef.current
|
const cachedXML = chartXMLRef.current;
|
||||||
if (cachedXML) {
|
if (cachedXML) {
|
||||||
currentXml = cachedXML
|
currentXml = cachedXML;
|
||||||
console.log(
|
console.log(
|
||||||
"[edit_diagram] Using cached chartXML, length:",
|
"[edit_diagram] Using cached chartXML, length:",
|
||||||
currentXml.length,
|
currentXml.length
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to export only if no cached XML
|
// Fallback to export only if no cached XML
|
||||||
console.log(
|
console.log(
|
||||||
"[edit_diagram] No cached XML, fetching from DrawIO...",
|
"[edit_diagram] No cached XML, fetching from DrawIO..."
|
||||||
)
|
);
|
||||||
currentXml = await onFetchChart(false)
|
currentXml = await onFetchChart(false);
|
||||||
console.log(
|
console.log(
|
||||||
"[edit_diagram] Got XML from export, length:",
|
"[edit_diagram] Got XML from export, length:",
|
||||||
currentXml.length,
|
currentXml.length
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { replaceXMLParts } = await import("@/lib/utils")
|
const { replaceXMLParts } = await import("@/lib/utils");
|
||||||
const editedXml = replaceXMLParts(currentXml, edits)
|
const editedXml = replaceXMLParts(currentXml, edits);
|
||||||
|
|
||||||
onDisplayChart(editedXml)
|
onDisplayChart(editedXml);
|
||||||
|
|
||||||
addToolResult({
|
addToolResult({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
||||||
})
|
});
|
||||||
console.log("[edit_diagram] Success")
|
console.log("[edit_diagram] Success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[edit_diagram] Failed:", error)
|
console.error("[edit_diagram] Failed:", error);
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error
|
error instanceof Error ? error.message : String(error);
|
||||||
? error.message
|
|
||||||
: String(error)
|
|
||||||
|
|
||||||
addToolResult({
|
addToolResult({
|
||||||
tool: "edit_diagram",
|
tool: "edit_diagram",
|
||||||
@@ -187,162 +161,200 @@ ${currentXml || "No XML available"}
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
|
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Silence access code error in console since it's handled by UI
|
console.error("Chat error:", error);
|
||||||
if (!error.message.includes("Invalid or missing access code")) {
|
setStreamingError(error);
|
||||||
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]
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error.message.includes("Invalid or missing access code")) {
|
|
||||||
// Show settings button and open dialog to help user fix it
|
|
||||||
setAccessCodeRequired(true)
|
|
||||||
setShowSettingsDialog(true)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
// Streaming timeout detection - detects when stream stalls mid-response (e.g., Bedrock 503)
|
||||||
|
// This catches cases where onError doesn't fire because headers were already sent
|
||||||
|
const lastMessageCountRef = useRef(0);
|
||||||
|
const lastMessagePartsRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear streaming error when status changes to ready
|
||||||
|
if (status === "ready") {
|
||||||
|
setStreamingError(null);
|
||||||
|
lastMessageCountRef.current = 0;
|
||||||
|
lastMessagePartsRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "streaming") return;
|
||||||
|
|
||||||
|
const STALL_TIMEOUT_MS = 15000; // 15 seconds without any update
|
||||||
|
|
||||||
|
// Capture current state BEFORE setting timeout
|
||||||
|
// This way we compare against values at the time timeout was set
|
||||||
|
const currentPartsCount = messages.reduce(
|
||||||
|
(acc, msg) => acc + (msg.parts?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const capturedMessageCount = messages.length;
|
||||||
|
const capturedPartsCount = currentPartsCount;
|
||||||
|
|
||||||
|
// Update refs immediately so next effect run has fresh values
|
||||||
|
lastMessageCountRef.current = messages.length;
|
||||||
|
lastMessagePartsRef.current = currentPartsCount;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
// Re-count parts at timeout time
|
||||||
|
const newPartsCount = messages.reduce(
|
||||||
|
(acc, msg) => acc + (msg.parts?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no change since timeout was set, stream has stalled
|
||||||
|
if (
|
||||||
|
messages.length === capturedMessageCount &&
|
||||||
|
newPartsCount === capturedPartsCount
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"[Streaming Timeout] No activity for 15s - forcing error state"
|
||||||
|
);
|
||||||
|
setStreamingError(
|
||||||
|
new Error(
|
||||||
|
"Connection lost. The AI service may be temporarily unavailable. Please try again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
stop(); // Allow user to retry by transitioning status to "ready"
|
||||||
|
}
|
||||||
|
}, STALL_TIMEOUT_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [status, messages, stop]);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current) {
|
if (messagesEndRef.current) {
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages]);
|
||||||
|
|
||||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
const isProcessing = status === "streaming" || status === "submitted"
|
// Allow retry if there's a streaming error (workaround for stop() not transitioning status)
|
||||||
|
const isProcessing =
|
||||||
|
(status === "streaming" || status === "submitted") &&
|
||||||
|
!streamingError;
|
||||||
if (input.trim() && !isProcessing) {
|
if (input.trim() && !isProcessing) {
|
||||||
|
// Clear any previous streaming error before starting new request
|
||||||
|
setStreamingError(null);
|
||||||
try {
|
try {
|
||||||
let chartXml = await onFetchChart()
|
let chartXml = await onFetchChart();
|
||||||
chartXml = formatXML(chartXml)
|
chartXml = formatXML(chartXml);
|
||||||
|
|
||||||
// Update ref directly to avoid race condition with React's async state update
|
// Update ref directly to avoid race condition with React's async state update
|
||||||
// This ensures edit_diagram has the correct XML before AI responds
|
// 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) {
|
if (files.length > 0) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader();
|
||||||
const dataUrl = await new Promise<string>((resolve) => {
|
const dataUrl = await new Promise<string>((resolve) => {
|
||||||
reader.onload = () =>
|
reader.onload = () =>
|
||||||
resolve(reader.result as string)
|
resolve(reader.result as string);
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file);
|
||||||
})
|
});
|
||||||
|
|
||||||
parts.push({
|
parts.push({
|
||||||
type: "file",
|
type: "file",
|
||||||
url: dataUrl,
|
url: dataUrl,
|
||||||
mediaType: file.type,
|
mediaType: file.type,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save XML snapshot for this message (will be at index = current messages.length)
|
// Save XML snapshot for this message (will be at index = current messages.length)
|
||||||
const messageIndex = messages.length
|
const messageIndex = messages.length;
|
||||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
xmlSnapshotsRef.current.set(messageIndex, chartXml);
|
||||||
|
|
||||||
const accessCode =
|
|
||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{ parts },
|
{ parts },
|
||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
xml: chartXml,
|
xml: chartXml,
|
||||||
sessionId,
|
|
||||||
},
|
},
|
||||||
headers: {
|
}
|
||||||
"x-access-code": accessCode,
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
setInput("")
|
setInput("");
|
||||||
setFiles([])
|
setFiles([]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching chart data:", error)
|
console.error("Error fetching chart data:", error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (
|
const handleInputChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
) => {
|
) => {
|
||||||
setInput(e.target.value)
|
setInput(e.target.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleFileChange = (newFiles: File[]) => {
|
const handleFileChange = (newFiles: File[]) => {
|
||||||
setFiles(newFiles)
|
setFiles(newFiles);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleRegenerate = async (messageIndex: number) => {
|
const handleRegenerate = async (messageIndex: number) => {
|
||||||
const isProcessing = status === "streaming" || status === "submitted"
|
const isProcessing = status === "streaming" || status === "submitted";
|
||||||
if (isProcessing) return
|
if (isProcessing) return;
|
||||||
|
|
||||||
// Find the user message before this assistant message
|
// Find the user message before this assistant message
|
||||||
let userMessageIndex = messageIndex - 1
|
let userMessageIndex = messageIndex - 1;
|
||||||
while (
|
while (
|
||||||
userMessageIndex >= 0 &&
|
userMessageIndex >= 0 &&
|
||||||
messages[userMessageIndex].role !== "user"
|
messages[userMessageIndex].role !== "user"
|
||||||
) {
|
) {
|
||||||
userMessageIndex--
|
userMessageIndex--;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userMessageIndex < 0) return
|
if (userMessageIndex < 0) return;
|
||||||
|
|
||||||
const userMessage = messages[userMessageIndex]
|
const userMessage = messages[userMessageIndex];
|
||||||
const userParts = userMessage.parts
|
const userParts = userMessage.parts;
|
||||||
|
|
||||||
// Get the text from the user message
|
// Get the text from the user message
|
||||||
const textPart = userParts?.find((p: any) => p.type === "text")
|
const textPart = userParts?.find((p: any) => p.type === "text");
|
||||||
if (!textPart) return
|
if (!textPart) return;
|
||||||
|
|
||||||
// Get the saved XML snapshot for this user message
|
// Get the saved XML snapshot for this user message
|
||||||
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
|
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex);
|
||||||
if (!savedXml) {
|
if (!savedXml) {
|
||||||
console.error(
|
console.error(
|
||||||
"No saved XML snapshot for message index:",
|
"No saved XML snapshot for message index:",
|
||||||
userMessageIndex,
|
userMessageIndex
|
||||||
)
|
);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the diagram to the saved state
|
// Restore the diagram to the saved state
|
||||||
onDisplayChart(savedXml)
|
onDisplayChart(savedXml);
|
||||||
|
|
||||||
// Update ref directly to ensure edit_diagram has the correct XML
|
// 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)
|
// Clean up snapshots for messages after the user message (they will be removed)
|
||||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||||
if (key > userMessageIndex) {
|
if (key > userMessageIndex) {
|
||||||
xmlSnapshotsRef.current.delete(key)
|
xmlSnapshotsRef.current.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
// 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
|
// Use flushSync to ensure state update is processed synchronously before sending
|
||||||
const newMessages = messages.slice(0, userMessageIndex)
|
const newMessages = messages.slice(0, userMessageIndex);
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setMessages(newMessages)
|
setMessages(newMessages);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Now send the message after state is guaranteed to be updated
|
// Now send the message after state is guaranteed to be updated
|
||||||
sendMessage(
|
sendMessage(
|
||||||
@@ -350,56 +362,55 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
xml: savedXml,
|
xml: savedXml,
|
||||||
sessionId,
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
||||||
const isProcessing = status === "streaming" || status === "submitted"
|
const isProcessing = status === "streaming" || status === "submitted";
|
||||||
if (isProcessing) return
|
if (isProcessing) return;
|
||||||
|
|
||||||
const message = messages[messageIndex]
|
const message = messages[messageIndex];
|
||||||
if (!message || message.role !== "user") return
|
if (!message || message.role !== "user") return;
|
||||||
|
|
||||||
// Get the saved XML snapshot for this user message
|
// Get the saved XML snapshot for this user message
|
||||||
const savedXml = xmlSnapshotsRef.current.get(messageIndex)
|
const savedXml = xmlSnapshotsRef.current.get(messageIndex);
|
||||||
if (!savedXml) {
|
if (!savedXml) {
|
||||||
console.error(
|
console.error(
|
||||||
"No saved XML snapshot for message index:",
|
"No saved XML snapshot for message index:",
|
||||||
messageIndex,
|
messageIndex
|
||||||
)
|
);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the diagram to the saved state
|
// Restore the diagram to the saved state
|
||||||
onDisplayChart(savedXml)
|
onDisplayChart(savedXml);
|
||||||
|
|
||||||
// Update ref directly to ensure edit_diagram has the correct XML
|
// 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)
|
// Clean up snapshots for messages after the user message (they will be removed)
|
||||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||||
if (key > messageIndex) {
|
if (key > messageIndex) {
|
||||||
xmlSnapshotsRef.current.delete(key)
|
xmlSnapshotsRef.current.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new parts with updated text
|
// Create new parts with updated text
|
||||||
const newParts = message.parts?.map((part: any) => {
|
const newParts = message.parts?.map((part: any) => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
return { ...part, text: newText }
|
return { ...part, text: newText };
|
||||||
}
|
}
|
||||||
return part
|
return part;
|
||||||
}) || [{ type: "text", text: newText }]
|
}) || [{ type: "text", text: newText }];
|
||||||
|
|
||||||
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
|
// 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
|
// Use flushSync to ensure state update is processed synchronously before sending
|
||||||
const newMessages = messages.slice(0, messageIndex)
|
const newMessages = messages.slice(0, messageIndex);
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setMessages(newMessages)
|
setMessages(newMessages);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Now send the edited message after state is guaranteed to be updated
|
// Now send the edited message after state is guaranteed to be updated
|
||||||
sendMessage(
|
sendMessage(
|
||||||
@@ -407,14 +418,13 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
xml: savedXml,
|
xml: savedXml,
|
||||||
sessionId,
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Collapsed view (desktop only)
|
// Collapsed view
|
||||||
if (!isVisible && !isMobile) {
|
if (!isVisible) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -436,55 +446,42 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
AI Chat
|
AI Chat
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full view
|
// Full view
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
|
||||||
<Toaster
|
|
||||||
position="bottom-center"
|
|
||||||
richColors
|
|
||||||
style={{ position: "absolute" }}
|
|
||||||
/>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header
|
<header className="px-5 py-4 border-b border-border/50">
|
||||||
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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Image
|
<Image
|
||||||
src="/favicon.ico"
|
src="/favicon.ico"
|
||||||
alt="Next AI Drawio"
|
alt="Next AI Drawio"
|
||||||
width={isMobile ? 24 : 28}
|
width={28}
|
||||||
height={isMobile ? 24 : 28}
|
height={28}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<h1
|
<h1 className="text-base font-semibold tracking-tight whitespace-nowrap">
|
||||||
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
|
|
||||||
>
|
|
||||||
Next AI Drawio
|
Next AI Drawio
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && (
|
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||||
>
|
>
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
{!isMobile && (
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent="Recent generation failures were caused by our AI provider's infrastructure issue, not the app code. After extensive debugging, I've switched providers and observed 6 hours of stability. If issues persist, please report on GitHub."
|
tooltipContent="Recent generation failures were caused by our AI provider's infrastructure issue, not the app code. After extensive debugging, I've switched providers and observed 30+ minutes of stability. If issues persist, please report on GitHub."
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 text-green-500 hover:text-green-600"
|
className="h-6 w-6 text-green-500 hover:text-green-600"
|
||||||
>
|
>
|
||||||
<CheckCircle className="h-4 w-4" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<a
|
<a
|
||||||
@@ -493,24 +490,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<FaGithub
|
<FaGithub className="w-5 h-5" />
|
||||||
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
|
||||||
/>
|
|
||||||
</a>
|
</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>
|
|
||||||
)}
|
|
||||||
{!isMobile && (
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent="Hide chat panel (Ctrl+B)"
|
tooltipContent="Hide chat panel (Ctrl+B)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -520,57 +501,41 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
>
|
>
|
||||||
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
|
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<main className="flex-1 w-full overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
<ChatMessageDisplay
|
<ChatMessageDisplay
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
error={error || streamingError}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={handleFileChange}
|
setFiles={handleFileChange}
|
||||||
sessionId={sessionId}
|
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
onEditMessage={handleEditMessage}
|
onEditMessage={handleEditMessage}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<footer
|
<footer className="p-4 border-t border-border/50 bg-card/50">
|
||||||
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
|
||||||
>
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={input}
|
input={input}
|
||||||
status={status}
|
status={status}
|
||||||
onSubmit={onFormSubmit}
|
onSubmit={onFormSubmit}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onClearChat={() => {
|
onClearChat={() => {
|
||||||
setMessages([])
|
setMessages([]);
|
||||||
clearDiagram()
|
clearDiagram();
|
||||||
setSessionId(
|
xmlSnapshotsRef.current.clear();
|
||||||
`session-${Date.now()}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2, 9)}`,
|
|
||||||
)
|
|
||||||
xmlSnapshotsRef.current.clear()
|
|
||||||
}}
|
}}
|
||||||
files={files}
|
files={files}
|
||||||
onFileChange={handleFileChange}
|
onFileChange={handleFileChange}
|
||||||
showHistory={showHistory}
|
showHistory={showHistory}
|
||||||
onToggleHistory={setShowHistory}
|
onToggleHistory={setShowHistory}
|
||||||
sessionId={sessionId}
|
error={error || streamingError}
|
||||||
error={error}
|
|
||||||
drawioUi={drawioUi}
|
|
||||||
onToggleDrawioUi={onToggleDrawioUi}
|
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<SettingsDialog
|
|
||||||
open={showSettingsDialog}
|
|
||||||
onOpenChange={setShowSettingsDialog}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Highlight, themes } from "prism-react-renderer"
|
import { Highlight, themes } from "prism-react-renderer";
|
||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
code: string
|
code: string;
|
||||||
language?: "xml" | "json"
|
language?: "xml" | "json";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden w-full">
|
<div className="overflow-hidden w-full">
|
||||||
<Highlight theme={themes.github} code={code} language={language}>
|
<Highlight theme={themes.github} code={code} language={language}>
|
||||||
{({
|
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||||
className: _className,
|
|
||||||
style,
|
|
||||||
tokens,
|
|
||||||
getLineProps,
|
|
||||||
getTokenProps,
|
|
||||||
}) => (
|
|
||||||
<pre
|
<pre
|
||||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
|
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
fontFamily:
|
fontFamily: "var(--font-mono), ui-monospace, monospace",
|
||||||
"var(--font-mono), ui-monospace, monospace",
|
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -32,16 +25,9 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tokens.map((line, i) => (
|
{tokens.map((line, i) => (
|
||||||
<div
|
<div key={i} {...getLineProps({ line })} style={{ wordBreak: "break-all" }}>
|
||||||
key={i}
|
|
||||||
{...getLineProps({ line })}
|
|
||||||
style={{ wordBreak: "break-all" }}
|
|
||||||
>
|
|
||||||
{line.map((token, key) => (
|
{line.map((token, key) => (
|
||||||
<span
|
<span key={key} {...getTokenProps({ token })} />
|
||||||
key={key}
|
|
||||||
{...getTokenProps({ token })}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -49,5 +35,5 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
|||||||
)}
|
)}
|
||||||
</Highlight>
|
</Highlight>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
|
|
||||||
interface ErrorToastProps {
|
|
||||||
message: React.ReactNode
|
|
||||||
onDismiss: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onDismiss}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-destructive"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,84 +1,44 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { X } from "lucide-react"
|
import React, { useEffect, useState } from "react";
|
||||||
import Image from "next/image"
|
import Image from "next/image";
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
interface FilePreviewListProps {
|
interface FilePreviewListProps {
|
||||||
files: File[]
|
files: File[];
|
||||||
onRemoveFile: (fileToRemove: File) => void
|
onRemoveFile: (fileToRemove: File) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
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())
|
|
||||||
|
|
||||||
// Create and cleanup object URLs when files change
|
// Cleanup object URLs on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentUrls = imageUrlsRef.current
|
const objectUrls = files
|
||||||
const newUrls = new Map<File, string>()
|
.filter((file) => file.type.startsWith("image/"))
|
||||||
|
.map((file) => URL.createObjectURL(file));
|
||||||
|
|
||||||
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 () => {
|
return () => {
|
||||||
imageUrlsRef.current.forEach((url) => {
|
objectUrls.forEach(URL.revokeObjectURL);
|
||||||
URL.revokeObjectURL(url)
|
};
|
||||||
})
|
}, [files]);
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Clear selected image if its URL was revoked
|
if (files.length === 0) return null;
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
selectedImage &&
|
|
||||||
!Array.from(imageUrls.values()).includes(selectedImage)
|
|
||||||
) {
|
|
||||||
setSelectedImage(null)
|
|
||||||
}
|
|
||||||
}, [imageUrls, selectedImage])
|
|
||||||
|
|
||||||
if (files.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
const imageUrl = imageUrls.get(file) || null
|
const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
||||||
return (
|
return (
|
||||||
<div key={file.name + index} className="relative group">
|
<div key={file.name + index} className="relative group">
|
||||||
<div
|
<div
|
||||||
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
|
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
|
||||||
onClick={() =>
|
onClick={() => imageUrl && setSelectedImage(imageUrl)}
|
||||||
imageUrl && setSelectedImage(imageUrl)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{file.type.startsWith("image/") && imageUrl ? (
|
{file.type.startsWith("image/") ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl!}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
width={80}
|
width={80}
|
||||||
height={80}
|
height={80}
|
||||||
@@ -99,7 +59,7 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,5 +89,5 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image"
|
import { useState } from "react";
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,32 +8,34 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog";
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
|
|
||||||
interface HistoryDialogProps {
|
interface HistoryDialogProps {
|
||||||
showHistory: boolean
|
showHistory: boolean;
|
||||||
onToggleHistory: (show: boolean) => void
|
onToggleHistory: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryDialog({
|
export function HistoryDialog({
|
||||||
showHistory,
|
showHistory,
|
||||||
onToggleHistory,
|
onToggleHistory,
|
||||||
}: HistoryDialogProps) {
|
}: HistoryDialogProps) {
|
||||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
|
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram();
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSelectedIndex(null)
|
setSelectedIndex(null);
|
||||||
onToggleHistory(false)
|
onToggleHistory(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleConfirmRestore = () => {
|
const handleConfirmRestore = () => {
|
||||||
if (selectedIndex !== null) {
|
if (selectedIndex !== null) {
|
||||||
onDisplayChart(diagramHistory[selectedIndex].xml)
|
onDisplayChart(diagramHistory[selectedIndex].xml);
|
||||||
handleClose()
|
handleClose();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||||
@@ -100,12 +100,15 @@ export function HistoryDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
interface ResetWarningModalProps {
|
interface ResetWarningModalProps {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
onClear: () => void
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResetWarningModal({
|
export function ResetWarningModal({
|
||||||
@@ -44,5 +44,5 @@ export function ResetWarningModal({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,36 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
DialogFooter,
|
||||||
import { Input } from "@/components/ui/input"
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
export type ExportFormat = "drawio" | "png" | "svg"
|
export type ExportFormat = "drawio" | "png" | "svg";
|
||||||
|
|
||||||
const FORMAT_OPTIONS: {
|
const FORMAT_OPTIONS: { value: ExportFormat; label: string; extension: string }[] = [
|
||||||
value: ExportFormat
|
|
||||||
label: string
|
|
||||||
extension: string
|
|
||||||
}[] = [
|
|
||||||
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
|
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
|
||||||
{ value: "png", label: "PNG Image", extension: ".png" },
|
{ value: "png", label: "PNG Image", extension: ".png" },
|
||||||
{ value: "svg", label: "SVG Image", extension: ".svg" },
|
{ value: "svg", label: "SVG Image", extension: ".svg" },
|
||||||
]
|
];
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
onSave: (filename: string, format: ExportFormat) => void
|
onSave: (filename: string, format: ExportFormat) => void;
|
||||||
defaultFilename: string
|
defaultFilename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveDialog({
|
export function SaveDialog({
|
||||||
@@ -43,29 +39,29 @@ export function SaveDialog({
|
|||||||
onSave,
|
onSave,
|
||||||
defaultFilename,
|
defaultFilename,
|
||||||
}: SaveDialogProps) {
|
}: SaveDialogProps) {
|
||||||
const [filename, setFilename] = useState(defaultFilename)
|
const [filename, setFilename] = useState(defaultFilename);
|
||||||
const [format, setFormat] = useState<ExportFormat>("drawio")
|
const [format, setFormat] = useState<ExportFormat>("drawio");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFilename(defaultFilename)
|
setFilename(defaultFilename);
|
||||||
}
|
}
|
||||||
}, [open, defaultFilename])
|
}, [open, defaultFilename]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const finalFilename = filename.trim() || defaultFilename
|
const finalFilename = filename.trim() || defaultFilename;
|
||||||
onSave(finalFilename, format)
|
onSave(finalFilename, format);
|
||||||
onOpenChange(false)
|
onOpenChange(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
handleSave()
|
handleSave();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -76,19 +72,13 @@ export function SaveDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Format</label>
|
<label className="text-sm font-medium">Format</label>
|
||||||
<Select
|
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
|
||||||
value={format}
|
|
||||||
onValueChange={(v) => setFormat(v as ExportFormat)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FORMAT_OPTIONS.map((opt) => (
|
{FORMAT_OPTIONS.map((opt) => (
|
||||||
<SelectItem
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
key={opt.value}
|
|
||||||
value={opt.value}
|
|
||||||
>
|
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -114,15 +104,12 @@ export function SaveDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave}>Save</Button>
|
<Button onClick={handleSave}>Save</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
|
||||||
|
|
||||||
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
||||||
const [accessCode, setAccessCode] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const storedCode =
|
|
||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
|
||||||
setAccessCode(storedCode)
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Settings</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure your access settings.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
|
||||||
Access Code
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={accessCode}
|
|
||||||
onChange={(e) => setAccessCode(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Enter access code"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
|
||||||
Required if the server has enabled access control.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave}>Save</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:brightness-75",
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function ResizablePanelGroup({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
|
||||||
return (
|
|
||||||
<ResizablePrimitive.PanelGroup
|
|
||||||
data-slot="resizable-panel-group"
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResizablePanel({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
|
||||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResizableHandle({
|
|
||||||
withHandle,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
|
||||||
withHandle?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
|
||||||
data-slot="resizable-handle"
|
|
||||||
className={cn(
|
|
||||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{withHandle && (
|
|
||||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
|
||||||
<GripVerticalIcon className="size-2.5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
|
||||||
@@ -18,7 +18,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 !overflow-x-hidden"
|
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|||||||
@@ -1,90 +1,85 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import type React from "react"
|
import React, { createContext, useContext, useRef, useState } from "react";
|
||||||
import { createContext, useContext, useRef, useState } from "react"
|
import type { DrawIoEmbedRef } from "react-drawio";
|
||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import { extractDiagramXML } from "../lib/utils";
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog";
|
||||||
import { extractDiagramXML } from "../lib/utils"
|
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string;
|
||||||
latestSvg: string
|
latestSvg: string;
|
||||||
diagramHistory: { svg: string; xml: string }[]
|
diagramHistory: { svg: string; xml: string }[];
|
||||||
loadDiagram: (chart: string) => void
|
loadDiagram: (chart: string) => void;
|
||||||
handleExport: () => void
|
handleExport: () => void;
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void;
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
resolverRef: React.Ref<((value: string) => void) | null>;
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
||||||
handleDiagramExport: (data: any) => void
|
handleDiagramExport: (data: any) => void;
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void;
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (filename: string, format: ExportFormat) => void;
|
||||||
filename: string,
|
|
||||||
format: ExportFormat,
|
|
||||||
sessionId?: string,
|
|
||||||
) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [chartXML, setChartXML] = useState<string>("")
|
const [chartXML, setChartXML] = useState<string>("");
|
||||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
const [latestSvg, setLatestSvg] = useState<string>("");
|
||||||
const [diagramHistory, setDiagramHistory] = useState<
|
const [diagramHistory, setDiagramHistory] = useState<
|
||||||
{ svg: string; xml: string }[]
|
{ svg: string; xml: string }[]
|
||||||
>([])
|
>([]);
|
||||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null);
|
||||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
const resolverRef = useRef<((value: string) => void) | null>(null);
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
const expectHistoryExportRef = useRef<boolean>(false);
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
const saveResolverRef = useRef<{
|
const saveResolverRef = useRef<{
|
||||||
resolver: ((data: string) => void) | null
|
resolver: ((data: string) => void) | null;
|
||||||
format: ExportFormat | null
|
format: ExportFormat | null;
|
||||||
}>({ resolver: null, format: null })
|
}>({ resolver: null, format: null });
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
// Mark that this export should be saved to history
|
// Mark that this export should be saved to history
|
||||||
expectHistoryExportRef.current = true
|
expectHistoryExportRef.current = true;
|
||||||
drawioRef.current.exportDiagram({
|
drawioRef.current.exportDiagram({
|
||||||
format: "xmlsvg",
|
format: "xmlsvg",
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExportWithoutHistory = () => {
|
const handleExportWithoutHistory = () => {
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
// Export without saving to history (for edit_diagram fetching current state)
|
// Export without saving to history (for edit_diagram fetching current state)
|
||||||
drawioRef.current.exportDiagram({
|
drawioRef.current.exportDiagram({
|
||||||
format: "xmlsvg",
|
format: "xmlsvg",
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadDiagram = (chart: string) => {
|
const loadDiagram = (chart: string) => {
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
drawioRef.current.load({
|
drawioRef.current.load({
|
||||||
xml: chart,
|
xml: chart,
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDiagramExport = (data: any) => {
|
const handleDiagramExport = (data: any) => {
|
||||||
// Handle save to file if requested (process raw data before extraction)
|
// Handle save to file if requested (process raw data before extraction)
|
||||||
if (saveResolverRef.current.resolver) {
|
if (saveResolverRef.current.resolver) {
|
||||||
const format = saveResolverRef.current.format
|
const format = saveResolverRef.current.format;
|
||||||
saveResolverRef.current.resolver(data.data)
|
saveResolverRef.current.resolver(data.data);
|
||||||
saveResolverRef.current = { resolver: null, format: null }
|
saveResolverRef.current = { resolver: null, format: null };
|
||||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||||
if (format === "png" || format === "svg") {
|
if (format === "png" || format === "svg") {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractedXML = extractDiagramXML(data.data)
|
const extractedXML = extractDiagramXML(data.data);
|
||||||
setChartXML(extractedXML)
|
setChartXML(extractedXML);
|
||||||
setLatestSvg(data.data)
|
setLatestSvg(data.data);
|
||||||
|
|
||||||
// Only add to history if this was a user-initiated export
|
// Only add to history if this was a user-initiated export
|
||||||
if (expectHistoryExportRef.current) {
|
if (expectHistoryExportRef.current) {
|
||||||
@@ -94,117 +89,90 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
svg: data.data,
|
svg: data.data,
|
||||||
xml: extractedXML,
|
xml: extractedXML,
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
expectHistoryExportRef.current = false
|
expectHistoryExportRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolverRef.current) {
|
if (resolverRef.current) {
|
||||||
resolverRef.current(extractedXML)
|
resolverRef.current(extractedXML);
|
||||||
resolverRef.current = null
|
resolverRef.current = null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const clearDiagram = () => {
|
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>`
|
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)
|
loadDiagram(emptyDiagram);
|
||||||
setChartXML(emptyDiagram)
|
setChartXML(emptyDiagram);
|
||||||
setLatestSvg("")
|
setLatestSvg("");
|
||||||
setDiagramHistory([])
|
setDiagramHistory([]);
|
||||||
}
|
};
|
||||||
|
|
||||||
const saveDiagramToFile = (
|
const saveDiagramToFile = (filename: string, format: ExportFormat) => {
|
||||||
filename: string,
|
|
||||||
format: ExportFormat,
|
|
||||||
sessionId?: string,
|
|
||||||
) => {
|
|
||||||
if (!drawioRef.current) {
|
if (!drawioRef.current) {
|
||||||
console.warn("Draw.io editor not ready")
|
console.warn("Draw.io editor not ready");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// 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
|
// Set up the resolver before triggering export
|
||||||
saveResolverRef.current = {
|
saveResolverRef.current = {
|
||||||
resolver: (exportData: string) => {
|
resolver: (exportData: string) => {
|
||||||
let fileContent: string | Blob
|
let fileContent: string | Blob;
|
||||||
let mimeType: string
|
let mimeType: string;
|
||||||
let extension: string
|
let extension: string;
|
||||||
|
|
||||||
if (format === "drawio") {
|
if (format === "drawio") {
|
||||||
// Extract XML from SVG for .drawio format
|
// Extract XML from SVG for .drawio format
|
||||||
const xml = extractDiagramXML(exportData)
|
const xml = extractDiagramXML(exportData);
|
||||||
let xmlContent = xml
|
let xmlContent = xml;
|
||||||
if (!xml.includes("<mxfile")) {
|
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
|
fileContent = xmlContent;
|
||||||
mimeType = "application/xml"
|
mimeType = "application/xml";
|
||||||
extension = ".drawio"
|
extension = ".drawio";
|
||||||
} else if (format === "png") {
|
} else if (format === "png") {
|
||||||
// PNG data comes as base64 data URL
|
// PNG data comes as base64 data URL
|
||||||
fileContent = exportData
|
fileContent = exportData;
|
||||||
mimeType = "image/png"
|
mimeType = "image/png";
|
||||||
extension = ".png"
|
extension = ".png";
|
||||||
} else {
|
} else {
|
||||||
// SVG format
|
// SVG format
|
||||||
fileContent = exportData
|
fileContent = exportData;
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml";
|
||||||
extension = ".svg"
|
extension = ".svg";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (flags the trace)
|
|
||||||
logSaveToLangfuse(filename, format, sessionId)
|
|
||||||
|
|
||||||
// Handle download
|
// Handle download
|
||||||
let url: string
|
let url: string;
|
||||||
if (
|
if (typeof fileContent === "string" && fileContent.startsWith("data:")) {
|
||||||
typeof fileContent === "string" &&
|
|
||||||
fileContent.startsWith("data:")
|
|
||||||
) {
|
|
||||||
// Already a data URL (PNG)
|
// Already a data URL (PNG)
|
||||||
url = fileContent
|
url = fileContent;
|
||||||
} else {
|
} else {
|
||||||
const blob = new Blob([fileContent], { type: mimeType })
|
const blob = new Blob([fileContent], { type: mimeType });
|
||||||
url = URL.createObjectURL(blob)
|
url = URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a");
|
||||||
a.href = url
|
a.href = url;
|
||||||
a.download = `${filename}${extension}`
|
a.download = `${filename}${extension}`;
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a);
|
||||||
a.click()
|
a.click();
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a);
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
// Delay URL revocation to ensure download completes
|
||||||
if (!url.startsWith("data:")) {
|
if (!url.startsWith("data:")) {
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
format,
|
format,
|
||||||
}
|
};
|
||||||
|
|
||||||
// Export diagram - callback will be handled in handleDiagramExport
|
// 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,
|
|
||||||
) => {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DiagramContext.Provider
|
<DiagramContext.Provider
|
||||||
@@ -224,13 +192,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DiagramContext.Provider>
|
</DiagramContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDiagram() {
|
export function useDiagram() {
|
||||||
const context = useContext(DiagramContext)
|
const context = useContext(DiagramContext);
|
||||||
if (context === undefined) {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
# AI Provider Configuration
|
|
||||||
|
|
||||||
This guide explains how to configure different AI model providers for next-ai-draw-io.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env.local`
|
|
||||||
2. Set your API key for your chosen provider
|
|
||||||
3. Set `AI_MODEL` to your desired model
|
|
||||||
4. Run `npm run dev`
|
|
||||||
|
|
||||||
## Supported Providers
|
|
||||||
|
|
||||||
### Google Gemini
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint (for OpenAI-compatible services):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### DeepSeek
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_BASE_URL=https://your-resource.openai.azure.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### AWS Bedrock
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AWS_REGION=us-west-2
|
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
|
||||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: On AWS (Amplify, Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
|
||||||
|
|
||||||
### OpenRouter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_API_KEY=your_api_key
|
|
||||||
AI_MODEL=anthropic/claude-sonnet-4
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama (Local)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=ollama
|
|
||||||
AI_MODEL=llama3.2
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Detection
|
|
||||||
|
|
||||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
|
||||||
|
|
||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, azure, bedrock, openrouter, ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
## Model Capability Requirements
|
|
||||||
|
|
||||||
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
|
||||||
|
|
||||||
**Recommended models**:
|
|
||||||
|
|
||||||
- Claude Sonnet 4.5 / Opus 4.5
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features
|
|
||||||
- **Budget-friendly**: DeepSeek offers competitive pricing
|
|
||||||
- **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware)
|
|
||||||
- **Flexibility**: OpenRouter provides access to many models through a single API
|
|
||||||
@@ -41,12 +41,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# DeepSeek Configuration
|
# DeepSeek Configuration
|
||||||
# DEEPSEEK_API_KEY=sk-...
|
# DEEPSEEK_API_KEY=sk-...
|
||||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Access Control (Optional)
|
|
||||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Register globally so AI SDK's telemetry also uses this processor
|
|
||||||
tracerProvider.register()
|
|
||||||
}
|
|
||||||
@@ -1,88 +1,88 @@
|
|||||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||||
import { azure, createAzure } from "@ai-sdk/azure"
|
import { openai, createOpenAI } from '@ai-sdk/openai';
|
||||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||||
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
import { azure, createAzure } from '@ai-sdk/azure';
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { ollama, createOllama } from 'ollama-ai-provider-v2';
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
import { deepseek, createDeepSeek } from '@ai-sdk/deepseek';
|
||||||
|
|
||||||
export type ProviderName =
|
export type ProviderName =
|
||||||
| "bedrock"
|
| 'bedrock'
|
||||||
| "openai"
|
| 'openai'
|
||||||
| "anthropic"
|
| 'anthropic'
|
||||||
| "google"
|
| 'google'
|
||||||
| "azure"
|
| 'azure'
|
||||||
| "ollama"
|
| 'ollama'
|
||||||
| "openrouter"
|
| 'openrouter'
|
||||||
| "deepseek"
|
| 'deepseek';
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any;
|
||||||
providerOptions?: any
|
providerOptions?: any;
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>;
|
||||||
modelId: string
|
modelId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
const BEDROCK_ANTHROPIC_BETA = {
|
const BEDROCK_ANTHROPIC_BETA = {
|
||||||
bedrock: {
|
bedrock: {
|
||||||
anthropicBeta: ["fine-grained-tool-streaming-2025-05-14"],
|
anthropicBeta: ['fine-grained-tool-streaming-2025-05-14'],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// Direct Anthropic API headers for beta features
|
// Direct Anthropic API headers for beta features
|
||||||
const ANTHROPIC_BETA_HEADERS = {
|
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
|
// Map of provider to required environment variable
|
||||||
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||||
openai: "OPENAI_API_KEY",
|
openai: 'OPENAI_API_KEY',
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||||
azure: "AZURE_API_KEY",
|
azure: 'AZURE_API_KEY',
|
||||||
ollama: null, // No credentials needed for local Ollama
|
ollama: null, // No credentials needed for local Ollama
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: 'OPENROUTER_API_KEY',
|
||||||
deepseek: "DEEPSEEK_API_KEY",
|
deepseek: 'DEEPSEEK_API_KEY',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-detect provider based on available API keys
|
* Auto-detect provider based on available API keys
|
||||||
* Returns the provider if exactly one is configured, otherwise null
|
* Returns the provider if exactly one is configured, otherwise null
|
||||||
*/
|
*/
|
||||||
function detectProvider(): ProviderName | null {
|
function detectProvider(): ProviderName | null {
|
||||||
const configuredProviders: ProviderName[] = []
|
const configuredProviders: ProviderName[] = [];
|
||||||
|
|
||||||
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
|
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
|
||||||
if (envVar === null) {
|
if (envVar === null) {
|
||||||
// Skip ollama - it doesn't require credentials
|
// Skip ollama - it doesn't require credentials
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (process.env[envVar]) {
|
if (process.env[envVar]) {
|
||||||
configuredProviders.push(provider as ProviderName)
|
configuredProviders.push(provider as ProviderName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configuredProviders.length === 1) {
|
if (configuredProviders.length === 1) {
|
||||||
return configuredProviders[0]
|
return configuredProviders[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that required API keys are present for the selected provider
|
* Validate that required API keys are present for the selected provider
|
||||||
*/
|
*/
|
||||||
function validateProviderCredentials(provider: ProviderName): void {
|
function validateProviderCredentials(provider: ProviderName): void {
|
||||||
const requiredVar = PROVIDER_ENV_VARS[provider]
|
const requiredVar = PROVIDER_ENV_VARS[provider];
|
||||||
if (requiredVar && !process.env[requiredVar]) {
|
if (requiredVar && !process.env[requiredVar]) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
`${requiredVar} environment variable is required for ${provider} provider. ` +
|
||||||
`Please set it in your .env.local file.`,
|
`Please set it in your .env.local file.`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,28 +106,28 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
*/
|
*/
|
||||||
export function getAIModel(): ModelConfig {
|
export function getAIModel(): ModelConfig {
|
||||||
const modelId = process.env.AI_MODEL
|
const modelId = process.env.AI_MODEL;
|
||||||
|
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`,
|
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine provider: explicit config > auto-detect > error
|
// Determine provider: explicit config > auto-detect > error
|
||||||
let provider: ProviderName
|
let provider: ProviderName;
|
||||||
if (process.env.AI_PROVIDER) {
|
if (process.env.AI_PROVIDER) {
|
||||||
provider = process.env.AI_PROVIDER as ProviderName
|
provider = process.env.AI_PROVIDER as ProviderName;
|
||||||
} else {
|
} else {
|
||||||
const detected = detectProvider()
|
const detected = detectProvider();
|
||||||
if (detected) {
|
if (detected) {
|
||||||
provider = detected
|
provider = detected;
|
||||||
console.log(`[AI Provider] Auto-detected provider: ${provider}`)
|
console.log(`[AI Provider] Auto-detected provider: ${provider}`);
|
||||||
} else {
|
} else {
|
||||||
// List configured providers for better error message
|
// List configured providers for better error message
|
||||||
const configured = Object.entries(PROVIDER_ENV_VARS)
|
const configured = Object.entries(PROVIDER_ENV_VARS)
|
||||||
.filter(([, envVar]) => envVar && process.env[envVar as string])
|
.filter(([, envVar]) => envVar && process.env[envVar as string])
|
||||||
.map(([p]) => p)
|
.map(([p]) => p);
|
||||||
|
|
||||||
if (configured.length === 0) {
|
if (configured.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -139,131 +139,125 @@ export function getAIModel(): ModelConfig {
|
|||||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
`Or set AI_PROVIDER=ollama for local Ollama.`
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Multiple AI providers configured (${configured.join(", ")}). ` +
|
`Multiple AI providers configured (${configured.join(', ')}). ` +
|
||||||
`Please set AI_PROVIDER to specify which one to use.`,
|
`Please set AI_PROVIDER to specify which one to use.`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider credentials
|
// Validate provider credentials
|
||||||
validateProviderCredentials(provider)
|
validateProviderCredentials(provider);
|
||||||
|
|
||||||
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
|
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`);
|
||||||
|
|
||||||
let model: any
|
let model: any;
|
||||||
let providerOptions: any
|
let providerOptions: any = undefined;
|
||||||
let headers: Record<string, string> | undefined
|
let headers: Record<string, string> | undefined = undefined;
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "bedrock": {
|
case 'bedrock': {
|
||||||
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
|
// 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
|
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
||||||
const bedrockProvider = createAmazonBedrock({
|
const bedrockProvider = createAmazonBedrock({
|
||||||
region: process.env.AWS_REGION || "us-west-2",
|
region: process.env.AWS_REGION || 'us-west-2',
|
||||||
credentialProvider: fromNodeProviderChain(),
|
credentialProvider: fromNodeProviderChain(),
|
||||||
})
|
});
|
||||||
model = bedrockProvider(modelId)
|
model = bedrockProvider(modelId);
|
||||||
// Add Anthropic beta options if using Claude models via Bedrock
|
// Add Anthropic beta options if using Claude models via Bedrock
|
||||||
if (modelId.includes("anthropic.claude")) {
|
if (modelId.includes('anthropic.claude')) {
|
||||||
providerOptions = BEDROCK_ANTHROPIC_BETA
|
providerOptions = BEDROCK_ANTHROPIC_BETA;
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "openai":
|
case 'openai':
|
||||||
if (process.env.OPENAI_BASE_URL) {
|
if (process.env.OPENAI_BASE_URL) {
|
||||||
const customOpenAI = createOpenAI({
|
const customOpenAI = createOpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
baseURL: process.env.OPENAI_BASE_URL,
|
baseURL: process.env.OPENAI_BASE_URL,
|
||||||
})
|
});
|
||||||
model = customOpenAI.chat(modelId)
|
model = customOpenAI.chat(modelId);
|
||||||
} else {
|
} else {
|
||||||
model = openai(modelId)
|
model = openai(modelId);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "anthropic": {
|
case 'anthropic':
|
||||||
const customProvider = createAnthropic({
|
const customProvider = createAnthropic({
|
||||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
baseURL:
|
baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1',
|
||||||
process.env.ANTHROPIC_BASE_URL ||
|
|
||||||
"https://api.anthropic.com/v1",
|
|
||||||
headers: ANTHROPIC_BETA_HEADERS,
|
headers: ANTHROPIC_BETA_HEADERS,
|
||||||
})
|
});
|
||||||
model = customProvider(modelId)
|
model = customProvider(modelId);
|
||||||
// Add beta headers for fine-grained tool streaming
|
// Add beta headers for fine-grained tool streaming
|
||||||
headers = ANTHROPIC_BETA_HEADERS
|
headers = ANTHROPIC_BETA_HEADERS;
|
||||||
break
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
case "google":
|
case 'google':
|
||||||
if (process.env.GOOGLE_BASE_URL) {
|
if (process.env.GOOGLE_BASE_URL) {
|
||||||
const customGoogle = createGoogleGenerativeAI({
|
const customGoogle = createGoogleGenerativeAI({
|
||||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
||||||
baseURL: process.env.GOOGLE_BASE_URL,
|
baseURL: process.env.GOOGLE_BASE_URL,
|
||||||
})
|
});
|
||||||
model = customGoogle(modelId)
|
model = customGoogle(modelId);
|
||||||
} else {
|
} else {
|
||||||
model = google(modelId)
|
model = google(modelId);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "azure":
|
case 'azure':
|
||||||
if (process.env.AZURE_BASE_URL) {
|
if (process.env.AZURE_BASE_URL) {
|
||||||
const customAzure = createAzure({
|
const customAzure = createAzure({
|
||||||
apiKey: process.env.AZURE_API_KEY,
|
apiKey: process.env.AZURE_API_KEY,
|
||||||
baseURL: process.env.AZURE_BASE_URL,
|
baseURL: process.env.AZURE_BASE_URL,
|
||||||
})
|
});
|
||||||
model = customAzure(modelId)
|
model = customAzure(modelId);
|
||||||
} else {
|
} else {
|
||||||
model = azure(modelId)
|
model = azure(modelId);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "ollama":
|
case 'ollama':
|
||||||
if (process.env.OLLAMA_BASE_URL) {
|
if (process.env.OLLAMA_BASE_URL) {
|
||||||
const customOllama = createOllama({
|
const customOllama = createOllama({
|
||||||
baseURL: process.env.OLLAMA_BASE_URL,
|
baseURL: process.env.OLLAMA_BASE_URL,
|
||||||
})
|
});
|
||||||
model = customOllama(modelId)
|
model = customOllama(modelId);
|
||||||
} else {
|
} else {
|
||||||
model = ollama(modelId)
|
model = ollama(modelId);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "openrouter": {
|
case 'openrouter':
|
||||||
const openrouter = createOpenRouter({
|
const openrouter = createOpenRouter({
|
||||||
apiKey: process.env.OPENROUTER_API_KEY,
|
apiKey: process.env.OPENROUTER_API_KEY,
|
||||||
...(process.env.OPENROUTER_BASE_URL && {
|
...(process.env.OPENROUTER_BASE_URL && { baseURL: process.env.OPENROUTER_BASE_URL }),
|
||||||
baseURL: process.env.OPENROUTER_BASE_URL,
|
});
|
||||||
}),
|
model = openrouter(modelId);
|
||||||
})
|
break;
|
||||||
model = openrouter(modelId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "deepseek":
|
case 'deepseek':
|
||||||
if (process.env.DEEPSEEK_BASE_URL) {
|
if (process.env.DEEPSEEK_BASE_URL) {
|
||||||
const customDeepSeek = createDeepSeek({
|
const customDeepSeek = createDeepSeek({
|
||||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||||
baseURL: process.env.DEEPSEEK_BASE_URL,
|
baseURL: process.env.DEEPSEEK_BASE_URL,
|
||||||
})
|
});
|
||||||
model = customDeepSeek(modelId)
|
model = customDeepSeek(modelId);
|
||||||
} else {
|
} else {
|
||||||
model = deepseek(modelId)
|
model = deepseek(modelId);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { model, providerOptions, headers, modelId }
|
return { model, providerOptions, headers, modelId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
export interface CachedResponse {
|
export interface CachedResponse {
|
||||||
promptText: string
|
promptText: string;
|
||||||
hasImage: boolean
|
hasImage: boolean;
|
||||||
xml: string
|
xml: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||||
{
|
{
|
||||||
promptText:
|
promptText: "Give me a **animated connector** diagram of transformer's architecture",
|
||||||
"Give me a **animated connector** diagram of transformer's architecture",
|
|
||||||
hasImage: false,
|
hasImage: false,
|
||||||
xml: `<root>
|
xml: `<root>
|
||||||
<mxCell id="0"/>
|
<mxCell id="0"/>
|
||||||
@@ -546,16 +545,13 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
|||||||
|
|
||||||
</root>`,
|
</root>`,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
export function findCachedResponse(
|
export function findCachedResponse(
|
||||||
promptText: string,
|
promptText: string,
|
||||||
hasImage: boolean,
|
hasImage: boolean
|
||||||
): CachedResponse | undefined {
|
): CachedResponse | undefined {
|
||||||
return CACHED_EXAMPLE_RESPONSES.find(
|
return CACHED_EXAMPLE_RESPONSES.find(
|
||||||
(c) =>
|
(c) => c.promptText === promptText && c.hasImage === hasImage && c.xml !== ''
|
||||||
c.promptText === promptText &&
|
);
|
||||||
c.hasImage === hasImage &&
|
|
||||||
c.xml !== "",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
107
lib/langfuse.ts
107
lib/langfuse.ts
@@ -1,107 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
export function getLangfuseClient(): LangfuseClient | 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return langfuseClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Langfuse is configured
|
|
||||||
export function isLangfuseEnabled(): boolean {
|
|
||||||
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
|
|
||||||
}) {
|
|
||||||
if (!isLangfuseEnabled()) return
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}) {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap a handler with Langfuse observe
|
|
||||||
export function wrapWithObserve<T>(
|
|
||||||
handler: (req: Request) => Promise<T>,
|
|
||||||
): (req: Request) => Promise<T> {
|
|
||||||
if (!isLangfuseEnabled()) {
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
|
|
||||||
return observe(handler, { name: "chat", endOnExit: false })
|
|
||||||
}
|
|
||||||
@@ -9,20 +9,6 @@ You are an expert diagram creation assistant specializing in draw.io XML generat
|
|||||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
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.
|
You can see the image that user uploaded.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
You can read and modify diagrams by generating draw.io XML code through tool calls.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
You utilize the following tools:
|
You utilize the following tools:
|
||||||
---Tool1---
|
---Tool1---
|
||||||
tool name: display_diagram
|
tool name: display_diagram
|
||||||
@@ -119,14 +105,46 @@ Common styles:
|
|||||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||||
`
|
`;
|
||||||
|
|
||||||
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
|
// Extended system prompt (~4000+ tokens) - for models with 4000 token cache minimum
|
||||||
const EXTENDED_ADDITIONS = `
|
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.
|
||||||
|
|
||||||
## Extended Tool Reference
|
## Available Tools
|
||||||
|
|
||||||
### display_diagram Details
|
### 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.
|
||||||
|
|
||||||
**VALIDATION RULES** (XML will be rejected if violated):
|
**VALIDATION RULES** (XML will be rejected if violated):
|
||||||
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
|
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
|
||||||
@@ -159,7 +177,13 @@ const EXTENDED_ADDITIONS = `
|
|||||||
</root>
|
</root>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### edit_diagram Details
|
**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.
|
||||||
|
|
||||||
**CRITICAL RULES:**
|
**CRITICAL RULES:**
|
||||||
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
|
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
|
||||||
@@ -181,6 +205,56 @@ const EXTENDED_ADDITIONS = `
|
|||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
## 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
|
## edit_diagram Best Practices
|
||||||
|
|
||||||
### Core Principle: Unique & Precise Patterns
|
### Core Principle: Unique & Precise Patterns
|
||||||
@@ -192,11 +266,13 @@ Your search pattern MUST uniquely identify exactly ONE location in the XML. Befo
|
|||||||
### Pattern Construction Rules
|
### Pattern Construction Rules
|
||||||
|
|
||||||
**Rule 1: Always include the element's id attribute**
|
**Rule 1: Always include the element's id attribute**
|
||||||
|
The id is the most reliable way to target a specific element:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
|
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**Rule 2: Include complete XML elements when possible**
|
**Rule 2: Include complete XML elements when possible**
|
||||||
|
For reliability, include the full mxCell with its mxGeometry child:
|
||||||
\`\`\`json
|
\`\`\`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>",
|
"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>",
|
||||||
@@ -205,13 +281,49 @@ Your search pattern MUST uniquely identify exactly ONE location in the XML. Befo
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**Rule 3: Preserve exact whitespace and formatting**
|
**Rule 3: Preserve exact whitespace and formatting**
|
||||||
Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order.
|
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
|
||||||
|
|
||||||
### Good vs Bad Patterns
|
### Good vs Bad Patterns
|
||||||
|
|
||||||
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements
|
**BAD - Too vague, matches multiple elements:**
|
||||||
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
|
\`\`\`json
|
||||||
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
|
{"search": "value=\\"Label\\"", "replace": "value=\\"New Label\\""}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**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\\""}
|
||||||
|
]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Error Recovery
|
### Error Recovery
|
||||||
If edit_diagram fails with "pattern not found":
|
If edit_diagram fails with "pattern not found":
|
||||||
@@ -220,42 +332,47 @@ If edit_diagram fails with "pattern not found":
|
|||||||
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
|
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
|
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
|
||||||
|
|
||||||
## Common Style Properties
|
### 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
|
||||||
|
|
||||||
### Shape Styles
|
## Draw.io XML Structure Reference
|
||||||
- rounded=1, fillColor=#hex, strokeColor=#hex, strokeWidth=2
|
|
||||||
- whiteSpace=wrap, html=1, opacity=50, shadow=1, glass=1
|
|
||||||
|
|
||||||
### Edge/Connector Styles
|
### Basic Structure
|
||||||
- endArrow=classic/block/open/oval/diamond/none, startArrow=none/classic
|
\`\`\`xml
|
||||||
- curved=1, edgeStyle=orthogonalEdgeStyle, strokeWidth=2
|
<mxGraphModel>
|
||||||
- dashed=1, dashPattern=3 3, flowAnimation=1
|
<root>
|
||||||
|
<mxCell id="0"/>
|
||||||
|
<mxCell id="1" parent="0"/>
|
||||||
|
<!-- All other elements go here as siblings -->
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Text Styles
|
### Critical Structure Rules
|
||||||
- fontSize=14, fontStyle=1 (1=bold, 2=italic, 4=underline, 3=bold+italic)
|
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
|
||||||
- fontColor=#hex, align=center/left/right, verticalAlign=middle/top/bottom
|
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
|
||||||
|
|
||||||
## Common Shape Types
|
### 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>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Basic Shapes
|
### Connector (Edge) Example
|
||||||
- Rectangle: rounded=0;whiteSpace=wrap;html=1;
|
\`\`\`xml
|
||||||
- Rounded Rectangle: rounded=1;whiteSpace=wrap;html=1;
|
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||||
- Ellipse/Circle: ellipse;whiteSpace=wrap;html=1;aspect=fixed;
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
- Diamond: rhombus;whiteSpace=wrap;html=1;
|
</mxCell>
|
||||||
- Cylinder: shape=cylinder3;whiteSpace=wrap;html=1;
|
\`\`\`
|
||||||
|
|
||||||
### Flowchart Shapes
|
### Container/Group Example
|
||||||
- Process: rounded=1;whiteSpace=wrap;html=1;
|
|
||||||
- Decision: rhombus;whiteSpace=wrap;html=1;
|
|
||||||
- Start/End: ellipse;whiteSpace=wrap;html=1;
|
|
||||||
- Document: shape=document;whiteSpace=wrap;html=1;
|
|
||||||
- Database: shape=cylinder3;whiteSpace=wrap;html=1;
|
|
||||||
|
|
||||||
### Container Types
|
|
||||||
- Swimlane: swimlane;whiteSpace=wrap;html=1;
|
|
||||||
- Group Box: rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;
|
|
||||||
|
|
||||||
## Container/Group Example
|
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
<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"/>
|
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||||
@@ -265,6 +382,84 @@ If edit_diagram fails with "pattern not found":
|
|||||||
</mxCell>
|
</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:
|
||||||
|
\`\`\`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>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
## Example: Complete Flowchart
|
||||||
|
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
@@ -294,17 +489,16 @@ If edit_diagram fails with "pattern not found":
|
|||||||
</mxCell>
|
</mxCell>
|
||||||
</root>
|
</root>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`
|
|
||||||
|
|
||||||
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
Remember: Quality diagrams communicate clearly. Choose appropriate shapes, use consistent styling, and maintain proper spacing for professional results.
|
||||||
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
`;
|
||||||
|
|
||||||
// Model patterns that require extended prompt (4000 token cache minimum)
|
// Model patterns that require extended prompt (4000 token cache minimum)
|
||||||
// These patterns match Opus 4.5 and Haiku 4.5 model IDs
|
// These patterns match Opus 4.5 and Haiku 4.5 model IDs
|
||||||
const EXTENDED_PROMPT_MODEL_PATTERNS = [
|
const EXTENDED_PROMPT_MODEL_PATTERNS = [
|
||||||
"claude-opus-4-5", // Matches any Opus 4.5 variant
|
'claude-opus-4-5', // Matches any Opus 4.5 variant
|
||||||
"claude-haiku-4-5", // Matches any Haiku 4.5 variant
|
'claude-haiku-4-5', // Matches any Haiku 4.5 variant
|
||||||
]
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the appropriate system prompt based on the model ID
|
* Get the appropriate system prompt based on the model ID
|
||||||
@@ -313,25 +507,10 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [
|
|||||||
* @returns The system prompt string
|
* @returns The system prompt string
|
||||||
*/
|
*/
|
||||||
export function getSystemPrompt(modelId?: string): string {
|
export function getSystemPrompt(modelId?: string): string {
|
||||||
const modelName = modelId || "AI"
|
if (modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some(pattern => modelId.includes(pattern))) {
|
||||||
|
console.log(`[System Prompt] Using EXTENDED prompt for model: ${modelId}`);
|
||||||
let prompt: string
|
return EXTENDED_SYSTEM_PROMPT;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`);
|
||||||
return prompt.replace("{{MODEL_NAME}}", modelName)
|
return DEFAULT_SYSTEM_PROMPT;
|
||||||
}
|
}
|
||||||
|
|||||||
481
lib/utils.ts
481
lib/utils.ts
@@ -1,6 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import * as pako from "pako"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import * as pako from 'pako';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -12,45 +12,45 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
* @param indent - The indentation string (default: ' ')
|
* @param indent - The indentation string (default: ' ')
|
||||||
* @returns Formatted XML string
|
* @returns Formatted XML string
|
||||||
*/
|
*/
|
||||||
export function formatXML(xml: string, indent: string = " "): string {
|
export function formatXML(xml: string, indent: string = ' '): string {
|
||||||
let formatted = ""
|
let formatted = '';
|
||||||
let pad = 0
|
let pad = 0;
|
||||||
|
|
||||||
// Remove existing whitespace between tags
|
// Remove existing whitespace between tags
|
||||||
xml = xml.replace(/>\s*</g, "><").trim()
|
xml = xml.replace(/>\s*</g, '><').trim();
|
||||||
|
|
||||||
// Split on tags
|
// Split on tags
|
||||||
const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean)
|
const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean);
|
||||||
|
|
||||||
tags.forEach((node) => {
|
tags.forEach((node) => {
|
||||||
if (node.match(/^<\/\w/)) {
|
if (node.match(/^<\/\w/)) {
|
||||||
// Closing tag - decrease indent
|
// Closing tag - decrease indent
|
||||||
pad = Math.max(0, pad - 1)
|
pad = Math.max(0, pad - 1);
|
||||||
formatted += indent.repeat(pad) + node + "\n"
|
formatted += indent.repeat(pad) + node + '\n';
|
||||||
} else if (node.match(/^<\w[^>]*[^/]>.*$/)) {
|
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
|
||||||
// Opening tag
|
// Opening tag
|
||||||
formatted += indent.repeat(pad) + node
|
formatted += indent.repeat(pad) + node;
|
||||||
// Only add newline if next item is a tag
|
// Only add newline if next item is a tag
|
||||||
const nextIndex = tags.indexOf(node) + 1
|
const nextIndex = tags.indexOf(node) + 1;
|
||||||
if (nextIndex < tags.length && tags[nextIndex].startsWith("<")) {
|
if (nextIndex < tags.length && tags[nextIndex].startsWith('<')) {
|
||||||
formatted += "\n"
|
formatted += '\n';
|
||||||
if (!node.match(/^<\w[^>]*\/>$/)) {
|
if (!node.match(/^<\w[^>]*\/>$/)) {
|
||||||
pad++
|
pad++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (node.match(/^<\w[^>]*\/>$/)) {
|
} else if (node.match(/^<\w[^>]*\/>$/)) {
|
||||||
// Self-closing tag
|
// Self-closing tag
|
||||||
formatted += indent.repeat(pad) + node + "\n"
|
formatted += indent.repeat(pad) + node + '\n';
|
||||||
} else if (node.startsWith("<")) {
|
} else if (node.startsWith('<')) {
|
||||||
// Other tags (like <?xml)
|
// Other tags (like <?xml)
|
||||||
formatted += indent.repeat(pad) + node + "\n"
|
formatted += indent.repeat(pad) + node + '\n';
|
||||||
} else {
|
} else {
|
||||||
// Text content
|
// Text content
|
||||||
formatted += node
|
formatted += node;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return formatted.trim()
|
return formatted.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,24 +63,22 @@ export function formatXML(xml: string, indent: string = " "): string {
|
|||||||
export function convertToLegalXml(xmlString: string): string {
|
export function convertToLegalXml(xmlString: string): string {
|
||||||
// This regex will match either self-closing <mxCell .../> or a block element
|
// This regex will match either self-closing <mxCell .../> or a block element
|
||||||
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
|
||||||
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
|
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g;
|
||||||
let match: RegExpExecArray | null
|
let match: RegExpExecArray | null;
|
||||||
let result = "<root>\n"
|
let result = "<root>\n";
|
||||||
|
|
||||||
while ((match = regex.exec(xmlString)) !== null) {
|
while ((match = regex.exec(xmlString)) !== null) {
|
||||||
// match[0] contains the entire matched mxCell block
|
// match[0] contains the entire matched mxCell block
|
||||||
// Indent each line of the matched block for readability.
|
// Indent each line of the matched block for readability.
|
||||||
const formatted = match[0]
|
const formatted = match[0].split('\n').map(line => " " + line.trim()).join('\n');
|
||||||
.split("\n")
|
result += formatted + "\n";
|
||||||
.map((line) => " " + line.trim())
|
|
||||||
.join("\n")
|
|
||||||
result += formatted + "\n"
|
|
||||||
}
|
}
|
||||||
result += "</root>"
|
result += "</root>";
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace nodes in a Draw.io XML diagram
|
* Replace nodes in a Draw.io XML diagram
|
||||||
* @param currentXML - The original Draw.io XML string
|
* @param currentXML - The original Draw.io XML string
|
||||||
@@ -90,96 +88,91 @@ export function convertToLegalXml(xmlString: string): string {
|
|||||||
export function replaceNodes(currentXML: string, nodes: string): string {
|
export function replaceNodes(currentXML: string, nodes: string): string {
|
||||||
// Check for valid inputs
|
// Check for valid inputs
|
||||||
if (!currentXML || !nodes) {
|
if (!currentXML || !nodes) {
|
||||||
throw new Error("Both currentXML and nodes must be provided")
|
throw new Error("Both currentXML and nodes must be provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse the XML strings to create DOM objects
|
// Parse the XML strings to create DOM objects
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser();
|
||||||
const currentDoc = parser.parseFromString(currentXML, "text/xml")
|
const currentDoc = parser.parseFromString(currentXML, "text/xml");
|
||||||
|
|
||||||
// Handle nodes input - if it doesn't contain <root>, wrap it
|
// Handle nodes input - if it doesn't contain <root>, wrap it
|
||||||
let nodesString = nodes
|
let nodesString = nodes;
|
||||||
if (!nodes.includes("<root>")) {
|
if (!nodes.includes("<root>")) {
|
||||||
nodesString = `<root>${nodes}</root>`
|
nodesString = `<root>${nodes}</root>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodesDoc = parser.parseFromString(nodesString, "text/xml")
|
const nodesDoc = parser.parseFromString(nodesString, "text/xml");
|
||||||
|
|
||||||
// Find the root element in the current document
|
// Find the root element in the current document
|
||||||
let currentRoot = currentDoc.querySelector("mxGraphModel > root")
|
let currentRoot = currentDoc.querySelector("mxGraphModel > root");
|
||||||
if (!currentRoot) {
|
if (!currentRoot) {
|
||||||
// If no root element is found, create the proper structure
|
// If no root element is found, create the proper structure
|
||||||
const mxGraphModel =
|
const mxGraphModel = currentDoc.querySelector("mxGraphModel") ||
|
||||||
currentDoc.querySelector("mxGraphModel") ||
|
currentDoc.createElement("mxGraphModel");
|
||||||
currentDoc.createElement("mxGraphModel")
|
|
||||||
|
|
||||||
if (!currentDoc.contains(mxGraphModel)) {
|
if (!currentDoc.contains(mxGraphModel)) {
|
||||||
currentDoc.appendChild(mxGraphModel)
|
currentDoc.appendChild(mxGraphModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentRoot = currentDoc.createElement("root")
|
currentRoot = currentDoc.createElement("root");
|
||||||
mxGraphModel.appendChild(currentRoot)
|
mxGraphModel.appendChild(currentRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the root element in the nodes document
|
// Find the root element in the nodes document
|
||||||
const nodesRoot = nodesDoc.querySelector("root")
|
const nodesRoot = nodesDoc.querySelector("root");
|
||||||
if (!nodesRoot) {
|
if (!nodesRoot) {
|
||||||
throw new Error(
|
throw new Error("Invalid nodes: Could not find or create <root> element");
|
||||||
"Invalid nodes: Could not find or create <root> element",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all existing child elements from the current root
|
// Clear all existing child elements from the current root
|
||||||
while (currentRoot.firstChild) {
|
while (currentRoot.firstChild) {
|
||||||
currentRoot.removeChild(currentRoot.firstChild)
|
currentRoot.removeChild(currentRoot.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the base cells exist
|
// Ensure the base cells exist
|
||||||
const hasCell0 = Array.from(nodesRoot.childNodes).some(
|
const hasCell0 = Array.from(nodesRoot.childNodes).some(
|
||||||
(node) =>
|
node => node.nodeName === "mxCell" &&
|
||||||
node.nodeName === "mxCell" &&
|
(node as Element).getAttribute("id") === "0"
|
||||||
(node as Element).getAttribute("id") === "0",
|
);
|
||||||
)
|
|
||||||
|
|
||||||
const hasCell1 = Array.from(nodesRoot.childNodes).some(
|
const hasCell1 = Array.from(nodesRoot.childNodes).some(
|
||||||
(node) =>
|
node => node.nodeName === "mxCell" &&
|
||||||
node.nodeName === "mxCell" &&
|
(node as Element).getAttribute("id") === "1"
|
||||||
(node as Element).getAttribute("id") === "1",
|
);
|
||||||
)
|
|
||||||
|
|
||||||
// Copy all child nodes from the nodes root to the current root
|
// Copy all child nodes from the nodes root to the current root
|
||||||
Array.from(nodesRoot.childNodes).forEach((node) => {
|
Array.from(nodesRoot.childNodes).forEach(node => {
|
||||||
const importedNode = currentDoc.importNode(node, true)
|
const importedNode = currentDoc.importNode(node, true);
|
||||||
currentRoot.appendChild(importedNode)
|
currentRoot.appendChild(importedNode);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Add default cells if they don't exist
|
// Add default cells if they don't exist
|
||||||
if (!hasCell0) {
|
if (!hasCell0) {
|
||||||
const cell0 = currentDoc.createElement("mxCell")
|
const cell0 = currentDoc.createElement("mxCell");
|
||||||
cell0.setAttribute("id", "0")
|
cell0.setAttribute("id", "0");
|
||||||
currentRoot.insertBefore(cell0, currentRoot.firstChild)
|
currentRoot.insertBefore(cell0, currentRoot.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCell1) {
|
if (!hasCell1) {
|
||||||
const cell1 = currentDoc.createElement("mxCell")
|
const cell1 = currentDoc.createElement("mxCell");
|
||||||
cell1.setAttribute("id", "1")
|
cell1.setAttribute("id", "1");
|
||||||
cell1.setAttribute("parent", "0")
|
cell1.setAttribute("parent", "0");
|
||||||
|
|
||||||
// Insert after cell0 if possible
|
// Insert after cell0 if possible
|
||||||
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
const cell0 = currentRoot.querySelector('mxCell[id="0"]');
|
||||||
if (cell0?.nextSibling) {
|
if (cell0 && cell0.nextSibling) {
|
||||||
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
currentRoot.insertBefore(cell1, cell0.nextSibling);
|
||||||
} else {
|
} else {
|
||||||
currentRoot.appendChild(cell1)
|
currentRoot.appendChild(cell1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the modified DOM back to a string
|
// Convert the modified DOM back to a string
|
||||||
const serializer = new XMLSerializer()
|
const serializer = new XMLSerializer();
|
||||||
return serializer.serializeToString(currentDoc)
|
return serializer.serializeToString(currentDoc);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Error replacing nodes: ${error}`)
|
throw new Error(`Error replacing nodes: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,30 +181,30 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
* Used for attribute-order agnostic comparison
|
* Used for attribute-order agnostic comparison
|
||||||
*/
|
*/
|
||||||
function charCountDict(str: string): Map<string, number> {
|
function charCountDict(str: string): Map<string, number> {
|
||||||
const dict = new Map<string, number>()
|
const dict = new Map<string, number>();
|
||||||
for (const char of str) {
|
for (const char of str) {
|
||||||
dict.set(char, (dict.get(char) || 0) + 1)
|
dict.set(char, (dict.get(char) || 0) + 1);
|
||||||
}
|
}
|
||||||
return dict
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare two strings by character frequency (order-agnostic)
|
* Compare two strings by character frequency (order-agnostic)
|
||||||
*/
|
*/
|
||||||
function sameCharFrequency(a: string, b: string): boolean {
|
function sameCharFrequency(a: string, b: string): boolean {
|
||||||
const trimmedA = a.trim()
|
const trimmedA = a.trim();
|
||||||
const trimmedB = b.trim()
|
const trimmedB = b.trim();
|
||||||
if (trimmedA.length !== trimmedB.length) return false
|
if (trimmedA.length !== trimmedB.length) return false;
|
||||||
|
|
||||||
const dictA = charCountDict(trimmedA)
|
const dictA = charCountDict(trimmedA);
|
||||||
const dictB = charCountDict(trimmedB)
|
const dictB = charCountDict(trimmedB);
|
||||||
|
|
||||||
if (dictA.size !== dictB.size) return false
|
if (dictA.size !== dictB.size) return false;
|
||||||
|
|
||||||
for (const [char, count] of dictA) {
|
for (const [char, count] of dictA) {
|
||||||
if (dictB.get(char) !== count) return false
|
if (dictB.get(char) !== count) return false;
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,88 +215,77 @@ function sameCharFrequency(a: string, b: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function replaceXMLParts(
|
export function replaceXMLParts(
|
||||||
xmlContent: string,
|
xmlContent: string,
|
||||||
searchReplacePairs: Array<{ search: string; replace: string }>,
|
searchReplacePairs: Array<{ search: string; replace: string }>
|
||||||
): string {
|
): string {
|
||||||
// Format the XML first to ensure consistent line breaks
|
// Format the XML first to ensure consistent line breaks
|
||||||
let result = formatXML(xmlContent)
|
let result = formatXML(xmlContent);
|
||||||
let lastProcessedIndex = 0
|
let lastProcessedIndex = 0;
|
||||||
|
|
||||||
for (const { search, replace } of searchReplacePairs) {
|
for (const { search, replace } of searchReplacePairs) {
|
||||||
// Also format the search content for consistency
|
// Also format the search content for consistency
|
||||||
const formattedSearch = formatXML(search)
|
const formattedSearch = formatXML(search);
|
||||||
const searchLines = formattedSearch.split("\n")
|
const searchLines = formattedSearch.split('\n');
|
||||||
|
|
||||||
// Split into lines for exact line matching
|
// Split into lines for exact line matching
|
||||||
const resultLines = result.split("\n")
|
const resultLines = result.split('\n');
|
||||||
|
|
||||||
// Remove trailing empty line if exists (from the trailing \n in search content)
|
// Remove trailing empty line if exists (from the trailing \n in search content)
|
||||||
if (searchLines[searchLines.length - 1] === "") {
|
if (searchLines[searchLines.length - 1] === '') {
|
||||||
searchLines.pop()
|
searchLines.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the line number where lastProcessedIndex falls
|
// Find the line number where lastProcessedIndex falls
|
||||||
let startLineNum = 0
|
let startLineNum = 0;
|
||||||
let currentIndex = 0
|
let currentIndex = 0;
|
||||||
while (
|
while (currentIndex < lastProcessedIndex && startLineNum < resultLines.length) {
|
||||||
currentIndex < lastProcessedIndex &&
|
currentIndex += resultLines[startLineNum].length + 1; // +1 for \n
|
||||||
startLineNum < resultLines.length
|
startLineNum++;
|
||||||
) {
|
|
||||||
currentIndex += resultLines[startLineNum].length + 1 // +1 for \n
|
|
||||||
startLineNum++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find exact match starting from lastProcessedIndex
|
// Try to find exact match starting from lastProcessedIndex
|
||||||
let matchFound = false
|
let matchFound = false;
|
||||||
let matchStartLine = -1
|
let matchStartLine = -1;
|
||||||
let matchEndLine = -1
|
let matchEndLine = -1;
|
||||||
|
|
||||||
// First try: exact match
|
// First try: exact match
|
||||||
for (
|
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
|
||||||
let i = startLineNum;
|
let matches = true;
|
||||||
i <= resultLines.length - searchLines.length;
|
|
||||||
i++
|
|
||||||
) {
|
|
||||||
let matches = true
|
|
||||||
|
|
||||||
for (let j = 0; j < searchLines.length; j++) {
|
for (let j = 0; j < searchLines.length; j++) {
|
||||||
if (resultLines[i + j] !== searchLines[j]) {
|
if (resultLines[i + j] !== searchLines[j]) {
|
||||||
matches = false
|
matches = false;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
matchStartLine = i
|
matchStartLine = i;
|
||||||
matchEndLine = i + searchLines.length
|
matchEndLine = i + searchLines.length;
|
||||||
matchFound = true
|
matchFound = true;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second try: line-trimmed match (fallback)
|
// Second try: line-trimmed match (fallback)
|
||||||
if (!matchFound) {
|
if (!matchFound) {
|
||||||
for (
|
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
|
||||||
let i = startLineNum;
|
let matches = true;
|
||||||
i <= resultLines.length - searchLines.length;
|
|
||||||
i++
|
|
||||||
) {
|
|
||||||
let matches = true
|
|
||||||
|
|
||||||
for (let j = 0; j < searchLines.length; j++) {
|
for (let j = 0; j < searchLines.length; j++) {
|
||||||
const originalTrimmed = resultLines[i + j].trim()
|
const originalTrimmed = resultLines[i + j].trim();
|
||||||
const searchTrimmed = searchLines[j].trim()
|
const searchTrimmed = searchLines[j].trim();
|
||||||
|
|
||||||
if (originalTrimmed !== searchTrimmed) {
|
if (originalTrimmed !== searchTrimmed) {
|
||||||
matches = false
|
matches = false;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
matchStartLine = i
|
matchStartLine = i;
|
||||||
matchEndLine = i + searchLines.length
|
matchEndLine = i + searchLines.length;
|
||||||
matchFound = true
|
matchFound = true;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,46 +293,37 @@ export function replaceXMLParts(
|
|||||||
// Third try: substring match as last resort (for single-line XML)
|
// Third try: substring match as last resort (for single-line XML)
|
||||||
if (!matchFound) {
|
if (!matchFound) {
|
||||||
// Try to find as a substring in the entire content
|
// Try to find as a substring in the entire content
|
||||||
const searchStr = search.trim()
|
const searchStr = search.trim();
|
||||||
const resultStr = result
|
const resultStr = result;
|
||||||
const index = resultStr.indexOf(searchStr)
|
const index = resultStr.indexOf(searchStr);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
// Found as substring - replace it
|
// Found as substring - replace it
|
||||||
result =
|
result = resultStr.substring(0, index) + replace.trim() + resultStr.substring(index + searchStr.length);
|
||||||
resultStr.substring(0, index) +
|
|
||||||
replace.trim() +
|
|
||||||
resultStr.substring(index + searchStr.length)
|
|
||||||
// Re-format after substring replacement
|
// Re-format after substring replacement
|
||||||
result = formatXML(result)
|
result = formatXML(result);
|
||||||
continue // Skip the line-based replacement below
|
continue; // Skip the line-based replacement below
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fourth try: character frequency match (attribute-order agnostic)
|
// Fourth try: character frequency match (attribute-order agnostic)
|
||||||
// This handles cases where the model generates XML with different attribute order
|
// This handles cases where the model generates XML with different attribute order
|
||||||
if (!matchFound) {
|
if (!matchFound) {
|
||||||
for (
|
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) {
|
||||||
let i = startLineNum;
|
let matches = true;
|
||||||
i <= resultLines.length - searchLines.length;
|
|
||||||
i++
|
|
||||||
) {
|
|
||||||
let matches = true
|
|
||||||
|
|
||||||
for (let j = 0; j < searchLines.length; j++) {
|
for (let j = 0; j < searchLines.length; j++) {
|
||||||
if (
|
if (!sameCharFrequency(resultLines[i + j], searchLines[j])) {
|
||||||
!sameCharFrequency(resultLines[i + j], searchLines[j])
|
matches = false;
|
||||||
) {
|
break;
|
||||||
matches = false
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
matchStartLine = i
|
matchStartLine = i;
|
||||||
matchEndLine = i + searchLines.length
|
matchEndLine = i + searchLines.length;
|
||||||
matchFound = true
|
matchFound = true;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,76 +331,70 @@ export function replaceXMLParts(
|
|||||||
// Fifth try: Match by mxCell id attribute
|
// Fifth try: Match by mxCell id attribute
|
||||||
// Extract id from search pattern and find the element with that id
|
// Extract id from search pattern and find the element with that id
|
||||||
if (!matchFound) {
|
if (!matchFound) {
|
||||||
const idMatch = search.match(/id="([^"]+)"/)
|
const idMatch = search.match(/id="([^"]+)"/);
|
||||||
if (idMatch) {
|
if (idMatch) {
|
||||||
const searchId = idMatch[1]
|
const searchId = idMatch[1];
|
||||||
// Find lines that contain this id
|
// Find lines that contain this id
|
||||||
for (let i = startLineNum; i < resultLines.length; i++) {
|
for (let i = startLineNum; i < resultLines.length; i++) {
|
||||||
if (resultLines[i].includes(`id="${searchId}"`)) {
|
if (resultLines[i].includes(`id="${searchId}"`)) {
|
||||||
// Found the element with matching id
|
// Found the element with matching id
|
||||||
// Now find the extent of this element (it might span multiple lines)
|
// Now find the extent of this element (it might span multiple lines)
|
||||||
let endLine = i + 1
|
let endLine = i + 1;
|
||||||
const line = resultLines[i].trim()
|
const line = resultLines[i].trim();
|
||||||
|
|
||||||
// Check if it's a self-closing tag or has children
|
// Check if it's a self-closing tag or has children
|
||||||
if (!line.endsWith("/>")) {
|
if (!line.endsWith('/>')) {
|
||||||
// Find the closing tag or the end of the mxCell block
|
// Find the closing tag or the end of the mxCell block
|
||||||
let depth = 1
|
let depth = 1;
|
||||||
while (endLine < resultLines.length && depth > 0) {
|
while (endLine < resultLines.length && depth > 0) {
|
||||||
const currentLine = resultLines[endLine].trim()
|
const currentLine = resultLines[endLine].trim();
|
||||||
if (
|
if (currentLine.startsWith('<') && !currentLine.startsWith('</') && !currentLine.endsWith('/>')) {
|
||||||
currentLine.startsWith("<") &&
|
depth++;
|
||||||
!currentLine.startsWith("</") &&
|
} else if (currentLine.startsWith('</')) {
|
||||||
!currentLine.endsWith("/>")
|
depth--;
|
||||||
) {
|
|
||||||
depth++
|
|
||||||
} else if (currentLine.startsWith("</")) {
|
|
||||||
depth--
|
|
||||||
}
|
}
|
||||||
endLine++
|
endLine++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
matchStartLine = i
|
matchStartLine = i;
|
||||||
matchEndLine = endLine
|
matchEndLine = endLine;
|
||||||
matchFound = true
|
matchFound = true;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matchFound) {
|
if (!matchFound) {
|
||||||
throw new Error(
|
throw new Error(`Search pattern not found in the diagram. The pattern may not exist in the current structure.`);
|
||||||
`Search pattern not found in the diagram. The pattern may not exist in the current structure.`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the matched lines
|
// Replace the matched lines
|
||||||
const replaceLines = replace.split("\n")
|
const replaceLines = replace.split('\n');
|
||||||
|
|
||||||
// Remove trailing empty line if exists
|
// Remove trailing empty line if exists
|
||||||
if (replaceLines[replaceLines.length - 1] === "") {
|
if (replaceLines[replaceLines.length - 1] === '') {
|
||||||
replaceLines.pop()
|
replaceLines.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the replacement
|
// Perform the replacement
|
||||||
const newResultLines = [
|
const newResultLines = [
|
||||||
...resultLines.slice(0, matchStartLine),
|
...resultLines.slice(0, matchStartLine),
|
||||||
...replaceLines,
|
...replaceLines,
|
||||||
...resultLines.slice(matchEndLine),
|
...resultLines.slice(matchEndLine)
|
||||||
]
|
];
|
||||||
|
|
||||||
result = newResultLines.join("\n")
|
result = newResultLines.join('\n');
|
||||||
|
|
||||||
// Update lastProcessedIndex to the position after the replacement
|
// Update lastProcessedIndex to the position after the replacement
|
||||||
lastProcessedIndex = 0
|
lastProcessedIndex = 0;
|
||||||
for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
|
for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
|
||||||
lastProcessedIndex += newResultLines[i].length + 1
|
lastProcessedIndex += newResultLines[i].length + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -436,173 +403,167 @@ export function replaceXMLParts(
|
|||||||
* @returns null if valid, error message string if invalid
|
* @returns null if valid, error message string if invalid
|
||||||
*/
|
*/
|
||||||
export function validateMxCellStructure(xml: string): string | null {
|
export function validateMxCellStructure(xml: string): string | null {
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(xml, "text/xml")
|
const doc = parser.parseFromString(xml, "text/xml");
|
||||||
|
|
||||||
// Check for XML parsing errors (includes unescaped special characters)
|
// Check for XML parsing errors (includes unescaped special characters)
|
||||||
const parseError = doc.querySelector("parsererror")
|
const parseError = doc.querySelector('parsererror');
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.`
|
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all mxCell elements once for all validations
|
// Get all mxCell elements once for all validations
|
||||||
const allCells = doc.querySelectorAll("mxCell")
|
const allCells = doc.querySelectorAll('mxCell');
|
||||||
|
|
||||||
// Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents
|
// Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents
|
||||||
const cellIds = new Set<string>()
|
const cellIds = new Set<string>();
|
||||||
const duplicateIds: string[] = []
|
const duplicateIds: string[] = [];
|
||||||
const nestedCells: string[] = []
|
const nestedCells: string[] = [];
|
||||||
const orphanCells: string[] = []
|
const orphanCells: string[] = [];
|
||||||
const invalidParents: { id: string; parent: string }[] = []
|
const invalidParents: { id: string; parent: string }[] = [];
|
||||||
const edgesToValidate: {
|
const edgesToValidate: { id: string; source: string | null; target: string | null }[] = [];
|
||||||
id: string
|
|
||||||
source: string | null
|
|
||||||
target: string | null
|
|
||||||
}[] = []
|
|
||||||
|
|
||||||
allCells.forEach((cell) => {
|
allCells.forEach(cell => {
|
||||||
const id = cell.getAttribute("id")
|
const id = cell.getAttribute('id');
|
||||||
const parent = cell.getAttribute("parent")
|
const parent = cell.getAttribute('parent');
|
||||||
const isEdge = cell.getAttribute("edge") === "1"
|
const isEdge = cell.getAttribute('edge') === '1';
|
||||||
|
|
||||||
// Check for duplicate IDs
|
// Check for duplicate IDs
|
||||||
if (id) {
|
if (id) {
|
||||||
if (cellIds.has(id)) {
|
if (cellIds.has(id)) {
|
||||||
duplicateIds.push(id)
|
duplicateIds.push(id);
|
||||||
} else {
|
} else {
|
||||||
cellIds.add(id)
|
cellIds.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for nested mxCell (parent element is also mxCell)
|
// Check for nested mxCell (parent element is also mxCell)
|
||||||
if (cell.parentElement?.tagName === "mxCell") {
|
if (cell.parentElement?.tagName === 'mxCell') {
|
||||||
nestedCells.push(id || "unknown")
|
nestedCells.push(id || 'unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check parent attribute (skip root cell id="0")
|
// Check parent attribute (skip root cell id="0")
|
||||||
if (id !== "0") {
|
if (id !== '0') {
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
if (id) orphanCells.push(id)
|
if (id) orphanCells.push(id);
|
||||||
} else {
|
} else {
|
||||||
// Store for later validation (after all IDs collected)
|
// Store for later validation (after all IDs collected)
|
||||||
invalidParents.push({ id: id || "unknown", parent })
|
invalidParents.push({ id: id || 'unknown', parent });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect edges for connection validation
|
// Collect edges for connection validation
|
||||||
if (isEdge) {
|
if (isEdge) {
|
||||||
edgesToValidate.push({
|
edgesToValidate.push({
|
||||||
id: id || "unknown",
|
id: id || 'unknown',
|
||||||
source: cell.getAttribute("source"),
|
source: cell.getAttribute('source'),
|
||||||
target: cell.getAttribute("target"),
|
target: cell.getAttribute('target')
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Return errors in priority order
|
// Return errors in priority order
|
||||||
if (nestedCells.length > 0) {
|
if (nestedCells.length > 0) {
|
||||||
return `Invalid XML: Found nested mxCell elements (IDs: ${nestedCells.slice(0, 3).join(", ")}). All mxCell elements must be direct children of <root>, never nested inside other mxCell elements. Please regenerate the diagram with correct structure.`
|
return `Invalid XML: Found nested mxCell elements (IDs: ${nestedCells.slice(0, 3).join(', ')}). All mxCell elements must be direct children of <root>, never nested inside other mxCell elements. Please regenerate the diagram with correct structure.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (duplicateIds.length > 0) {
|
if (duplicateIds.length > 0) {
|
||||||
return `Invalid XML: Found duplicate cell IDs (${duplicateIds.slice(0, 3).join(", ")}). Each mxCell must have a unique ID. Please regenerate the diagram with unique IDs for all elements.`
|
return `Invalid XML: Found duplicate cell IDs (${duplicateIds.slice(0, 3).join(', ')}). Each mxCell must have a unique ID. Please regenerate the diagram with unique IDs for all elements.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orphanCells.length > 0) {
|
if (orphanCells.length > 0) {
|
||||||
return `Invalid XML: Found cells without parent attribute (IDs: ${orphanCells.slice(0, 3).join(", ")}). All mxCell elements (except id="0") must have a parent attribute. Please regenerate the diagram with proper parent references.`
|
return `Invalid XML: Found cells without parent attribute (IDs: ${orphanCells.slice(0, 3).join(', ')}). All mxCell elements (except id="0") must have a parent attribute. Please regenerate the diagram with proper parent references.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate parent references (now that all IDs are collected)
|
// Validate parent references (now that all IDs are collected)
|
||||||
const badParents = invalidParents.filter((p) => !cellIds.has(p.parent))
|
const badParents = invalidParents.filter(p => !cellIds.has(p.parent));
|
||||||
if (badParents.length > 0) {
|
if (badParents.length > 0) {
|
||||||
const details = badParents
|
const details = badParents.slice(0, 3).map(p => `${p.id} (parent: ${p.parent})`).join(', ');
|
||||||
.slice(0, 3)
|
return `Invalid XML: Found cells with invalid parent references (${details}). Parent IDs must reference existing cells. Please regenerate the diagram with valid parent references.`;
|
||||||
.map((p) => `${p.id} (parent: ${p.parent})`)
|
|
||||||
.join(", ")
|
|
||||||
return `Invalid XML: Found cells with invalid parent references (${details}). Parent IDs must reference existing cells. Please regenerate the diagram with valid parent references.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate edge connections
|
// Validate edge connections
|
||||||
const invalidConnections: string[] = []
|
const invalidConnections: string[] = [];
|
||||||
edgesToValidate.forEach((edge) => {
|
edgesToValidate.forEach(edge => {
|
||||||
if (edge.source && !cellIds.has(edge.source)) {
|
if (edge.source && !cellIds.has(edge.source)) {
|
||||||
invalidConnections.push(`${edge.id} (source: ${edge.source})`)
|
invalidConnections.push(`${edge.id} (source: ${edge.source})`);
|
||||||
}
|
}
|
||||||
if (edge.target && !cellIds.has(edge.target)) {
|
if (edge.target && !cellIds.has(edge.target)) {
|
||||||
invalidConnections.push(`${edge.id} (target: ${edge.target})`)
|
invalidConnections.push(`${edge.id} (target: ${edge.target})`);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
if (invalidConnections.length > 0) {
|
if (invalidConnections.length > 0) {
|
||||||
return `Invalid XML: Found edges with invalid source/target references (${invalidConnections.slice(0, 3).join(", ")}). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections.`
|
return `Invalid XML: Found edges with invalid source/target references (${invalidConnections.slice(0, 3).join(', ')}). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractDiagramXML(xml_svg_string: string): string {
|
export function extractDiagramXML(xml_svg_string: string): string {
|
||||||
try {
|
try {
|
||||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||||
const svgString = atob(xml_svg_string.slice(26))
|
const svgString = atob(xml_svg_string.slice(26));
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser();
|
||||||
const svgDoc = parser.parseFromString(svgString, "image/svg+xml")
|
const svgDoc = parser.parseFromString(svgString, "image/svg+xml");
|
||||||
const svgElement = svgDoc.querySelector("svg")
|
const svgElement = svgDoc.querySelector('svg');
|
||||||
|
|
||||||
if (!svgElement) {
|
if (!svgElement) {
|
||||||
throw new Error("No SVG element found in the input string.")
|
throw new Error("No SVG element found in the input string.");
|
||||||
}
|
}
|
||||||
// 2. Extract the 'content' attribute
|
// 2. Extract the 'content' attribute
|
||||||
const encodedContent = svgElement.getAttribute("content")
|
const encodedContent = svgElement.getAttribute('content');
|
||||||
|
|
||||||
if (!encodedContent) {
|
if (!encodedContent) {
|
||||||
throw new Error("SVG element does not have a 'content' attribute.")
|
throw new Error("SVG element does not have a 'content' attribute.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Decode HTML entities (using a minimal function)
|
// 3. Decode HTML entities (using a minimal function)
|
||||||
function decodeHtmlEntities(str: string) {
|
function decodeHtmlEntities(str: string) {
|
||||||
const textarea = document.createElement("textarea") // Use built-in element
|
const textarea = document.createElement('textarea'); // Use built-in element
|
||||||
textarea.innerHTML = str
|
textarea.innerHTML = str;
|
||||||
return textarea.value
|
return textarea.value;
|
||||||
}
|
}
|
||||||
const xmlContent = decodeHtmlEntities(encodedContent)
|
const xmlContent = decodeHtmlEntities(encodedContent);
|
||||||
|
|
||||||
// 4. Parse the XML content
|
// 4. Parse the XML content
|
||||||
const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
|
const xmlDoc = parser.parseFromString(xmlContent, "text/xml");
|
||||||
const diagramElement = xmlDoc.querySelector("diagram")
|
const diagramElement = xmlDoc.querySelector('diagram');
|
||||||
|
|
||||||
if (!diagramElement) {
|
if (!diagramElement) {
|
||||||
throw new Error("No diagram element found")
|
throw new Error("No diagram element found");
|
||||||
}
|
}
|
||||||
// 5. Extract base64 encoded data
|
// 5. Extract base64 encoded data
|
||||||
const base64EncodedData = diagramElement.textContent
|
const base64EncodedData = diagramElement.textContent;
|
||||||
|
|
||||||
if (!base64EncodedData) {
|
if (!base64EncodedData) {
|
||||||
throw new Error("No encoded data found in the diagram element")
|
throw new Error("No encoded data found in the diagram element");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Decode base64 data
|
// 6. Decode base64 data
|
||||||
const binaryString = atob(base64EncodedData)
|
const binaryString = atob(base64EncodedData);
|
||||||
|
|
||||||
// 7. Convert binary string to Uint8Array
|
// 7. Convert binary string to Uint8Array
|
||||||
const len = binaryString.length
|
const len = binaryString.length;
|
||||||
const bytes = new Uint8Array(len)
|
const bytes = new Uint8Array(len);
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
bytes[i] = binaryString.charCodeAt(i)
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
|
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
|
||||||
const decompressedData = pako.inflate(bytes, { windowBits: -15 })
|
const decompressedData = pako.inflate(bytes, { windowBits: -15 });
|
||||||
|
|
||||||
// 9. Convert the decompressed data to a string
|
// 9. Convert the decompressed data to a string
|
||||||
const decoder = new TextDecoder("utf-8")
|
const decoder = new TextDecoder('utf-8');
|
||||||
const decodedString = decoder.decode(decompressedData)
|
const decodedString = decoder.decode(decompressedData);
|
||||||
|
|
||||||
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
|
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
|
||||||
const urlDecodedString = decodeURIComponent(decodedString)
|
const urlDecodedString = decodeURIComponent(decodedString);
|
||||||
|
|
||||||
|
return urlDecodedString;
|
||||||
|
|
||||||
return urlDecodedString
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error extracting diagram XML:", error)
|
console.error("Error extracting diagram XML:", error);
|
||||||
throw error // Re-throw for caller handling
|
throw error; // Re-throw for caller handling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
output: "standalone",
|
output: 'standalone',
|
||||||
}
|
};
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig;
|
||||||
|
|||||||
1457
package-lock.json
generated
1457
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,16 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.3.0",
|
"version": "0.2.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack --port 6002",
|
"dev": "next dev --turbopack --port 6002",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start --port 6001",
|
"start": "next start --port 6001",
|
||||||
"lint": "biome lint .",
|
"lint": "next lint"
|
||||||
"format": "biome check --write .",
|
|
||||||
"check": "biome ci",
|
|
||||||
"prepare": "husky"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.62",
|
"@ai-sdk/amazon-bedrock": "^3.0.62",
|
||||||
@@ -21,12 +18,8 @@
|
|||||||
"@ai-sdk/openai": "^2.0.19",
|
"@ai-sdk/openai": "^2.0.19",
|
||||||
"@ai-sdk/react": "^2.0.22",
|
"@ai-sdk/react": "^2.0.22",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@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",
|
"@next/third-parties": "^16.0.6",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
@@ -48,32 +41,19 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-drawio": "^1.0.3",
|
"react-drawio": "^1.0.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-resizable-panels": "^3.0.6",
|
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.1.12"
|
"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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.8",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-next": "16.0.5",
|
"eslint-config-next": "16.0.5",
|
||||||
"husky": "^9.1.7",
|
|
||||||
"lint-staged": "^16.2.7",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: ["@tailwindcss/postcss"],
|
plugins: ["@tailwindcss/postcss"],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default config
|
export default config;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -29,5 +35,7 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user